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;
}
}
}
}