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>
This commit is contained in:
Erik 2026-08-21 14:46:53 +02:00
parent ab3934e21d
commit f629ce7f3d
9 changed files with 564 additions and 19 deletions

View file

@ -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,

View file

@ -131,7 +131,14 @@ public static class GameEventWiring
Action<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
Action<IReadOnlyList<GameEvents.HousePayment>>? 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<IReadOnlyDictionary<uint, ContractTracker>>? onContractTable = null,
Action<ContractTrackerUpdate>? 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 =>

View file

@ -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,

View file

@ -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<long, string> _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
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
public RuntimeTradeState TradeOwner { get; }
public RuntimeContractState ContractsOwner { get; }
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
/// <see cref="RuntimeHouseState"/>'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,
};

View file

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

View file

@ -69,15 +69,24 @@ public enum RuntimeGenerationResetStage
/// construction-transaction Fault() point).
/// </summary>
House = 15,
BeginEntityRetirement = 16,
RetireEntities = 17,
DrainHostProjection = 18,
CompleteCanonicalEntities = 19,
CompleteHostProjection = 20,
ChatIdentity = 21,
PlayerSnapshots = 22,
PlayerIdentity = 23,
Complete = 24,
/// <summary>
/// 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.
/// </summary>
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

View file

@ -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);
/// <summary>
/// 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();

View file

@ -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());

View file

@ -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;
/// <summary>
/// Campaign QT slice QT3: the canonical contract-tracker owner.
/// </summary>
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<uint, ContractTracker>
{
[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<uint, ContractTracker>());
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<uint, ContractTracker> { [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<uint, ContractTracker> { [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<ContractTracker> 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<uint, ContractTracker>());
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<uint, ContractTracker> { [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);
}
}