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): the ONE decomp-verified /// line gmHouseUI::DisplayPurchaseTimeText @0x004a3110 emits for a /// houseless/fresh character, and the wait-period-not-expired case that /// stays empty (unrecoverable strftime format, ISSUES #413 item 2). /// 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_ShowsBuyImmediatelyLine() { 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); Assert.Equal(["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_RendersNoLine() { // HouseSystem::HasPurchaseWaitPeriodExpired: (now - timestamp) > // 0x278d00 (2,592,000 s = 30 days). Inside the window, retail takes // the strftime-formatted branch this session leaves unported // (ISSUES #413 item 2) — must render nothing, not a guess. 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); Assert.Empty(house.Lines); } [Fact] public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBuyImmediatelyLine() { 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 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 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; public void Advance(TimeSpan elapsed) => _now += elapsed; } }