Derived the mechanism from the decomp before writing code: neither gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a HouseQuery, and six of gmHouseUI's seven Display* builders early-return on m_pHouseData == 0. The only text a houseless character's House tab shows is gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy another house immediately." for a fresh character. Exhaustive search of the 2013 EoR decomp, ACE, and the live DAT found zero support for a second "You do not currently own a house." line the task brief described — this commit ports what the decomp actually shows. Ships: - RuntimeHouseState: a minimal (no disposal, no construction-transaction Fault() point) Runtime owner per ISSUES #413's own sizing note, wired through GameEventWiring's existing HouseData/HouseStatus delegate holes, LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in RuntimeGenerationReset (new House stage) since a fresh login must not show a stale character's house state. - HousePageController.Bindings.Lines/OnShown wired to real data; OnShown fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream trigger, not a ported retail call site — filed in the divergence register). - Fixed a real bug found along the way: HousePageController.Bind never wired UiTemplateListBox.TemplateResolver, so no row could ever render regardless of Lines content. Now reuses the Map tab's generic hotspot resolver. Live-verified against a real local ACE server and the +Acdream character (--session-config auto-select + a UI automation script): screenshot and structural UI-tree dump both confirm the House tab renders exactly "You may buy another house immediately." Graceful logout confirmed both launches. ISSUES #413 narrowed to its one remaining piece: the six owned-house-only Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/ Location/WarningText), unexercisable without a test character that owns a house. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
6 KiB
C#
161 lines
6 KiB
C#
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Properties;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
/// <summary>
|
|
/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified
|
|
/// line <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c> emits for a
|
|
/// houseless/fresh character, and the wait-period-not-expired case that
|
|
/// stays empty (unrecoverable strftime format, ISSUES #413 item 2).
|
|
/// </summary>
|
|
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<GameEvents.HousePayment>(),
|
|
Rent: Array.Empty<GameEvents.HousePayment>(),
|
|
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;
|
|
}
|
|
}
|