using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Properties; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// House-tab conformance (Batch C, 2026-08-17; two-line correction at the /// same-day morning gate round): the houseless case renders retail's exact /// TWO lines in builder order — gmHouseUI::DisplayBuyPayment /// @0x004a2b30's houseless branch ("You do not currently own a /// house.", byte-decoded data_7ab688 — the m_pHouseData gate only /// selects WHICH text; the emit is unconditional) followed by /// DisplayPurchaseTimeText @0x004a3110's wait-period line. /// public sealed class RuntimeHouseStateTests { private const uint Self = 0x50000001u; [Fact] public void EmptyBeforeAnyNoticeArrives() { // gmHouseUI::PostInit never calls Update/DisplayHouseData — the // ListBox starts genuinely empty (live-DAT-confirmed: the House // page's ListBox children=0, no other page content). var house = new RuntimeHouseState(); Assert.Empty(house.Lines); Assert.False(house.HasReceivedNotice); } [Fact] public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBothHouselessLines() { var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); // No PropertyInt.HousePurchaseTimestamp set — absent reads as 0, // matching a fresh character that has never purchased or abandoned // a house. HasPurchaseWaitPeriodExpired(0) is trivially true. var house = new RuntimeHouseState(objects); house.ApplyHouseStatus(weenieError: 0u, Self); Assert.True(house.HasReceivedNotice); // 2026-08-17 morning gate finding 2 (user retail screenshot): // BOTH lines, in gmHouseUI::Update's fixed builder order — // DisplayBuyPayment's houseless literal first, then // DisplayPurchaseTimeText's expired-branch literal. Assert.Equal( [ "You do not currently own a house.", "You may buy another house immediately.", ], house.Lines); } [Fact] public void HouseStatus_WeenieErrorValueIsDiscarded() { // Decomp-confirmed: gmHouseUI::Update(uint32_t)/gmMapUI:: // RecvNotice_FailedHouseTransaction never read their arg2. The // rendered text must not depend on the wire WeenieError value. var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); var houseA = new RuntimeHouseState(objects); var houseB = new RuntimeHouseState(objects); houseA.ApplyHouseStatus(weenieError: 0u, Self); houseB.ApplyHouseStatus(weenieError: 0x45Fu /* HouseEvicted */, Self); Assert.Equal(houseA.Lines, houseB.Lines); } [Fact] public void HouseData_OwnedHouseWithExpiredWaitPeriod_ShowsAbandonFirstLine() { var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); var house = new RuntimeHouseState(objects); house.ApplyHouseData(SampleHouseData(), Self); Assert.Equal( ["You may buy another house immediately after you abandon this one."], house.Lines); } [Fact] public void HouseStatus_TimestampWithinThirtyDayWindow_ShowsExpiryDateLine() { // HouseSystem::HasPurchaseWaitPeriodExpired: (now - timestamp) > // 0x278d00 (2,592,000 s = 30 days). Inside the window, retail's // gmHouseUI::DisplayPurchaseTimeText composes prefix + strftime("%c") // of (timestamp + 30 days) + suffix (night-round review F8 — the // "unrecoverable strftime branch" from ISSUES #413 item 2 was // byte-decoded and is now ported). The exact date substring is // locale/timezone-formatted (.NET's honest %c analogue), so this // pins the STRUCTURE (prefix/suffix, non-empty middle), not the // exact rendered date text. var clock = new ManualTimeProvider(); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); var bundle = new PropertyBundle(); bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] = (int)clock.GetUtcNow().ToUnixTimeSeconds(); objects.UpsertProperties(Self, bundle); var house = new RuntimeHouseState(objects, clock); clock.Advance(TimeSpan.FromDays(29)); house.ApplyHouseStatus(weenieError: 0u, Self); // Houseless -> DisplayBuyPayment's line precedes the purchase-time // line; the not-expired branch supplies the second. Assert.Equal(2, house.Lines.Count); Assert.Equal("You do not currently own a house.", house.Lines[0]); string line = house.Lines[1]; Assert.StartsWith("You may buy another landscape house at ", line); Assert.EndsWith(". This restriction does not apply to apartments.", line); } [Fact] public void HouseStatus_TimestampWithinThirtyDayWindow_ExpiryDateIsTimestampPlusThirtyDays() { // Pins the actual computed expiry moment (retail: timestamp + // 0x278d00 = 2,592,000 s, formatted via localtime — this fixture's // LocalTimeZone is UTC, so the rendered date is exactly the UTC // expiry instant with no offset ambiguity). var clock = new ManualTimeProvider(); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); DateTimeOffset purchaseTime = clock.GetUtcNow(); var bundle = new PropertyBundle(); bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] = (int)purchaseTime.ToUnixTimeSeconds(); objects.UpsertProperties(Self, bundle); var house = new RuntimeHouseState(objects, clock); clock.Advance(TimeSpan.FromDays(29)); house.ApplyHouseStatus(weenieError: 0u, Self); DateTime expectedExpiry = purchaseTime.AddSeconds(0x278d00).UtcDateTime; Assert.Equal(2, house.Lines.Count); Assert.Contains( expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), house.Lines[1]); } [Fact] public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBothHouselessLines() { var clock = new ManualTimeProvider(); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); var bundle = new PropertyBundle(); bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] = (int)clock.GetUtcNow().ToUnixTimeSeconds(); objects.UpsertProperties(Self, bundle); var house = new RuntimeHouseState(objects, clock); clock.Advance(TimeSpan.FromDays(31)); house.ApplyHouseStatus(weenieError: 0u, Self); Assert.Equal( [ "You do not currently own a house.", "You may buy another house immediately.", ], house.Lines); } [Fact] public void ResetSession_RestoresGenuinelyEmptyPreNoticeState() { var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); var house = new RuntimeHouseState(objects); house.ApplyHouseStatus(weenieError: 0u, Self); Assert.NotEmpty(house.Lines); house.ResetSession(); Assert.Empty(house.Lines); Assert.False(house.HasReceivedNotice); } [Fact] public void MissingObjectTable_DefaultsTimestampToZero() { // Bare-fixture callers (no ClientObjectTable) must not throw — the // same optional-borrow discipline RuntimeTradeState uses. var house = new RuntimeHouseState(); house.ApplyHouseStatus(weenieError: 0u, Self); Assert.Equal( [ "You do not currently own a house.", "You may buy another house immediately.", ], house.Lines); } private static GameEvents.HouseData SampleHouseData() => new( BuyTime: 0u, RentTime: 0u, Type: 0u, MaintenanceFree: false, Buy: Array.Empty(), Rent: Array.Empty(), Position: new CreateObject.ServerPosition(0u, 0f, 0f, 0f, 1f, 0f, 0f, 0f)); private sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 8, 17, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; // F8: pin LocalTimeZone to UTC so the not-yet-expired branch's // TimeZoneInfo.ConvertTime call is deterministic across machines — // the real production TimeProvider.System.LocalTimeZone is // TimeZoneInfo.Local, matching retail's own localtime() call. public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; public void Advance(TimeSpan elapsed) => _now += elapsed; } }