diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6460bc14..b3acfe7d 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -393,7 +393,8 @@ internal sealed class LiveSessionRuntimeFactory Fellowship: _domain.Runtime.FellowshipOwner, Allegiance: _domain.Runtime.AllegianceOwner, Trade: _domain.Runtime.TradeOwner, - House: _domain.Runtime.HouseOwner)); + House: _domain.Runtime.HouseOwner, + Contracts: _domain.Runtime.ContractsOwner)); return new GraphicalSessionEventRoute( route, _domain.Runtime, diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 13afc2be..81d3160f 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -131,7 +131,14 @@ public static class GameEventWiring Action? onHouseData = null, Action? onHouseStatus = null, Action? onHouseUpdateRentTime = null, - Action>? onHouseUpdateRentPayment = null) + Action>? 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>? onContractTable = null, + Action? 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 => diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 16a4ed51..a0140d36 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1250,7 +1250,8 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.Squelch, (text, type) => Runtime.CommunicationOwner.AddText(text, type), Fellowship: Runtime.FellowshipOwner, - Allegiance: Runtime.AllegianceOwner)); + Allegiance: Runtime.AllegianceOwner, + Contracts: Runtime.ContractsOwner)); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index f89374fd..475fa3ff 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -52,6 +52,8 @@ public enum GameRuntimeTeardownStage TradeDisposed = 1 << 11, IdentityDisposed = 1 << 12, EntityObjectsDisposed = 1 << 13, + // Campaign QT (2026-08-21): fourth sibling J-owner, same shape. + ContractsDisposed = 1 << 14, Complete = HostLeasesReleased | EventsDetached @@ -65,6 +67,7 @@ public enum GameRuntimeTeardownStage | FellowshipDisposed | AllegianceDisposed | TradeDisposed + | ContractsDisposed | IdentityDisposed | EntityObjectsDisposed, } @@ -109,6 +112,7 @@ internal enum GameRuntimeConstructionPoint FellowshipCreated, AllegianceCreated, TradeCreated, + ContractsCreated, HouseCreated, MovementCreated, ActionsCreated, @@ -128,6 +132,7 @@ internal sealed class GameRuntimeConstructionContext public RuntimeFellowshipState? Fellowship { get; set; } public RuntimeAllegianceState? Allegiance { get; set; } public RuntimeTradeState? Trade { get; set; } + public RuntimeContractState? Contracts { get; set; } public RuntimeHouseState? House { get; set; } public RuntimeLocalPlayerMovementState? Movement { get; set; } public RuntimeActionState? Actions { get; set; } @@ -144,7 +149,11 @@ public sealed class GameRuntime IRuntimeEventSource, IDisposable { - private const int TeardownStageCount = 14; + // Campaign QT (2026-08-21): 15 with the contract owner. This bound and + // GameRuntimeTeardownStage.Complete have to move together — the drain + // loop stops here, so leaving it behind would silently never dispose + // the last owner while the ledger kept demanding its flag. + private const int TeardownStageCount = 15; private readonly object _lifetimeGate = new(); private readonly Dictionary _hostLeases = []; @@ -284,6 +293,17 @@ public sealed class GameRuntime context, faultInjection); + // Campaign QT (2026-08-21): fourth sibling J-owner. A pure + // projection of server-pushed contract state — it borrows + // nothing, because the retail client stores no quest state of + // its own. + context.Contracts = new RuntimeContractState(); + construction.Own(context.Contracts); + Fault( + GameRuntimeConstructionPoint.ContractsCreated, + context, + faultInjection); + // House tab (Batch C, Map/House toolbar panel, 2026-08-17): // deliberately minimal owner (ISSUES #413's own sizing note) — // no live-object side effects, nothing to dispose, so no @@ -351,7 +371,8 @@ public sealed class GameRuntime context.Fellowship, context.Allegiance, context.Trade, - context.House); + context.House, + context.Contracts); context.Movement.AttachPhysicsPublication( new RuntimeLocalPlayerPhysicsPublicationState( @@ -407,6 +428,7 @@ public sealed class GameRuntime FellowshipOwner = context.Fellowship; AllegianceOwner = context.Allegiance; TradeOwner = context.Trade; + ContractsOwner = context.Contracts; HouseOwner = context.House; MovementOwner = context.Movement; ActionOwner = context.Actions; @@ -516,6 +538,7 @@ public sealed class GameRuntime /// Secure trade (2026-08-14): third sibling J-owner. public RuntimeTradeState TradeOwner { get; } + public RuntimeContractState ContractsOwner { get; } /// Batch C (2026-08-17): House tab minimal owner — see /// 's own class doc for the sizing @@ -572,6 +595,7 @@ public sealed class GameRuntime public IRuntimeAllegianceView Allegiance => AllegianceOwner.View; public IRuntimeTradeView Trade => TradeOwner.View; + public IRuntimeContractView Contracts => ContractsOwner.View; public IRuntimeActionView Actions => ActionOwner.View; public IRuntimeMovementView Movement => MovementOwner.View; public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner; @@ -800,21 +824,28 @@ public sealed class GameRuntime & ~GameRuntimeTeardownStage.FellowshipDisposed & ~GameRuntimeTeardownStage.AllegianceDisposed & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 10 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.AllegianceDisposed & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 11 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 12 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 13 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.IdentityDisposed + & ~GameRuntimeTeardownStage.EntityObjectsDisposed, + 14 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.EntityObjectsDisposed, _ => GameRuntimeTeardownStage.Complete, }; @@ -869,9 +900,12 @@ public sealed class GameRuntime TradeOwner.Dispose(); return TradeOwner.CaptureOwnership().IsConverged; case 12: + ContractsOwner.Dispose(); + return ContractsOwner.CaptureOwnership().IsConverged; + case 13: PlayerIdentity.Dispose(); return PlayerIdentity.CaptureOwnership().IsConverged; - case 13: + case 14: EntityObjects.Dispose(); return EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged; @@ -895,8 +929,9 @@ public sealed class GameRuntime 9 => FellowshipOwner.CaptureOwnership().IsConverged, 10 => AllegianceOwner.CaptureOwnership().IsConverged, 11 => TradeOwner.CaptureOwnership().IsConverged, - 12 => PlayerIdentity.CaptureOwnership().IsConverged, - 13 => EntityObjects.CaptureOwnership().IsConverged + 12 => ContractsOwner.CaptureOwnership().IsConverged, + 13 => PlayerIdentity.CaptureOwnership().IsConverged, + 14 => EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged, _ => true, }; diff --git a/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs b/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs new file mode 100644 index 00000000..eb5217ba --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs @@ -0,0 +1,201 @@ +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; + } + } + } +} diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index 28441e7c..e497507a 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -69,15 +69,24 @@ public enum RuntimeGenerationResetStage /// construction-transaction Fault() point). /// House = 15, - BeginEntityRetirement = 16, - RetireEntities = 17, - DrainHostProjection = 18, - CompleteCanonicalEntities = 19, - CompleteHostProjection = 20, - ChatIdentity = 21, - PlayerSnapshots = 22, - PlayerIdentity = 23, - Complete = 24, + /// + /// Campaign QT (2026-08-21): the contract tracker is a projection of + /// server state and nothing else — the retail client stores no quest + /// state of its own. A fresh session opens with a full 0x0314 + /// replacement, so clearing here cannot lose anything the next login + /// will not immediately restate, while NOT clearing would show a + /// previous character's quests. + /// + Contracts = 16, + BeginEntityRetirement = 17, + RetireEntities = 18, + DrainHostProjection = 19, + CompleteCanonicalEntities = 20, + CompleteHostProjection = 21, + ChatIdentity = 22, + PlayerSnapshots = 23, + PlayerIdentity = 24, + Complete = 25, } public readonly record struct RuntimeGenerationResetSnapshot( @@ -128,6 +137,7 @@ public sealed class RuntimeGenerationReset private readonly RuntimeFellowshipState _fellowship; private readonly RuntimeAllegianceState _allegiance; private readonly RuntimeTradeState _trade; + private readonly RuntimeContractState _contracts; private readonly RuntimeHouseState _house; private ResetState? _state; private RuntimeGenerationToken _lastCompletedGeneration; @@ -147,7 +157,8 @@ public sealed class RuntimeGenerationReset RuntimeFellowshipState fellowship, RuntimeAllegianceState allegiance, RuntimeTradeState trade, - RuntimeHouseState house) + RuntimeHouseState house, + RuntimeContractState contracts) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _communication = communication @@ -169,6 +180,8 @@ public sealed class RuntimeGenerationReset ?? throw new ArgumentNullException(nameof(allegiance)); _trade = trade ?? throw new ArgumentNullException(nameof(trade)); _house = house ?? throw new ArgumentNullException(nameof(house)); + _contracts = contracts + ?? throw new ArgumentNullException(nameof(contracts)); } public RuntimeGenerationToken? ActiveRetiringGeneration => @@ -348,6 +361,9 @@ public sealed class RuntimeGenerationReset case RuntimeGenerationResetStage.House: Advance(state, _house.ResetSession); break; + case RuntimeGenerationResetStage.Contracts: + Advance(state, _contracts.ResetSession); + break; case RuntimeGenerationResetStage.BeginEntityRetirement: _ = _entityObjects.BeginSessionClear(); state.Retirements = _entityObjects diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 53179aae..f1caafdf 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -92,7 +92,10 @@ public sealed record LiveSocialSessionBindings( // Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional // compatibility convention — a minimal owner (RuntimeHouseState's own // class doc), not a full sibling J-owner. - RuntimeHouseState? House = null); + RuntimeHouseState? House = null, + // Campaign QT (2026-08-21): the fourth sibling J-owner, same + // trailing/optional compatibility convention. + RuntimeContractState? Contracts = null); /// /// Owns every inbound subscription for one exact live session. Domain state @@ -334,6 +337,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : null, onHouseStatus: social.House is { } houseStatus ? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid()) + : null, + // Campaign QT (2026-08-21): same conditional delegate-hole + // discipline as house above. + onContractTable: social.Contracts is { } contractTable + ? contractTable.ApplyTable + : null, + onContractUpdate: social.Contracts is { } contractUpdate + ? contractUpdate.ApplyUpdate : null)); ConstructionCheckpoint(); diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs index d42f126d..cc59f3e9 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -240,6 +240,10 @@ public sealed class GameRuntimeTests GameRuntimeTeardownStage.FellowshipDisposed, GameRuntimeTeardownStage.AllegianceDisposed, GameRuntimeTeardownStage.TradeDisposed, + // Campaign QT (2026-08-21): the contract owner disposes beside + // its three sibling J-owners, before the identity and entity + // foundations. + GameRuntimeTeardownStage.ContractsDisposed, GameRuntimeTeardownStage.IdentityDisposed, GameRuntimeTeardownStage.EntityObjectsDisposed, ]; @@ -255,6 +259,17 @@ public sealed class GameRuntimeTests } Assert.Equal(GameRuntimeTeardownStage.Complete, expected); + + // The drain loop stops at TeardownStageCount, so a new owner added to + // the enum without bumping the constant is never disposed at all while + // the ledger goes on demanding its flag — the runtime then hangs in + // teardown forever rather than failing anywhere near the mistake. + // Campaign QT hit exactly this; tying the two together here is what + // makes it fail at the edit instead. + int stageCount = (int)typeof(GameRuntime) + .GetField("TeardownStageCount", BindingFlags.NonPublic | BindingFlags.Static)! + .GetRawConstantValue()!; + Assert.Equal(orderedFlags.Length, stageCount); } private static GameRuntime Create() => new(Dependencies()); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs new file mode 100644 index 00000000..1f63a2fa --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign QT slice QT3: the canonical contract-tracker owner. +/// +public sealed class RuntimeContractStateTests +{ + private static readonly DateTime Arrival = new(2026, 8, 21, 13, 0, 0, DateTimeKind.Utc); + + private static ContractTracker Tracker( + uint contractId, + ContractStage stage = ContractStage.InProgress, + double whenRepeats = 0) + => new(1u, contractId, stage, 0, whenRepeats, Arrival); + + private static ContractTrackerUpdate Update( + uint contractId, + ContractStage stage = ContractStage.InProgress, + bool delete = false, + bool setAsDisplay = false) + => new(Tracker(contractId, stage), delete, setAsDisplay); + + [Fact] + public void AnUpdateAddsAContractAndASecondUpdateReplacesIt() + { + using var state = new RuntimeContractState(); + + state.ApplyUpdate(Update(0x10u, ContractStage.Available)); + state.ApplyUpdate(Update(0x10u, ContractStage.InProgress)); + + Assert.True(state.View.TryGetContract(0x10u, out ContractTracker tracker)); + Assert.Equal(ContractStage.InProgress, tracker.Stage); + Assert.Equal(1, state.View.Snapshot.ContractCount); + } + + [Fact] + public void TheDeleteFlagRemovesTheContractItNames() + { + // ACE builds a full tracker struct even for a delete, so the message + // looks exactly like an add apart from one flag. Reading it as an add + // would make abandoned quests immortal. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + + state.ApplyUpdate(Update(0x10u, delete: true)); + + Assert.False(state.View.TryGetContract(0x10u, out _)); + Assert.Equal(0, state.View.Snapshot.ContractCount); + } + + [Fact] + public void DeletingTheDisplayContractClearsTheDisplaySelection() + { + // A panel pointed at a contract the server no longer tracks has + // nothing to render. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + Assert.Equal(0x10u, state.View.Snapshot.DisplayContractId); + + state.ApplyUpdate(Update(0x10u, delete: true)); + + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableReplacesEverythingRatherThanMergingIntoIt() + { + // 0x0314 is a full replacement. Merging would resurrect contracts the + // server has dropped. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + state.ApplyUpdate(Update(0x20u)); + + state.ApplyTable(new Dictionary + { + [0x30u] = Tracker(0x30u), + }); + + Assert.False(state.View.TryGetContract(0x10u, out _)); + Assert.True(state.View.TryGetContract(0x30u, out _)); + Assert.Equal(1, state.View.Snapshot.ContractCount); + } + + [Fact] + public void AnEmptyTableClearsTheTracker() + { + // "You have no contracts" is a real thing the server says. Ignoring an + // empty table would strand the last quest on screen forever. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary()); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableThatDropsTheDisplayContractClearsTheSelection() + { + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary { [0x20u] = Tracker(0x20u) }); + + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableThatKeepsTheDisplayContractKeepsTheSelection() + { + // A routine full refresh must not deselect what the player is looking + // at. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary { [0x10u] = Tracker(0x10u) }); + + Assert.Equal(0x10u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ContractsComeBackInAStableOrder() + { + // The panel draws a list; an unordered dictionary would reshuffle the + // rows under the cursor on every refresh. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x30u)); + state.ApplyUpdate(Update(0x10u)); + state.ApplyUpdate(Update(0x20u)); + + IReadOnlyList contracts = state.View.GetContracts(); + + Assert.Equal( + new uint[] { 0x10u, 0x20u, 0x30u }, + contracts.Select(c => c.ContractId).ToArray()); + } + + [Fact] + public void EveryMutationAdvancesTheRevision() + { + // Consumers poll rather than subscribe, so a mutation that does not + // bump is a mutation the panel never draws. + using var state = new RuntimeContractState(); + long start = state.View.Snapshot.Revision; + + state.ApplyUpdate(Update(0x10u)); + long afterAdd = state.View.Snapshot.Revision; + state.ApplyTable(new Dictionary()); + long afterTable = state.View.Snapshot.Revision; + + Assert.True(afterAdd > start); + Assert.True(afterTable > afterAdd); + } + + [Fact] + public void DeletingSomethingAbsentDoesNotAdvanceTheRevision() + { + // A no-op that bumps would redraw the panel on every stray message. + using var state = new RuntimeContractState(); + long start = state.View.Snapshot.Revision; + + state.ApplyUpdate(Update(0x99u, delete: true)); + + Assert.Equal(start, state.View.Snapshot.Revision); + } + + [Fact] + public void ResetSessionClearsBecauseQuestStateIsPurelyServerSide() + { + // A reconnect must not show the previous character's quests. Safe + // because the next login opens with a full 0x0314. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ResetSession(); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ResetSessionIsSafeToRepeatBecauseTheResetTransactionRetries() + { + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + + state.ResetSession(); + state.ResetSession(); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + } + + [Fact] + public void OwnershipConvergesOnlyAfterDisposal() + { + var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + Assert.False(state.CaptureOwnership().IsConverged); + + state.Dispose(); + + RuntimeContractOwnershipSnapshot ownership = state.CaptureOwnership(); + Assert.True(ownership.IsConverged); + Assert.Equal(0, ownership.ContractCount); + Assert.Equal(0u, ownership.DisplayContractId); + } + + [Fact] + public void MutationsAfterDisposalAreIgnoredRatherThanThrowing() + { + // Teardown is terminal and a late inbound packet must not resurrect + // state or take the process down. + var state = new RuntimeContractState(); + state.Dispose(); + + state.ApplyUpdate(Update(0x10u)); + state.ApplyTable(new Dictionary { [0x20u] = Tracker(0x20u) }); + + Assert.True(state.CaptureOwnership().IsConverged); + } + + [Fact] + public void DisposalIsIdempotent() + { + var state = new RuntimeContractState(); + state.Dispose(); + state.Dispose(); + Assert.True(state.CaptureOwnership().IsConverged); + } +}