acdream/src/AcDream.Runtime/RuntimeGenerationReset.cs
Erik 06512f0957 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>
2026-08-17 03:38:16 +02:00

478 lines
18 KiB
C#

using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
/// <summary>
/// The only host-specific edge in a character-generation reset. The host
/// retires presentation borrowed from an exact Runtime incarnation; Runtime
/// retains the retirement set, cursor, canonical cleanup, and generation.
/// </summary>
public interface IRuntimeGenerationResetHost
{
void RetireEntityProjection(RuntimeEntityRecord entity);
void DrainEntityProjectionBoundary();
void CompleteEntityProjectionRetirement();
}
public enum RuntimeGenerationResetStage
{
None = 0,
Transit = 1,
CommandTargets = 2,
ExternalContainer = 3,
Actions = 4,
Movement = 5,
ObjectTable = 6,
Character = 7,
ItemMana = 8,
Friends = 9,
Squelch = 10,
NegotiatedChannels = 11,
/// <summary>
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
/// session-scoped (a disconnect drops you from the fellowship
/// server-side) — clear it here, alongside the other social-list
/// stages.
/// </summary>
Fellowship = 12,
/// <summary>
/// FA2 fix-round MUST-FIX 1 (2026-08-12,
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1): the
/// allegiance profile is ALSO cleared here — retail's
/// <c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
/// tail-calls <c>AllegianceProfile::Clear</c> at exactly this
/// per-character-session boundary, and the precedent this owner cites
/// (<see cref="RuntimeCharacterOptionsState.ResetSession"/>) clears AND
/// re-latches, it does not persist. See
/// <see cref="RuntimeAllegianceState"/>'s class doc for the full
/// correction (the original "survives reconnect" design was inverted
/// from the precedent it named).
/// </summary>
Allegiance = 13,
/// <summary>
/// Secure trade (2026-08-14): the trade window is session-scoped — a
/// disconnect closes the trade server-side (ACE tears the negotiation
/// down with the session), so the third sibling J-owner clears here
/// beside its fellowship/allegiance precedents.
/// </summary>
Trade = 14,
/// <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(
bool IsActive,
bool IsExecuting,
RuntimeGenerationToken RetiringGeneration,
RuntimeGenerationToken LastCompletedGeneration,
RuntimeGenerationResetStage Stage,
int RetirementCount,
int RetirementCursor,
bool CurrentProjectionAcknowledged,
long TransactionId)
{
public bool IsConverged =>
!IsActive
&& !IsExecuting;
}
public sealed class RuntimeGenerationResetStageException(
RuntimeGenerationToken retiringGeneration,
RuntimeGenerationResetStage stage,
Exception innerException) : Exception(
$"Runtime generation {retiringGeneration.Value} reset stage "
+ $"'{stage}' did not converge.",
innerException)
{
public RuntimeGenerationToken RetiringGeneration { get; } =
retiringGeneration;
public RuntimeGenerationResetStage Stage { get; } = stage;
}
/// <summary>
/// One persisted, retryable reset transaction for every canonical owner in a
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
/// completed stages and exact-incarnation retirements never replay.
/// </summary>
public sealed class RuntimeGenerationReset
{
private readonly RuntimeWorldTransitState _transit;
private readonly RuntimeCommunicationState _communication;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCharacterState _character;
private readonly RuntimeLocalPlayerIdentityState _identity;
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;
private bool _executing;
private long _nextTransactionId;
internal RuntimeGenerationReset(
RuntimeWorldTransitState transit,
RuntimeCommunicationState communication,
RuntimeInventoryState inventory,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeEntityObjectLifetime entityObjects,
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance,
RuntimeTradeState trade,
RuntimeHouseState house)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
_inventory = inventory
?? throw new ArgumentNullException(nameof(inventory));
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
_movement = movement
?? throw new ArgumentNullException(nameof(movement));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_character = character
?? throw new ArgumentNullException(nameof(character));
_identity = identity
?? throw new ArgumentNullException(nameof(identity));
_fellowship = fellowship
?? throw new ArgumentNullException(nameof(fellowship));
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
_house = house ?? throw new ArgumentNullException(nameof(house));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
_state?.Generation;
public RuntimeGenerationResetSnapshot CaptureSnapshot()
{
ResetState? state = _state;
return state is null
? new RuntimeGenerationResetSnapshot(
false,
_executing,
default,
_lastCompletedGeneration,
_hasCompletedGeneration
? RuntimeGenerationResetStage.Complete
: RuntimeGenerationResetStage.None,
0,
0,
false,
0)
: new RuntimeGenerationResetSnapshot(
true,
_executing,
state.Generation,
_lastCompletedGeneration,
state.Stage,
state.Retirements?.Length ?? 0,
state.RetirementCursor,
state.CurrentProjectionAcknowledged,
state.TransactionId);
}
public void Reset(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host)
{
ArgumentNullException.ThrowIfNull(host);
if (_executing)
{
throw new InvalidOperationException(
"Runtime generation reset cannot run concurrently or reentrantly.");
}
ResetState state = AcquireState(retiringGeneration, host);
if (state.Stage is RuntimeGenerationResetStage.Complete)
return;
_executing = true;
try
{
Drain(state);
}
catch (Exception error)
{
throw new RuntimeGenerationResetStageException(
state.Generation,
state.Stage,
error);
}
finally
{
_executing = false;
}
}
internal void DrainPending()
{
ResetState? state = _state;
if (state is null)
return;
Reset(state.Generation, state.Host);
}
private ResetState AcquireState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host)
{
if (_state is { } pending)
{
if (pending.Generation != generation)
{
throw new InvalidOperationException(
$"Runtime generation {pending.Generation.Value} reset "
+ $"must converge before generation {generation.Value} can reset.");
}
if (!ReferenceEquals(pending.Host, host))
{
throw new InvalidOperationException(
"An in-progress Runtime generation reset cannot replace "
+ "its borrowed projection host.");
}
return pending;
}
if (_hasCompletedGeneration)
{
if (generation == _lastCompletedGeneration)
return ResetState.Completed(generation, host);
if (generation.Value < _lastCompletedGeneration.Value)
{
throw new InvalidOperationException(
$"Runtime generation {generation.Value} reset is stale; "
+ $"generation {_lastCompletedGeneration.Value} already converged.");
}
}
var created = new ResetState(
generation,
host,
checked(++_nextTransactionId));
_state = created;
return created;
}
private void Drain(ResetState state)
{
while (state.Stage is not RuntimeGenerationResetStage.Complete)
{
switch (state.Stage)
{
case RuntimeGenerationResetStage.Transit:
Advance(state, _transit.ResetSession);
break;
case RuntimeGenerationResetStage.CommandTargets:
Advance(state, _communication.ResetCommandTargets);
break;
case RuntimeGenerationResetStage.ExternalContainer:
// Slice 5.3: the vendor browse session shares the
// external-container stage rather than claiming a new
// enum ordinal — both are client-local "open server
// object" sessions torn down uniformly at session
// reset/portal-out/logout (research doc §C.2's
// "generation/lifecycle contract every other J4/J5 child
// follows").
Advance(state, () =>
{
_inventory.ResetExternalContainer();
_inventory.ResetVendor();
});
break;
case RuntimeGenerationResetStage.Actions:
Advance(state, _actions.ResetSession);
break;
case RuntimeGenerationResetStage.Movement:
Advance(state, _movement.ResetSession);
break;
case RuntimeGenerationResetStage.ObjectTable:
Advance(state, _entityObjects.ClearObjects);
break;
case RuntimeGenerationResetStage.Character:
Advance(state, _character.ResetSession);
break;
case RuntimeGenerationResetStage.ItemMana:
Advance(state, _inventory.ResetItemMana);
break;
case RuntimeGenerationResetStage.Friends:
Advance(state, _communication.ResetFriends);
break;
case RuntimeGenerationResetStage.Squelch:
Advance(state, _communication.ResetSquelch);
break;
case RuntimeGenerationResetStage.NegotiatedChannels:
Advance(
state,
_communication.ResetNegotiatedChannels);
break;
case RuntimeGenerationResetStage.Fellowship:
Advance(state, _fellowship.ResetSession);
break;
case RuntimeGenerationResetStage.Allegiance:
Advance(state, _allegiance.ResetSession);
break;
case RuntimeGenerationResetStage.Trade:
Advance(state, _trade.Clear);
break;
case RuntimeGenerationResetStage.House:
Advance(state, _house.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects
.CaptureSessionClearRetirements()
.ToArray();
state.Stage = RuntimeGenerationResetStage.RetireEntities;
break;
case RuntimeGenerationResetStage.RetireEntities:
RetireCurrentEntity(state);
break;
case RuntimeGenerationResetStage.DrainHostProjection:
state.Host.DrainEntityProjectionBoundary();
state.Stage =
RuntimeGenerationResetStage.CompleteCanonicalEntities;
break;
case RuntimeGenerationResetStage.CompleteCanonicalEntities:
if (!_entityObjects.CompleteSessionClearIfConverged())
{
throw new InvalidOperationException(
"Canonical entity/object lifetime still owns an "
+ "unacknowledged session retirement.");
}
state.Stage =
RuntimeGenerationResetStage.CompleteHostProjection;
break;
case RuntimeGenerationResetStage.CompleteHostProjection:
state.Host.CompleteEntityProjectionRetirement();
state.Stage = RuntimeGenerationResetStage.ChatIdentity;
break;
case RuntimeGenerationResetStage.ChatIdentity:
// CH2 REJECT-review rework (SHOULD-FIX 1,
// docs/research/2026-08-09-ch2-review-findings.md):
// RuntimeCommunicationState.ResetSpewBox was dead code —
// no caller reset the transient SpewBox queue at
// generation boundaries even though ResetChatIdentity
// (the chat transcript's identity/dedup reset) already
// ran here every generation. They share this stage
// because they're the same lifetime boundary — a fresh
// generation must not resurrect a stale refusal line —
// even though they differ in WHAT they reset:
// ResetChatIdentity preserves the visible transcript,
// ResetSpewBox clears it (see RuntimeCommunicationState's
// own doc comments on each).
Advance(state, () =>
{
_communication.ResetChatIdentity();
_communication.ResetSpewBox();
});
break;
case RuntimeGenerationResetStage.PlayerSnapshots:
Advance(state, _inventory.ResetPlayerSnapshots);
break;
case RuntimeGenerationResetStage.PlayerIdentity:
_identity.ResetSession();
Complete(state);
break;
default:
throw new InvalidOperationException(
$"Unsupported Runtime generation reset stage {state.Stage}.");
}
}
}
private void RetireCurrentEntity(ResetState state)
{
RuntimeEntityRecord[] retirements = state.Retirements ?? [];
if (state.RetirementCursor >= retirements.Length)
{
state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
return;
}
RuntimeEntityRecord current = retirements[state.RetirementCursor];
if (!state.CurrentProjectionAcknowledged)
{
state.Host.RetireEntityProjection(current);
state.CurrentProjectionAcknowledged = true;
}
_entityObjects.CompleteSessionEntityRetirement(current);
state.CurrentProjectionAcknowledged = false;
state.RetirementCursor++;
}
private static void Advance(ResetState state, Action action)
{
action();
state.Stage++;
}
private void Complete(ResetState state)
{
state.Stage = RuntimeGenerationResetStage.Complete;
_lastCompletedGeneration = state.Generation;
_hasCompletedGeneration = true;
_state = null;
}
private sealed class ResetState
{
public ResetState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host,
long transactionId)
{
Generation = generation;
Host = host;
TransactionId = transactionId;
Stage = RuntimeGenerationResetStage.Transit;
}
public RuntimeGenerationToken Generation { get; }
public IRuntimeGenerationResetHost Host { get; }
public long TransactionId { get; }
public RuntimeGenerationResetStage Stage { get; set; }
public RuntimeEntityRecord[]? Retirements { get; set; }
public int RetirementCursor { get; set; }
public bool CurrentProjectionAcknowledged { get; set; }
public static ResetState Completed(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host) =>
new(generation, host, 0)
{
Stage = RuntimeGenerationResetStage.Complete,
};
}
}