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; } /// Whole-tracker state at one revision. public readonly record struct RuntimeContractsSnapshot( long Revision, int ContractCount, uint DisplayContractId); /// Borrowed read surface over . public interface IRuntimeContractView { RuntimeContractsSnapshot Snapshot { get; } bool TryGetContract(uint contractId, out ContractTracker tracker); /// Every tracked contract, ordered by contract id for stable display. IReadOnlyList GetContracts(); } /// /// Canonical presentation-independent owner for the player's contract tracker /// — Campaign QT slice QT3. /// /// /// /// Session-scoped, and unusually safe to make so: the retail client stores NO /// quest state of its own (r10-quest-dialogs.md §1.3). Everything here /// is a projection of what the server pushed, and a fresh session opens with a /// full 0x0314 replacement, so clearing at generation reset cannot lose /// anything the next login will not immediately restate. /// /// /// Three mutation shapes, all from QT1's parsers: /// 0x0314 REPLACES the table wholesale; 0x0315 upserts one /// contract, or removes it when DeleteContract is set; and either path /// may nominate the display contract. Every mutation bumps the monotonic /// revision so consumers poll rather than subscribe. /// /// public sealed class RuntimeContractState : IDisposable { private readonly object _gate = new(); private readonly Dictionary _contracts = []; private uint _displayContractId; private long _revision; private bool _disposed; public RuntimeContractState() => View = new ContractView(this); public IRuntimeContractView View { get; } /// /// 0x0314 — the server's complete list replaces ours. /// /// /// 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. /// /// 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. /// /// public void ApplyTable(IReadOnlyDictionary 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(); } } /// /// 0x0315 — one contract added, changed, or removed. /// /// /// 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. /// 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); } /// /// 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. /// 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 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; } } } }