From f629ce7f3d635166077ad433d92860b09103150b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:46:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(quest):=20QT3=20=E2=80=94=20the=20contract?= =?UTF-8?q?=20tracker=20becomes=20state,=20and=20the=20events=20get=20rout?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Net/LiveSessionRuntimeFactory.cs | 3 +- src/AcDream.Core.Net/GameEventWiring.cs | 30 ++- .../Hosting/HeadlessSessionHost.cs | 3 +- src/AcDream.Runtime/GameRuntime.cs | 45 +++- .../Gameplay/RuntimeContractState.cs | 201 +++++++++++++++ src/AcDream.Runtime/RuntimeGenerationReset.cs | 36 ++- .../Session/LiveSessionEventRouter.cs | 13 +- .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 15 ++ .../Gameplay/RuntimeContractStateTests.cs | 237 ++++++++++++++++++ 9 files changed, 564 insertions(+), 19 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeContractState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs 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); + } +}