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
|
|
@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint
|
|||
FellowshipCreated,
|
||||
AllegianceCreated,
|
||||
TradeCreated,
|
||||
HouseCreated,
|
||||
MovementCreated,
|
||||
ActionsCreated,
|
||||
EnvironmentCreated,
|
||||
|
|
@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext
|
|||
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||
public RuntimeTradeState? Trade { get; set; }
|
||||
public RuntimeHouseState? House { get; set; }
|
||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||
public RuntimeActionState? Actions { get; set; }
|
||||
public GameRuntimeEventHub? Events { get; set; }
|
||||
|
|
@ -282,6 +284,17 @@ public sealed class GameRuntime
|
|||
context,
|
||||
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();
|
||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||
|
|
@ -337,7 +350,8 @@ public sealed class GameRuntime
|
|||
context.PlayerIdentity,
|
||||
context.Fellowship,
|
||||
context.Allegiance,
|
||||
context.Trade);
|
||||
context.Trade,
|
||||
context.House);
|
||||
|
||||
context.Movement.AttachPhysicsPublication(
|
||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
|
|
@ -393,6 +407,7 @@ public sealed class GameRuntime
|
|||
FellowshipOwner = context.Fellowship;
|
||||
AllegianceOwner = context.Allegiance;
|
||||
TradeOwner = context.Trade;
|
||||
HouseOwner = context.House;
|
||||
MovementOwner = context.Movement;
|
||||
ActionOwner = context.Actions;
|
||||
EnvironmentOwner = environment;
|
||||
|
|
@ -501,6 +516,11 @@ public sealed class GameRuntime
|
|||
|
||||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||
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 RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||
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.
|
||||
/// </summary>
|
||||
Trade = 14,
|
||||
BeginEntityRetirement = 15,
|
||||
RetireEntities = 16,
|
||||
DrainHostProjection = 17,
|
||||
CompleteCanonicalEntities = 18,
|
||||
CompleteHostProjection = 19,
|
||||
ChatIdentity = 20,
|
||||
PlayerSnapshots = 21,
|
||||
PlayerIdentity = 22,
|
||||
Complete = 23,
|
||||
/// <summary>
|
||||
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
|
||||
/// query result is session-scoped like fellowship/allegiance/trade
|
||||
/// above — a fresh login must not show a previous character's house
|
||||
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
|
||||
/// owner is lighter-weight than its three siblings (no disposal, no
|
||||
/// construction-transaction Fault() point).
|
||||
/// </summary>
|
||||
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(
|
||||
|
|
@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset
|
|||
private readonly RuntimeFellowshipState _fellowship;
|
||||
private readonly RuntimeAllegianceState _allegiance;
|
||||
private readonly RuntimeTradeState _trade;
|
||||
private readonly RuntimeHouseState _house;
|
||||
private ResetState? _state;
|
||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||
private bool _hasCompletedGeneration;
|
||||
|
|
@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset
|
|||
RuntimeLocalPlayerIdentityState identity,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
RuntimeTradeState trade,
|
||||
RuntimeHouseState house)
|
||||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_communication = communication
|
||||
|
|
@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset
|
|||
_allegiance = allegiance
|
||||
?? throw new ArgumentNullException(nameof(allegiance));
|
||||
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
||||
_house = house ?? throw new ArgumentNullException(nameof(house));
|
||||
}
|
||||
|
||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||
|
|
@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset
|
|||
case RuntimeGenerationResetStage.Trade:
|
||||
Advance(state, _trade.Clear);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.House:
|
||||
Advance(state, _house.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||
_ = _entityObjects.BeginSessionClear();
|
||||
state.Retirements = _entityObjects
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings(
|
|||
RuntimeAllegianceState? Allegiance = null,
|
||||
// Secure trade (2026-08-14): the third sibling J-owner, same
|
||||
// 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>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
|
|
@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
: null,
|
||||
onTradeClearAcceptance: social.Trade is { } tradeClear
|
||||
? 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));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue