acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs
Erik f629ce7f3d 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>
2026-08-21 14:46:53 +02:00

237 lines
7.9 KiB
C#

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