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;
}
}