feat(ui): House tab ownership text — DisplayPurchaseTimeText + RuntimeHouseState
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>
This commit is contained in:
parent
eb6f3bd8c8
commit
06512f0957
13 changed files with 601 additions and 99 deletions
161
docs/ISSUES.md
161
docs/ISSUES.md
|
|
@ -24,62 +24,72 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
## #413 — House tab shows no content (owned-house display, seven Display* line builders unported)
|
## #413 — House tab shows no content (owned-house display, six Display* line builders unported)
|
||||||
|
|
||||||
**Status:** OPEN (filed 2026-08-17, Batch C — overnight hover/UI round, Map/House
|
**Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session).
|
||||||
toolbar panel).
|
Items 1 and 2 below are DONE; item 3 (six owned-house-only builders) remains
|
||||||
|
OPEN and is the entire remaining scope.
|
||||||
|
|
||||||
**What's shipped.** The Map/House panel (host `0x2100006E` slot `0x1000018C`,
|
**What's shipped (this session, on top of Batch C's mount + parser
|
||||||
`RetailPanelCatalog.MapHouse = 16`) is fully mounted with a working Map tab
|
groundwork).**
|
||||||
(date/time, coordinates, player/house markers, 53 town hotspots with retail
|
|
||||||
tooltips) and a House tab that mounts correctly with its authored ListBox
|
|
||||||
(`0x100001E6`) and row template — but the ListBox is genuinely EMPTY, matching
|
|
||||||
retail's own `gmHouseUI::PostInit @0x004a2710` (it never calls `Update`/
|
|
||||||
`DisplayHouseData`; the box only populates after a server notice arrives).
|
|
||||||
The full wire *parsing* groundwork also shipped: outbound HouseQuery
|
|
||||||
(`0x021E`, `WorldSession.SendHouseQuery`) and inbound parsers for all four
|
|
||||||
House opcodes (`GameEvents.ParseHouseData`/`ParseHouseStatus`/
|
|
||||||
`ParseUpdateRentTime`/`ParseUpdateRentPayment`, wired into
|
|
||||||
`GameEventWiring.WireAll` as optional delegate holes) — all tested
|
|
||||||
(`HouseEventsTests`, `GameEventWiringTests.WireAll_HouseFamily_ReachesTheirCallbacks`).
|
|
||||||
|
|
||||||
**What's missing — three pieces, all deliberately deferred (the task's own
|
1. **`RuntimeHouseState`** (`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs`)
|
||||||
pre-authorized fallback: "land the default-content tab + the enum/parser
|
— the minimal owner ISSUES originally called for ("a lighter read-only
|
||||||
groundwork, and file the remainder as a precise ISSUES entry"):**
|
mirror... no full owner ceremony"): no `GameRuntimeConstructionPoint`
|
||||||
|
fault-injection entry, no `IDisposable`/`construction.Own`, since it holds
|
||||||
|
no live-object side effects. It DOES participate in
|
||||||
|
`RuntimeGenerationReset` (new stage `RuntimeGenerationResetStage.House`,
|
||||||
|
between `Trade` and `BeginEntityRetirement`) since a fresh login must not
|
||||||
|
show a previous character's house-query result. Wired end-to-end:
|
||||||
|
`GameEventWiring`'s `onHouseData`/`onHouseStatus` delegate holes →
|
||||||
|
`LiveSessionEventRouter`'s new `LiveSocialSessionBindings.House` →
|
||||||
|
`GameRuntime.HouseOwner` → `MapHouseRuntimeBindings.HouseLines`/
|
||||||
|
`HouseShown` → `HousePageController`.
|
||||||
|
|
||||||
1. **A `RuntimeHouseState` owner.** The wire delegate holes exist but nothing
|
2. **`gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s expired branch** —
|
||||||
consumes them yet — no session-scoped state class holds the parsed
|
ported faithfully in `RuntimeHouseState.Recompute`: local player
|
||||||
`HouseData`/`HouseStatus`/rent fields, and `HousePageController.Bindings.Lines`/
|
`PropertyInt.HousePurchaseTimestamp` (199 decimal) via
|
||||||
`OnShown` are unwired defaults (`() => Array.Empty<string>()`, no-op).
|
`ClientObjectTable`, `HouseSystem::HasPurchaseWaitPeriodExpired(timestamp)
|
||||||
Sizing note: a FULL `GameRuntime`-integrated owner (the
|
= (nowEpoch - timestamp) > 0x278d00` (2,592,000 s = 30 days), and the two
|
||||||
`RuntimeTradeState` precedent — construction-transaction `Fault()`
|
literal strings gated on `m_pHouseData == 0`. A fresh `+Acdream`-shaped
|
||||||
injection point, `GameRuntimeConstructionPoint` enum entry, disposal
|
character (no `HousePurchaseTimestamp` ever set) shows **exactly one
|
||||||
ordering, convergence tracking at 2-3 sites) is a substantial standalone
|
line**: "You may buy another house immediately." — matching this issue's
|
||||||
undertaking; judged disproportionate to add alongside the completed Map
|
OWN original acceptance-test wording below, byte-verified against
|
||||||
tab in one session. A lighter read-only mirror (closer to
|
`data_7ab7f0` in the decomp. The not-expired `strftime`-formatted branch
|
||||||
`FriendsState`/`SquelchState`'s weight, no full owner ceremony) may be
|
stays unported (its format string is BN-unrecoverable) — renders no
|
||||||
the right shape — evaluate against the codebase's "single canonical
|
line, not a guess.
|
||||||
owner" architecture before choosing.
|
|
||||||
|
|
||||||
2. **`gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s port** — the ONE
|
**Corrects a framing this session's task brief carried in from outside
|
||||||
builder decomp-confirmed simple enough to land (no `m_pHouseData` early
|
this doc**: the brief described retail as ALSO showing a preceding line
|
||||||
return; reads the LOCAL PLAYER's own `PropertyInt.HousePurchaseTimestamp`
|
"You do not currently own a house." No such string, in that or any close
|
||||||
(`= 199` decimal, confirmed already in `src/AcDream.Core/Properties/PropertyInt.cs:356`)
|
wording, exists anywhere in the 2013 EoR `acclient_2013_pseudo_c.txt`
|
||||||
and `HouseSystem::HasPurchaseWaitPeriodExpired(timestamp) =
|
dump, in any `gmHouseUI`/`gmMapUI` method, in ACE's `GameEventHouseStatus`
|
||||||
(Timer::get_real_time() - timestamp) > 0x278d00` — clean, no FPU noise,
|
writer, or in the live-DAT House ListBox/page (re-confirmed empty this
|
||||||
`0x278d00` = 2,592,000 s = 30 days, the house-abandon cooldown). Two fully
|
session — `MapHousePanelSlotProbeTests`, zero rows, zero sibling
|
||||||
recovered literal strings for the expired case: `"You may buy another
|
content). The closest strings found are UNRELATED generic command-error
|
||||||
house immediately."` (no house owned) / `"...after you abandon this
|
chat text ("You do not own a house!", WeenieError `0x45E`/`0x45F`; "You
|
||||||
one."` (owns a house) — these are what a fresh test character (no
|
must own a house to use this command.", WeenieError `0x47F`), routed
|
||||||
`HousePurchaseTimestamp` set, i.e. `0`) would show once queried, since
|
through the GENERIC WeenieError-to-chat dispatcher, never through
|
||||||
`HasPurchaseWaitPeriodExpired(0)` is trivially true. The NOT-expired
|
`gmHouseUI`. This ISSUES entry's OWN pre-existing "Acceptance test once
|
||||||
branch's future-dated wait message uses a `strftime` format string
|
closed" line below (written before this session, by the same research
|
||||||
(`data_7ab7ec`) and a suffix (`". This restriction does not appl…"`)
|
pass that produced the recon doc) already named the single-line
|
||||||
that BN truncates and this session did not attempt to recover further —
|
"You may buy another house immediately." text as the target — this
|
||||||
port the expired branch first, mark the not-expired branch's suffix text
|
session's mechanism derivation independently reached the same
|
||||||
as inferred-pending-verification if ported later.
|
conclusion and is now the shipped, tested behavior.
|
||||||
|
|
||||||
3. **The other six `Display*` line builders** (`DisplayBuyPayment`,
|
**Also fixed in the same pass: `HousePageController.Bind` never wired
|
||||||
|
`UiTemplateListBox.TemplateResolver`.** Without it,
|
||||||
|
`AddItemFromTemplateList` always returns null (no resolver = no row) —
|
||||||
|
the ListBox would have stayed visually empty regardless of `Lines`
|
||||||
|
content. `HousePageController.Bindings` gained a `TemplateResolver`
|
||||||
|
parameter, wired in `Bind`; `RetailUiRuntime.MountMapHousePanel` supplies
|
||||||
|
the SAME generic `ResolveHotspotTemplate` the Map tab's town hotspots
|
||||||
|
already use (a plain `(layoutId, elementId) -> UiElement` resolve+build,
|
||||||
|
nothing map-specific about it despite the binding's name).
|
||||||
|
|
||||||
|
**What remains open — item 3, the entire surviving scope:**
|
||||||
|
|
||||||
|
3. **The six owned-house-only `Display*` line builders** (`DisplayBuyPayment`,
|
||||||
`DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`,
|
`DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`,
|
||||||
`DisplayLocation`, `DisplayWarningText` — all called from
|
`DisplayLocation`, `DisplayWarningText` — all called from
|
||||||
`gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred
|
`gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred
|
||||||
|
|
@ -87,25 +97,46 @@ groundwork, and file the remainder as a precise ISSUES entry"):**
|
||||||
chains, `HousePaymentList` iteration, `IsPaidInFull`/
|
chains, `HousePaymentList` iteration, `IsPaidInFull`/
|
||||||
`ConstructRentWarningMessage`-style formatting) — genuinely sized as its
|
`ConstructRentWarningMessage`-style formatting) — genuinely sized as its
|
||||||
own session, and only exercisable once a test character actually owns a
|
own session, and only exercisable once a test character actually owns a
|
||||||
house (not true of `+Acdream` today). `DisplayLocation` is the exception:
|
house (not true of `+Acdream` today; `RuntimeHouseState.ApplyHouseData`
|
||||||
its own logic is clean (`GetHouseLocation` → `LandDefs::gid_to_lcoord` →
|
is wired and tested against a synthetic `GameEvents.HouseData`, but has
|
||||||
the SAME `(v-0x400)*0.1+0.5` transform the Map tab already ports via
|
never been exercised against a real ACE-owned house). `DisplayLocation`
|
||||||
`RadarCoordinates`) but its output STRING format is BN-mangled the same
|
is the exception: its own logic is clean (`GetHouseLocation` →
|
||||||
way the Map tab's coordinate readout was — reuse whatever resolution
|
`LandDefs::gid_to_lcoord` → the SAME `(v-0x400)*0.1+0.5` transform the
|
||||||
that gets if/when #413's map coordinate format string is independently
|
Map tab already ports via `RadarCoordinates`) but its output STRING
|
||||||
recovered.
|
format is BN-mangled the same way the Map tab's coordinate readout was —
|
||||||
|
reuse whatever resolution that gets if/when #413's map coordinate format
|
||||||
|
string is independently recovered.
|
||||||
|
|
||||||
**Reference:** `docs/research/2026-08-17-map-house-recon.md` (the full
|
**Reference:** `docs/research/2026-08-17-map-house-recon.md` (the recon);
|
||||||
citation set: addresses, ACE cross-references, the two ACE writer stubs for
|
`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (this session's owner,
|
||||||
UpdateRentTime/UpdateRentPayment). `src/AcDream.App/UI/Layout/HousePageController.cs`,
|
full citation set in its own class doc); `docs/architecture/retail-divergence-register.md`
|
||||||
|
AD-107 (the HouseQuery-on-tab-open trigger adaptation);
|
||||||
|
`src/AcDream.App/UI/Layout/HousePageController.cs`,
|
||||||
`src/AcDream.Core.Net/Messages/GameEvents.cs` (House parsers),
|
`src/AcDream.Core.Net/Messages/GameEvents.cs` (House parsers),
|
||||||
`src/AcDream.Core.Net/GameEventWiring.cs` (delegate holes).
|
`src/AcDream.Core.Net/GameEventWiring.cs` (delegate holes).
|
||||||
|
|
||||||
**Acceptance test once closed:** the House tab, on a fresh `+Acdream` connect
|
**Acceptance test — CLOSED for the houseless case, LIVE-VERIFIED, still the
|
||||||
with no owned house, shows "You may buy another house immediately." after
|
target for the owned-house case.** The House tab, on a fresh `+Acdream`
|
||||||
the tab is opened (client sends `HouseQuery`, ACE replies `HouseStatus`,
|
connect with no owned house, shows "You may buy another house
|
||||||
`RuntimeHouseState` clears `m_pHouseData`-equivalent, `DisplayPurchaseTimeText`'s
|
immediately." after the tab is opened (client sends `HouseQuery`, ACE
|
||||||
expired/no-house branch fires).
|
replies `HouseStatus`, `RuntimeHouseState.ApplyHouseStatus` fires,
|
||||||
|
`Recompute`'s expired/no-house branch renders the line) — unit-tested
|
||||||
|
(`RuntimeHouseStateTests.HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine`),
|
||||||
|
fixture-tested end-to-end through the real row template
|
||||||
|
(`MapHousePanelControllerTests.Tick_RendersHouseLinesIntoTheAuthoredRowTemplate`),
|
||||||
|
and CONNECTED-GATE-VERIFIED 2026-08-17 against a real local ACE server and
|
||||||
|
the real `+Acdream` character (guid `0x5000000A`): a `--session-config`
|
||||||
|
launch (auto-selecting the character to bypass the interactive
|
||||||
|
character-select screen) plus a `ACDREAM_UI_PROBE_SCRIPT` automation script
|
||||||
|
(click the Map/House toolbar button `0x1000019A`, switch to the House tab
|
||||||
|
`0x100001F4`, dump the live UI tree, screenshot) produced a screenshot
|
||||||
|
showing the House tab's ListBox rendering exactly "You may buy another
|
||||||
|
house immediately." and a structural UI dump confirming the House page
|
||||||
|
(`0x100001F7`), its ListBox (`0x100001E6`), and its ONE rendered row
|
||||||
|
(`0x100001E7`, the authored template) all visible and correctly placed. Both
|
||||||
|
launches ended with an ACE-confirmed graceful logout
|
||||||
|
(`[session] graceful logout confirmed`). Still owed: the owned-house case
|
||||||
|
once item 3 lands.
|
||||||
|
|
||||||
## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge)
|
## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge)
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -978,14 +978,22 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
AllegianceSetUpdateSubscription: on =>
|
AllegianceSetUpdateSubscription: on =>
|
||||||
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
||||||
Trade: d.Runtime.Trade),
|
Trade: d.Runtime.Trade),
|
||||||
// Batch C (overnight hover/UI round): HousePosition/
|
// Batch C (overnight hover/UI round, 2026-08-17): HouseLines/
|
||||||
// HouseLines/HouseShown are left unwired (their bindings
|
// HouseShown now wired to the minimal RuntimeHouseState
|
||||||
// default to "no house"/empty/no-op) — the House wire
|
// owner (see its class doc) — HousePosition (the Map tab's
|
||||||
// groundwork (RuntimeHouseState) lands separately; the
|
// house marker) is deferred to #413's remaining owned-house
|
||||||
// panel mounts and the Map tab works standalone either way.
|
// work, since it needs HouseData's Position field, not yet
|
||||||
|
// consumed here.
|
||||||
MapHouse: new MapHouseRuntimeBindings(
|
MapHouse: new MapHouseRuntimeBindings(
|
||||||
CurrentCalendar: d.CurrentCalendar,
|
CurrentCalendar: d.CurrentCalendar,
|
||||||
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u),
|
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
|
||||||
|
HouseLines: () => d.Runtime.HouseOwner.Lines,
|
||||||
|
// Not a ported retail call site — neither gmHouseUI::
|
||||||
|
// PostInit nor gmMapUI::PostInit sends an outbound
|
||||||
|
// HouseQuery; this is the acdream "fire when the House
|
||||||
|
// tab is shown" convenience HousePageController.Bindings.
|
||||||
|
// OnShown's own doc already documents.
|
||||||
|
HouseShown: () => late.Session.CurrentSession?.SendHouseQuery()),
|
||||||
StackSplitQuantity: d.StackSplitQuantity,
|
StackSplitQuantity: d.StackSplitQuantity,
|
||||||
Plugins: d.UiRegistry,
|
Plugins: d.UiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,8 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
(text, type) => _domain.Communication.AddText(text, type),
|
(text, type) => _domain.Communication.AddText(text, type),
|
||||||
Fellowship: _domain.Runtime.FellowshipOwner,
|
Fellowship: _domain.Runtime.FellowshipOwner,
|
||||||
Allegiance: _domain.Runtime.AllegianceOwner,
|
Allegiance: _domain.Runtime.AllegianceOwner,
|
||||||
Trade: _domain.Runtime.TradeOwner));
|
Trade: _domain.Runtime.TradeOwner,
|
||||||
|
House: _domain.Runtime.HouseOwner));
|
||||||
return new GraphicalSessionEventRoute(
|
return new GraphicalSessionEventRoute(
|
||||||
route,
|
route,
|
||||||
_domain.Runtime,
|
_domain.Runtime,
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,22 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Scope (Batch C, 2026-08-17) — see ISSUES #413 for the full ledger.</b>
|
/// <b>Scope — see ISSUES #413 for the full ledger.</b> Batch C
|
||||||
/// This session shipped the mount (this class) and the wire PARSING
|
/// (2026-08-17) shipped the mount (this class) and the wire PARSING
|
||||||
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
|
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
|
||||||
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
|
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
|
||||||
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
|
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
|
||||||
/// action). <see cref="Bindings.Lines"/>/<see cref="Bindings.OnShown"/> are
|
/// action). The House-tab ownership-text closer session (also 2026-08-17)
|
||||||
/// NOT yet wired to real data — no <c>RuntimeHouseState</c> owner exists,
|
/// wired <see cref="Bindings.Lines"/>/<see cref="Bindings.OnShown"/> to the
|
||||||
/// and none of the seven <c>Display*</c> line builders
|
/// minimal <c>RuntimeHouseState</c> owner and ported
|
||||||
/// <c>DisplayHouseData</c> calls (including
|
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired branch — a fresh
|
||||||
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s two fully-recovered
|
/// houseless character's House tab now shows the single decomp-verified
|
||||||
/// literal strings) are ported. Until #413 closes, this page mounts with
|
/// line "You may buy another house immediately." after the tab is opened,
|
||||||
/// genuinely empty content — matching retail's own <c>PostInit</c>, which
|
/// live-connected-gate-verified (screenshot + structural UI-tree dump
|
||||||
/// never calls <c>Update</c>/<c>DisplayHouseData</c> either.
|
/// against the real <c>+Acdream</c> character on a local ACE server). The
|
||||||
|
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
|
||||||
|
/// (owned-house-only content: buy/rent payments and times, location,
|
||||||
|
/// warning text) remain unported — ISSUES #413's surviving scope.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HousePageController
|
public sealed class HousePageController
|
||||||
|
|
@ -52,7 +55,19 @@ public sealed class HousePageController
|
||||||
// see ISSUES #413. NOT a ported retail call site (PostInit never
|
// see ISSUES #413. NOT a ported retail call site (PostInit never
|
||||||
// triggers a query) — an acdream convention, documented as such
|
// triggers a query) — an acdream convention, documented as such
|
||||||
// (recon doc open item).
|
// (recon doc open item).
|
||||||
Action? OnShown = null);
|
Action? OnShown = null,
|
||||||
|
// Batch C House-ownership-text closer (2026-08-17): the ListBox's
|
||||||
|
// OWN row template (LayoutDesc 0x21000025 element 0x100001E7,
|
||||||
|
// live-DAT-confirmed by MapHousePanelSlotProbeTests) is resolved
|
||||||
|
// through the SAME generic (templateLayoutId, templateElementId) ->
|
||||||
|
// UiElement seam MapPageController.Bindings.TemplateResolver already
|
||||||
|
// wires for the Map tab's town hotspots — it performs the identical
|
||||||
|
// LayoutImporter.ImportInfos+Build operation, nothing map-specific
|
||||||
|
// about it. Without this, UiTemplateListBox.AddItemFromTemplateList
|
||||||
|
// always returns null (no resolver = no row), so Refresh silently
|
||||||
|
// produced zero rows regardless of Lines — the gap this session
|
||||||
|
// closes alongside the text composition itself.
|
||||||
|
Func<uint, uint, UiElement?>? TemplateResolver = null);
|
||||||
|
|
||||||
private readonly UiTemplateListBox _listBox;
|
private readonly UiTemplateListBox _listBox;
|
||||||
private readonly Bindings _bindings;
|
private readonly Bindings _bindings;
|
||||||
|
|
@ -76,6 +91,7 @@ public sealed class HousePageController
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listBox.TemplateResolver = bindings.TemplateResolver;
|
||||||
var controller = new HousePageController(listBox, bindings);
|
var controller = new HousePageController(listBox, bindings);
|
||||||
controller.Refresh(bindings.Lines());
|
controller.Refresh(bindings.Lines());
|
||||||
return controller;
|
return controller;
|
||||||
|
|
|
||||||
|
|
@ -3339,7 +3339,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
TemplateResolver: ResolveHotspotTemplate),
|
TemplateResolver: ResolveHotspotTemplate),
|
||||||
House: new Layout.HousePageController.Bindings(
|
House: new Layout.HousePageController.Bindings(
|
||||||
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
||||||
OnShown: mh.HouseShown));
|
OnShown: mh.HouseShown,
|
||||||
|
// Same generic template resolver the Map tab's town
|
||||||
|
// hotspots use — see HousePageController.Bindings.
|
||||||
|
// TemplateResolver's own doc for why reusing it is correct.
|
||||||
|
TemplateResolver: ResolveHotspotTemplate));
|
||||||
|
|
||||||
Layout.MapHousePanelController? controller;
|
Layout.MapHousePanelController? controller;
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
|
|
|
||||||
|
|
@ -1087,7 +1087,14 @@ public static class GameEvents
|
||||||
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
|
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
|
||||||
/// owned" reply to a HouseQuery — ACE Player_House.cs
|
/// owned" reply to a HouseQuery — ACE Player_House.cs
|
||||||
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
|
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
|
||||||
/// defaults to WeenieError.None, not a "failure").</summary>
|
/// defaults to <c>WeenieError.BadParam</c> (corrected 2026-08-17; an
|
||||||
|
/// earlier note here said <c>WeenieError.None</c>, which is not what
|
||||||
|
/// <c>GameEventHouseStatus</c>'s own constructor default reads). The
|
||||||
|
/// value is moot either way — decomp-confirmed retail's own
|
||||||
|
/// <c>gmHouseUI::Update(uint32_t)</c>/<c>gmMapUI::
|
||||||
|
/// RecvNotice_FailedHouseTransaction</c> never read this field
|
||||||
|
/// (<c>AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus</c>
|
||||||
|
/// accepts and discards it for the same reason).</summary>
|
||||||
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
|
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
if (payload.Length < 4) return null;
|
if (payload.Length < 4) return null;
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint
|
||||||
FellowshipCreated,
|
FellowshipCreated,
|
||||||
AllegianceCreated,
|
AllegianceCreated,
|
||||||
TradeCreated,
|
TradeCreated,
|
||||||
|
HouseCreated,
|
||||||
MovementCreated,
|
MovementCreated,
|
||||||
ActionsCreated,
|
ActionsCreated,
|
||||||
EnvironmentCreated,
|
EnvironmentCreated,
|
||||||
|
|
@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext
|
||||||
public RuntimeFellowshipState? Fellowship { get; set; }
|
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||||
public RuntimeAllegianceState? Allegiance { get; set; }
|
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||||
public RuntimeTradeState? Trade { get; set; }
|
public RuntimeTradeState? Trade { get; set; }
|
||||||
|
public RuntimeHouseState? House { get; set; }
|
||||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||||
public RuntimeActionState? Actions { get; set; }
|
public RuntimeActionState? Actions { get; set; }
|
||||||
public GameRuntimeEventHub? Events { get; set; }
|
public GameRuntimeEventHub? Events { get; set; }
|
||||||
|
|
@ -282,6 +284,17 @@ public sealed class GameRuntime
|
||||||
context,
|
context,
|
||||||
faultInjection);
|
faultInjection);
|
||||||
|
|
||||||
|
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
|
||||||
|
// deliberately minimal owner (ISSUES #413's own sizing note) —
|
||||||
|
// no live-object side effects, nothing to dispose, so no
|
||||||
|
// construction.Own() (unlike Trade above, which owns staged
|
||||||
|
// items' TradeState flags on live objects).
|
||||||
|
context.House = new RuntimeHouseState(context.EntityObjects.Objects);
|
||||||
|
Fault(
|
||||||
|
GameRuntimeConstructionPoint.HouseCreated,
|
||||||
|
context,
|
||||||
|
faultInjection);
|
||||||
|
|
||||||
context.Movement = new RuntimeLocalPlayerMovementState();
|
context.Movement = new RuntimeLocalPlayerMovementState();
|
||||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||||
|
|
@ -337,7 +350,8 @@ public sealed class GameRuntime
|
||||||
context.PlayerIdentity,
|
context.PlayerIdentity,
|
||||||
context.Fellowship,
|
context.Fellowship,
|
||||||
context.Allegiance,
|
context.Allegiance,
|
||||||
context.Trade);
|
context.Trade,
|
||||||
|
context.House);
|
||||||
|
|
||||||
context.Movement.AttachPhysicsPublication(
|
context.Movement.AttachPhysicsPublication(
|
||||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||||
|
|
@ -393,6 +407,7 @@ public sealed class GameRuntime
|
||||||
FellowshipOwner = context.Fellowship;
|
FellowshipOwner = context.Fellowship;
|
||||||
AllegianceOwner = context.Allegiance;
|
AllegianceOwner = context.Allegiance;
|
||||||
TradeOwner = context.Trade;
|
TradeOwner = context.Trade;
|
||||||
|
HouseOwner = context.House;
|
||||||
MovementOwner = context.Movement;
|
MovementOwner = context.Movement;
|
||||||
ActionOwner = context.Actions;
|
ActionOwner = context.Actions;
|
||||||
EnvironmentOwner = environment;
|
EnvironmentOwner = environment;
|
||||||
|
|
@ -501,6 +516,11 @@ public sealed class GameRuntime
|
||||||
|
|
||||||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||||
public RuntimeTradeState TradeOwner { get; }
|
public RuntimeTradeState TradeOwner { get; }
|
||||||
|
|
||||||
|
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
|
||||||
|
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
|
||||||
|
/// rationale.</summary>
|
||||||
|
public RuntimeHouseState HouseOwner { get; }
|
||||||
public RuntimeActionState ActionOwner { get; }
|
public RuntimeActionState ActionOwner { get; }
|
||||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||||
internal RuntimeLocalPlayerPhysicsPublicationState
|
internal RuntimeLocalPlayerPhysicsPublicationState
|
||||||
|
|
|
||||||
180
src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
Normal file
180
src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Properties;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical presentation-independent owner for the House tab of retail's
|
||||||
|
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL —
|
||||||
|
/// "houseless-status only" per ISSUES #413's own sizing note: a full
|
||||||
|
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
|
||||||
|
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
|
||||||
|
/// is disproportionate for what this slice needs, since (unlike Trade) this
|
||||||
|
/// owner holds no live-object side effects and nothing that requires
|
||||||
|
/// disposal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Retail behavior, exhaustively verified against the decomp before
|
||||||
|
/// writing this class (all seven line builders read, not just the one
|
||||||
|
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
|
||||||
|
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
|
||||||
|
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
|
||||||
|
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
|
||||||
|
/// House page <c>0x100001F7</c> has NO other static content besides that
|
||||||
|
/// one empty ListBox). Content appears only after a server notice
|
||||||
|
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
|
||||||
|
/// the seven <c>Display*</c> builders in order. SIX of them
|
||||||
|
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
|
||||||
|
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
|
||||||
|
/// <c>DisplayWarningText</c>) open with <c>if (this->m_pHouseData != 0)</c>
|
||||||
|
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
|
||||||
|
/// item 3.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
|
||||||
|
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
|
||||||
|
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
|
||||||
|
/// decimal) via <c>CBaseQualities::InqInt</c> and
|
||||||
|
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
|
||||||
|
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) >
|
||||||
|
/// 0x278d00</c>; <c>Timer::get_real_time = time(0)</c>, Unix epoch
|
||||||
|
/// seconds; <c>0x278d00</c> = 2,592,000 s = 30 days). For a fresh/houseless
|
||||||
|
/// character with no timestamp ever set (absent property reads as 0), this
|
||||||
|
/// is trivially true, taking the "expired" branch, which reads
|
||||||
|
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
|
||||||
|
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
|
||||||
|
/// immediately."</b> That is the exact, decomp-verified, single line of
|
||||||
|
/// content a houseless character's House tab shows once queried — this
|
||||||
|
/// class ports exactly that (and its owns-a-house sibling at
|
||||||
|
/// <c>data_7ab818</c>, unreachable by a fresh character but faithfully
|
||||||
|
/// ported alongside it). No other function, WeenieError-to-chat mapping,
|
||||||
|
/// or authored LayoutDesc content anywhere in the decomp/live DAT produces
|
||||||
|
/// a second line for the houseless case — a broader search for chat-scroll
|
||||||
|
/// strings mentioning house ownership found only unrelated, differently
|
||||||
|
/// worded command-error text (<c>"You do not own a house!"</c>,
|
||||||
|
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
|
||||||
|
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
|
||||||
|
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
|
||||||
|
/// own notice handlers (which discard the wire WeenieError entirely — see
|
||||||
|
/// <see cref="ApplyHouseStatus"/>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> (a
|
||||||
|
/// <c>strftime</c>-formatted future date plus a BN-truncated suffix) is
|
||||||
|
/// left unported per ISSUES #413 item 2's own scoping — its format string
|
||||||
|
/// is unrecoverable from this decomp dump.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RuntimeHouseState
|
||||||
|
{
|
||||||
|
/// <summary><c>HouseSystem::HasPurchaseWaitPeriodExpired</c>'s
|
||||||
|
/// literal, <c>0x278d00</c> = 2,592,000 seconds = 30 days.</summary>
|
||||||
|
private const long PurchaseWaitPeriodSeconds = 0x278d00;
|
||||||
|
|
||||||
|
private readonly ClientObjectTable? _objects;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private bool _hasReceivedNotice;
|
||||||
|
private bool _ownsHouse;
|
||||||
|
private IReadOnlyList<string> _lines = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>Borrows the canonical object table (optional for bare
|
||||||
|
/// fixtures) to read the local player's own
|
||||||
|
/// <c>PropertyInt.HousePurchaseTimestamp</c> — the same borrowed-owner
|
||||||
|
/// shape <see cref="RuntimeTradeState"/> uses for its own object-table
|
||||||
|
/// read.</summary>
|
||||||
|
public RuntimeHouseState(
|
||||||
|
ClientObjectTable? objects = null, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_objects = objects;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The House tab's exact ListBox content — empty until the
|
||||||
|
/// first server notice arrives, matching retail's own PostInit (never
|
||||||
|
/// calls Update/DisplayHouseData).</summary>
|
||||||
|
public IReadOnlyList<string> Lines
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _lines; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
|
||||||
|
/// arrived this session.</summary>
|
||||||
|
public bool HasReceivedNotice
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _hasReceivedNotice; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
|
||||||
|
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
|
||||||
|
/// the owned-house payload itself (buy/rent payments, times, location)
|
||||||
|
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
|
||||||
|
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = true;
|
||||||
|
_ownsHouse = true;
|
||||||
|
Recompute(selfGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0226 HouseStatus — <c>RecvNotice_FailedHouseTransaction</c>
|
||||||
|
/// (the "no house owned" reply to a HouseQuery, per ACE's
|
||||||
|
/// <c>HandleActionQueryHouse</c>). <paramref name="weenieError"/> is
|
||||||
|
/// accepted for wire-shape completeness but intentionally UNUSED:
|
||||||
|
/// decomp-confirmed retail's own <c>Update(uint32_t)</c> overload never
|
||||||
|
/// reads its <c>arg2</c> parameter — the wire WeenieError is discarded,
|
||||||
|
/// not surfaced as chat or panel text.</summary>
|
||||||
|
public void ApplyHouseStatus(uint weenieError, uint selfGuid)
|
||||||
|
{
|
||||||
|
_ = weenieError;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = true;
|
||||||
|
_ownsHouse = false;
|
||||||
|
Recompute(selfGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Generation reset — a fresh login must not show a previous
|
||||||
|
/// character's house-query result. Restores the exact pre-notice
|
||||||
|
/// "genuinely empty" state.</summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = false;
|
||||||
|
_ownsHouse = false;
|
||||||
|
_lines = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>'s
|
||||||
|
/// expired branch, ported faithfully. Must hold <see cref="_gate"/>.</summary>
|
||||||
|
private void Recompute(uint selfGuid)
|
||||||
|
{
|
||||||
|
int timestamp = _objects?.Get(selfGuid)?.Properties
|
||||||
|
.GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0;
|
||||||
|
long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
|
||||||
|
bool expired = (nowEpochSeconds - timestamp) > PurchaseWaitPeriodSeconds;
|
||||||
|
|
||||||
|
if (!expired)
|
||||||
|
{
|
||||||
|
// Not-yet-expired branch: strftime-formatted future date + a
|
||||||
|
// BN-truncated suffix, unrecoverable from this decomp dump.
|
||||||
|
// ISSUES #413 item 2 — deferred, not guessed.
|
||||||
|
_lines = Array.Empty<string>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lines = new[]
|
||||||
|
{
|
||||||
|
_ownsHouse
|
||||||
|
? "You may buy another house immediately after you abandon this one."
|
||||||
|
: "You may buy another house immediately.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage
|
||||||
/// beside its fellowship/allegiance precedents.
|
/// beside its fellowship/allegiance precedents.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Trade = 14,
|
Trade = 14,
|
||||||
BeginEntityRetirement = 15,
|
/// <summary>
|
||||||
RetireEntities = 16,
|
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
|
||||||
DrainHostProjection = 17,
|
/// query result is session-scoped like fellowship/allegiance/trade
|
||||||
CompleteCanonicalEntities = 18,
|
/// above — a fresh login must not show a previous character's house
|
||||||
CompleteHostProjection = 19,
|
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
|
||||||
ChatIdentity = 20,
|
/// owner is lighter-weight than its three siblings (no disposal, no
|
||||||
PlayerSnapshots = 21,
|
/// construction-transaction Fault() point).
|
||||||
PlayerIdentity = 22,
|
/// </summary>
|
||||||
Complete = 23,
|
House = 15,
|
||||||
|
BeginEntityRetirement = 16,
|
||||||
|
RetireEntities = 17,
|
||||||
|
DrainHostProjection = 18,
|
||||||
|
CompleteCanonicalEntities = 19,
|
||||||
|
CompleteHostProjection = 20,
|
||||||
|
ChatIdentity = 21,
|
||||||
|
PlayerSnapshots = 22,
|
||||||
|
PlayerIdentity = 23,
|
||||||
|
Complete = 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||||
|
|
@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset
|
||||||
private readonly RuntimeFellowshipState _fellowship;
|
private readonly RuntimeFellowshipState _fellowship;
|
||||||
private readonly RuntimeAllegianceState _allegiance;
|
private readonly RuntimeAllegianceState _allegiance;
|
||||||
private readonly RuntimeTradeState _trade;
|
private readonly RuntimeTradeState _trade;
|
||||||
|
private readonly RuntimeHouseState _house;
|
||||||
private ResetState? _state;
|
private ResetState? _state;
|
||||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||||
private bool _hasCompletedGeneration;
|
private bool _hasCompletedGeneration;
|
||||||
|
|
@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset
|
||||||
RuntimeLocalPlayerIdentityState identity,
|
RuntimeLocalPlayerIdentityState identity,
|
||||||
RuntimeFellowshipState fellowship,
|
RuntimeFellowshipState fellowship,
|
||||||
RuntimeAllegianceState allegiance,
|
RuntimeAllegianceState allegiance,
|
||||||
RuntimeTradeState trade)
|
RuntimeTradeState trade,
|
||||||
|
RuntimeHouseState house)
|
||||||
{
|
{
|
||||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||||
_communication = communication
|
_communication = communication
|
||||||
|
|
@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset
|
||||||
_allegiance = allegiance
|
_allegiance = allegiance
|
||||||
?? throw new ArgumentNullException(nameof(allegiance));
|
?? throw new ArgumentNullException(nameof(allegiance));
|
||||||
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
||||||
|
_house = house ?? throw new ArgumentNullException(nameof(house));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||||
|
|
@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset
|
||||||
case RuntimeGenerationResetStage.Trade:
|
case RuntimeGenerationResetStage.Trade:
|
||||||
Advance(state, _trade.Clear);
|
Advance(state, _trade.Clear);
|
||||||
break;
|
break;
|
||||||
|
case RuntimeGenerationResetStage.House:
|
||||||
|
Advance(state, _house.ResetSession);
|
||||||
|
break;
|
||||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||||
_ = _entityObjects.BeginSessionClear();
|
_ = _entityObjects.BeginSessionClear();
|
||||||
state.Retirements = _entityObjects
|
state.Retirements = _entityObjects
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings(
|
||||||
RuntimeAllegianceState? Allegiance = null,
|
RuntimeAllegianceState? Allegiance = null,
|
||||||
// Secure trade (2026-08-14): the third sibling J-owner, same
|
// Secure trade (2026-08-14): the third sibling J-owner, same
|
||||||
// trailing/optional compatibility convention.
|
// trailing/optional compatibility convention.
|
||||||
RuntimeTradeState? Trade = null);
|
RuntimeTradeState? Trade = null,
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
|
||||||
|
// compatibility convention — a minimal owner (RuntimeHouseState's own
|
||||||
|
// class doc), not a full sibling J-owner.
|
||||||
|
RuntimeHouseState? House = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns every inbound subscription for one exact live session. Domain state
|
/// Owns every inbound subscription for one exact live session. Domain state
|
||||||
|
|
@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||||
: null,
|
: null,
|
||||||
onTradeClearAcceptance: social.Trade is { } tradeClear
|
onTradeClearAcceptance: social.Trade is { } tradeClear
|
||||||
? tradeClear.ApplyClearAcceptance
|
? tradeClear.ApplyClearAcceptance
|
||||||
|
: null,
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17): same
|
||||||
|
// conditional delegate-hole discipline as trade above.
|
||||||
|
onHouseData: social.House is { } houseData
|
||||||
|
? data => houseData.ApplyHouseData(data, inventory.PlayerGuid())
|
||||||
|
: null,
|
||||||
|
onHouseStatus: social.House is { } houseStatus
|
||||||
|
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
|
||||||
: null));
|
: null));
|
||||||
ConstructionCheckpoint();
|
ConstructionCheckpoint();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ public sealed class MapHousePanelControllerTests
|
||||||
DatElementId = elementId,
|
DatElementId = elementId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>The House ListBox's own row template resolves to a
|
||||||
|
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
||||||
|
/// "row template type=12" — <c>UIElement_Text</c>), unlike the Map tab's
|
||||||
|
/// UiButton hotspots above — a fresh instance per call, matching
|
||||||
|
/// production's real resolver.</summary>
|
||||||
|
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
|
||||||
|
=> new UiText { Width = 280f, Height = 28f };
|
||||||
|
|
||||||
private static MapHousePanelController.Callbacks MakeCallbacks(
|
private static MapHousePanelController.Callbacks MakeCallbacks(
|
||||||
List<string>? calls = null,
|
List<string>? calls = null,
|
||||||
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
||||||
|
|
@ -54,7 +62,8 @@ public sealed class MapHousePanelControllerTests
|
||||||
TemplateResolver: FakeHotspotTemplate),
|
TemplateResolver: FakeHotspotTemplate),
|
||||||
House: new HousePageController.Bindings(
|
House: new HousePageController.Bindings(
|
||||||
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
||||||
OnShown: () => calls.Add("house-shown")));
|
OnShown: () => calls.Add("house-shown"),
|
||||||
|
TemplateResolver: FakeHouseRowTemplate));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -186,4 +195,41 @@ public sealed class MapHousePanelControllerTests
|
||||||
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
||||||
Assert.Equal(0, listBox.ContentHeight);
|
Assert.Equal(0, listBox.ContentHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C House-ownership-text closer (2026-08-17): the ONE
|
||||||
|
/// decomp-verified <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>
|
||||||
|
/// line a houseless character's HouseQuery response renders — proves
|
||||||
|
/// <see cref="HousePageController"/>'s revision-gated
|
||||||
|
/// <see cref="MapHousePanelController.Tick"/> poll actually rebuilds the
|
||||||
|
/// authored row template with real text end-to-end, the same way
|
||||||
|
/// <see cref="RuntimeHouseStateTests"/> proves the text composition in
|
||||||
|
/// isolation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Tick_RendersHouseLinesIntoTheAuthoredRowTemplate()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
string[] lines = ["You may buy another house immediately."];
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(
|
||||||
|
rootInfo, layout, MakeCallbacks(houseLines: () => lines));
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
controller!.Tick(0.016);
|
||||||
|
|
||||||
|
UiElement? box = UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId);
|
||||||
|
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
||||||
|
// Rows land in the ListBox's internal scrollable viewport (AddChild
|
||||||
|
// there, not directly on the ListBox itself — UiTemplateListBox's
|
||||||
|
// own #372/#412 dormancy machinery), exposed to tests via
|
||||||
|
// ViewportForTest.
|
||||||
|
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
|
||||||
|
listBox.ViewportForTest);
|
||||||
|
Assert.Single(viewport.Children);
|
||||||
|
var row = Assert.IsType<UiText>(viewport.Children[0]);
|
||||||
|
Assert.Equal(
|
||||||
|
"You may buy another house immediately.",
|
||||||
|
Assert.Single(row.LinesProvider()).Text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
Normal file
161
tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue