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_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); string line = Assert.Single(house.Lines); 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; string line = Assert.Single(house.Lines); Assert.Contains(expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), line); } [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; // 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; } }