acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
Erik ef567bfa20 fix(ui): morning gate — House tab renders retail's TWO houseless lines, not one
User finding 2 (retail screenshot, houseless character): the House tab
shows "You do not currently own a house." ABOVE "You may buy another
house immediately." — ours showed only the second line, and the prior
session had REFUTED the first line outright ("no such string exists
anywhere in the 2013 dump").

Re-derivation: the string exists in the binary at data_7ab688 — it is
gmHouseUI::DisplayBuyPayment @0x004a2b30's HOUSELESS branch. Two
compounding misreads hid it: (a) DisplayBuyPayment was mislabeled
houseless-silent, but its m_pHouseData gate only selects WHICH text
(jne 0x4a2b63) — the ListBox emit (@0x004a2b80 onward,
AddItemFromTemplateList + SetTextWithFont) runs in BOTH branches; and
(b) BN's pseudo-C renders both push-literal operands as spurious
&vftable.RecvNotice_* symbol matches (the TS-85/F3 artifact class), so
text sweeps of the dump find nothing — capstone byte-decode of the
PDB-paired binary resolves houseless @0x004a2b57 push 0x7ab688 =
"You do not currently own a house." and owned @0x004a2b63 push
0x7ab65c = "The purchase price for this dwelling is:\n" (+
HousePaymentList::ComposeText, still #413 item-3 scope). The morning
brief's alternate DAT-string-table hypothesis was checked and is NOT
the mechanism — plain exe string-pool literal.

RuntimeHouseState.Recompute now renders the houseless case as retail's
exact two lines in gmHouseUI::Update's fixed builder order
(DisplayBuyPayment first, DisplayPurchaseTimeText last); the owned case
is unchanged (its DisplayBuyPayment content needs ComposeText, #413
item 3). Class doc + ISSUES #413 corrected honestly — the user's retail
evidence supersedes the earlier refutation. Runtime house tests updated
to pin both lines; 9/9 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 08:54:55 +02:00

227 lines
9.1 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; two-line correction at the
/// same-day morning gate round): the houseless case renders retail's exact
/// TWO lines in builder order — <c>gmHouseUI::DisplayBuyPayment
/// @0x004a2b30</c>'s houseless branch ("You do not currently own a
/// house.", byte-decoded <c>data_7ab688</c> — the m_pHouseData gate only
/// selects WHICH text; the emit is unconditional) followed by
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s wait-period line.
/// </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_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<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;
// 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;
}
}