acdream/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs
Erik f629ce7f3d 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>
2026-08-21 14:46:53 +02:00

201 lines
6.4 KiB
C#

using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeContractOwnershipSnapshot(
bool IsDisposed,
int ContractCount,
uint DisplayContractId)
{
public bool IsConverged =>
IsDisposed
&& ContractCount == 0
&& DisplayContractId == 0u;
}
/// <summary>Whole-tracker state at one revision.</summary>
public readonly record struct RuntimeContractsSnapshot(
long Revision,
int ContractCount,
uint DisplayContractId);
/// <summary>Borrowed read surface over <see cref="RuntimeContractState"/>.</summary>
public interface IRuntimeContractView
{
RuntimeContractsSnapshot Snapshot { get; }
bool TryGetContract(uint contractId, out ContractTracker tracker);
/// <summary>Every tracked contract, ordered by contract id for stable display.</summary>
IReadOnlyList<ContractTracker> GetContracts();
}
/// <summary>
/// Canonical presentation-independent owner for the player's contract tracker
/// — Campaign QT slice QT3.
/// </summary>
/// <remarks>
/// <para>
/// Session-scoped, and unusually safe to make so: the retail client stores NO
/// quest state of its own (<c>r10-quest-dialogs.md</c> §1.3). Everything here
/// is a projection of what the server pushed, and a fresh session opens with a
/// full <c>0x0314</c> replacement, so clearing at generation reset cannot lose
/// anything the next login will not immediately restate.
/// </para>
/// <para>
/// Three mutation shapes, all from QT1's parsers:
/// <c>0x0314</c> REPLACES the table wholesale; <c>0x0315</c> upserts one
/// contract, or removes it when <c>DeleteContract</c> is set; and either path
/// may nominate the display contract. Every mutation bumps the monotonic
/// revision so consumers poll rather than subscribe.
/// </para>
/// </remarks>
public sealed class RuntimeContractState : IDisposable
{
private readonly object _gate = new();
private readonly Dictionary<uint, ContractTracker> _contracts = [];
private uint _displayContractId;
private long _revision;
private bool _disposed;
public RuntimeContractState() => View = new ContractView(this);
public IRuntimeContractView View { get; }
/// <summary>
/// <c>0x0314</c> — the server's complete list replaces ours.
/// </summary>
/// <remarks>
/// An EMPTY table is meaningful and must clear: it is how the server says
/// "you have no contracts". Treating empty as "nothing to do" would strand
/// contracts on screen after the last one is abandoned.
/// <para>
/// A display contract that is not in the new table is dropped, because a
/// panel pointed at a contract the server no longer tracks has nothing to
/// render.
/// </para>
/// </remarks>
public void ApplyTable(IReadOnlyDictionary<uint, ContractTracker> table)
{
ArgumentNullException.ThrowIfNull(table);
lock (_gate)
{
if (_disposed) return;
_contracts.Clear();
foreach ((uint id, ContractTracker tracker) in table)
_contracts[id] = tracker;
if (_displayContractId != 0u && !_contracts.ContainsKey(_displayContractId))
_displayContractId = 0u;
Bump();
}
}
/// <summary>
/// <c>0x0315</c> — one contract added, changed, or removed.
/// </summary>
/// <remarks>
/// The delete flag is checked BEFORE the upsert. A delete carries a whole
/// tracker struct alongside it (ACE builds one either way), so storing
/// first and deleting second would work, but reading the message as
/// "here is a contract" when it says "remove this contract" is the
/// misreading worth ruling out.
/// </remarks>
public void ApplyUpdate(ContractTrackerUpdate update)
{
lock (_gate)
{
if (_disposed) return;
uint id = update.Tracker.ContractId;
if (update.Delete)
{
bool removed = _contracts.Remove(id);
if (_displayContractId == id)
_displayContractId = 0u;
if (removed) Bump();
return;
}
_contracts[id] = update.Tracker;
if (update.SetAsDisplay)
_displayContractId = id;
Bump();
}
}
public RuntimeContractOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
return new RuntimeContractOwnershipSnapshot(
_disposed,
_contracts.Count,
_displayContractId);
}
/// <summary>
/// Session-scoped: cleared at every generation reset. No disposed guard,
/// matching the sibling owners — the reset transaction is retryable and
/// disposal is terminal, so a throwing guard here could never converge.
/// </summary>
public void ResetSession()
{
lock (_gate) ClearLocked();
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
_disposed = true;
}
}
private void ClearLocked()
{
bool changed = _contracts.Count != 0 || _displayContractId != 0u;
_contracts.Clear();
_displayContractId = 0u;
if (changed) Bump();
}
private void Bump() => _revision++;
private sealed class ContractView(RuntimeContractState owner) : IRuntimeContractView
{
public RuntimeContractsSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeContractsSnapshot(
owner._revision,
owner._contracts.Count,
owner._displayContractId);
}
}
public bool TryGetContract(uint contractId, out ContractTracker tracker)
{
lock (owner._gate)
return owner._contracts.TryGetValue(contractId, out tracker);
}
public IReadOnlyList<ContractTracker> GetContracts()
{
lock (owner._gate)
{
var result = new ContractTracker[owner._contracts.Count];
int i = 0;
foreach (ContractTracker tracker in owner._contracts.Values)
result[i++] = tracker;
Array.Sort(result, static (a, b) => a.ContractId.CompareTo(b.ContractId));
return result;
}
}
}
}