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