feat(quest): QT3 — the contract tracker becomes state, and the events get routed

Fourth sibling J-owner, built to the shape the other three established. It
borrows nothing, because the retail client stores no quest state of its own —
everything here is a projection of what the server pushed.

Clearing at generation reset is safe for the same reason: a fresh session opens
with a full 0x0314 replacement, so the reset cannot lose anything the next
login will not immediately restate, while NOT clearing would show a previous
character's quests.

Three readings of the wire that would each lose contracts silently, one test
apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the
server dropped); an empty 0x0314 clears rather than being ignored (it is how
the server says "you have none", and ignoring it strands the last quest on
screen); and a delete carries a full tracker struct, so it looks exactly like
an add apart from one flag.

Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the
drain loop while GameRuntimeTeardownStage.Complete defines what the ledger
demands, and nothing tied them together. Leave the constant behind and the new
owner is never disposed at all, while the ledger goes on waiting for its flag —
the runtime hangs in teardown rather than failing anywhere near the edit. The
stage-ledger test now reads the constant by reflection and asserts it against
the flag list, so the next owner fails at the edit instead.

Campaign QT slice 3 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 14:46:53 +02:00
parent ab3934e21d
commit f629ce7f3d
9 changed files with 564 additions and 19 deletions

View file

@ -131,7 +131,14 @@ public static class GameEventWiring
Action<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null,
// Campaign QT (2026-08-21): the contract tracker's two events. Same
// Runtime-owned delegate-hole shape as house/trade above --
// RuntimeContractState is the consumer. Both opcodes have been named
// in GameEventType since the wire catalog with nothing behind them,
// so until this wiring the bytes arrived and were dropped.
Action<IReadOnlyDictionary<uint, ContractTracker>>? onContractTable = null,
Action<ContractTrackerUpdate>? onContractUpdate = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -446,6 +453,27 @@ public static class GameEventWiring
});
}
// Campaign QT (2026-08-21). Arrival is stamped HERE rather than
// inside the parser's caller, because FillProgressString counts a
// repeat timer down from the moment the state arrived and the server
// never sends that moment.
if (onContractTable is not null)
{
registrar.Register(GameEventType.SendClientContractTrackerTable, e =>
{
var p = ContractTrackerMessages.ParseTable(e.Payload.Span, DateTime.UtcNow);
if (p is not null) onContractTable(p);
});
}
if (onContractUpdate is not null)
{
registrar.Register(GameEventType.SendClientContractTracker, e =>
{
var p = ContractTrackerMessages.ParseUpdate(e.Payload.Span, DateTime.UtcNow);
if (p is not null) onContractUpdate(p.Value);
});
}
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>