acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
Erik 09029f9f4b fix(runtime,net): OP1 review fixes — server-seed gate, tick-wired auto-save/logout flush, fellowship mutual exclusion
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.

MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.

MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.

SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).

SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.

SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.

SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.

SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.

SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).

SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.

SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.

Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.

Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
  from inside a flush callback can still be erased by the trailing
  "_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
  (same outcome whether the callback runs inside or outside the lock),
  not reachable from any current caller, not a one-liner to close
  correctly (needs a per-dirty-period generation token). Left as documented
  in the review; worth closing before the Options panel ever flushes from
  inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
  touching files this round doesn't otherwise edit (SocialActions.cs,
  CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
  the "one-liner in a file already being edited" instruction.

Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).

Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:39:51 +02:00

1124 lines
41 KiB
C#

using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionControllerTests
{
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
private sealed class TestCommandBus
{
public bool Active { get; set; }
public int PublishCount { get; private set; }
public void Publish<T>(T command) where T : notnull
{
if (Active)
PublishCount++;
}
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public CharacterList.Parsed? Characters { get; set; } = AvailableCharacters();
public Action? OnResolve { get; set; }
public Action? OnCreate { get; set; }
public Action? OnConnect { get; set; }
public Action? OnCharacters { get; set; }
public Action? OnEnterWorld { get; set; }
public Action? OnTick { get; set; }
public Action? OnDispose { get; set; }
public bool ThrowOnConnect { get; set; }
public bool ThrowOnCreate { get; set; }
public bool ThrowOnCharacters { get; set; }
public bool ThrowOnEnterWorld { get; set; }
public bool ThrowOnTick { get; set; }
public bool FailDisposeOnce { get; set; }
public List<WorldSession> Sessions { get; } = [];
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
public int EnterWorldCount { get; private set; }
public int TickCount { get; private set; }
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
OnResolve?.Invoke();
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
OnCreate?.Invoke();
if (ThrowOnCreate)
throw new InvalidOperationException("create failure");
var session = new WorldSession(endpoint, new TestTransport());
Sessions.Add(session);
return session;
}
public void Connect(WorldSession session, string user, string password)
{
calls.Add("connect");
OnConnect?.Invoke();
if (ThrowOnConnect)
throw new InvalidOperationException("connect failure");
}
public CharacterList.Parsed? GetCharacters(WorldSession session)
{
OnCharacters?.Invoke();
if (ThrowOnCharacters)
throw new InvalidOperationException("characters failure");
return Characters;
}
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
calls.Add($"enter:{activeCharacterIndex}");
EnterWorldCount++;
OnEnterWorld?.Invoke();
if (ThrowOnEnterWorld)
throw new InvalidOperationException("enter failure");
}
public void Tick(WorldSession session)
{
calls.Add("tick");
TickCount++;
OnTick?.Invoke();
if (ThrowOnTick)
throw new InvalidOperationException("tick failure");
}
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
OnDispose?.Invoke();
if (FailDisposeOnce)
{
FailDisposeOnce = false;
throw new InvalidOperationException("dispose failure");
}
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
}
}
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
{
public Action? OnBind { get; set; }
public Action? OnReset { get; set; }
public Action? OnConnecting { get; set; }
public Action? OnConnected { get; set; }
public Action? OnSelected { get; set; }
public Action? OnActivate { get; set; }
public Action? OnEntered { get; set; }
public Action? OnDetach { get; set; }
public bool FailResetOnce { get; set; }
public bool FailDetachOnce { get; set; }
public bool FailDeactivateOnce { get; set; }
public bool FailEventDetachOnce { get; set; }
public bool ThrowOnBind { get; set; }
public bool ThrowOnConnecting { get; set; }
public bool ThrowOnConnected { get; set; }
public bool ThrowOnSelected { get; set; }
public bool ThrowOnActivate { get; set; }
public bool ThrowOnEntered { get; set; }
public WorldSession? BindingSessionOverride { get; set; }
public int ResetCount { get; private set; }
public int DetachCount { get; private set; }
public int DeactivateCount { get; private set; }
public int EventDetachCount { get; private set; }
public int ActivateCount { get; private set; }
public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = [];
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
public LiveSessionBinding BindSession(WorldSession session)
{
calls.Add("bind");
OnBind?.Invoke();
if (ThrowOnBind)
throw new InvalidOperationException("bind failure");
var commands = new TestCommandBus();
CommandBuses.Add(commands);
return new LiveSessionBinding(
BindingSessionOverride ?? session,
activateCommands: () =>
{
calls.Add("activate");
ActivateCount++;
commands.Active = true;
OnActivate?.Invoke();
if (ThrowOnActivate)
throw new InvalidOperationException("activate failure");
},
deactivateCommands: () =>
{
calls.Add("deactivate");
DeactivateCount++;
commands.Active = false;
if (FailDeactivateOnce)
{
FailDeactivateOnce = false;
throw new InvalidOperationException("deactivate failure");
}
},
detachEvents: () =>
{
calls.Add("detach-events");
EventDetachCount++;
if (FailEventDetachOnce)
{
FailEventDetachOnce = false;
throw new InvalidOperationException("event detach failure");
}
});
}
public void ResetSessionState(
RuntimeGenerationToken retiringGeneration)
{
calls.Add("reset");
ResetCount++;
ResetGenerations.Add(retiringGeneration);
OnReset?.Invoke();
if (FailResetOnce)
{
FailResetOnce = false;
throw new InvalidOperationException("reset failure");
}
}
public void ReportConnecting(string host, int port, string user)
{
calls.Add("report-connecting");
OnConnecting?.Invoke();
if (ThrowOnConnecting)
throw new InvalidOperationException("connecting failure");
}
public void ReportConnected()
{
calls.Add("report-connected");
OnConnected?.Invoke();
if (ThrowOnConnected)
throw new InvalidOperationException("connected failure");
}
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
{
calls.Add("selected");
Selections.Add(selection);
OnSelected?.Invoke();
if (ThrowOnSelected)
throw new InvalidOperationException("selected failure");
}
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
calls.Add("entered");
OnEntered?.Invoke();
if (ThrowOnEntered)
throw new InvalidOperationException("entered failure");
}
public void DetachSession(WorldSession session)
{
calls.Add("detach-session");
DetachCount++;
OnDetach?.Invoke();
if (FailDetachOnce)
{
FailDetachOnce = false;
throw new InvalidOperationException("detach failure");
}
}
}
public enum ReentrantStopPoint
{
Reset,
Resolve,
Create,
Bind,
Connecting,
Connect,
Connected,
Selected,
EnterWorld,
Activate,
Entered,
}
[Fact]
public void Start_BindsBeforeConnectAndPublishesCanonicalSelectionAfterEnterWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"activate", "entered",
],
calls);
Assert.Equal(
new LiveSessionCharacterSelection(1, 0x50000002u, "Ready", "Canonical"),
result.Selection);
Assert.True(controller.IsInWorld);
Assert.Same(operations.Sessions[0], controller.CurrentSession);
Assert.True(host.CommandBuses[0].Active);
}
[Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.Disabled,
controller.Start(LiveOptions(live: false), host).Status);
Assert.Equal(
LiveSessionStartStatus.MissingCredentials,
controller.Start(LiveOptions(user: null), host).Status);
Assert.Equal(2, host.ResetCount);
Assert.Empty(operations.Sessions);
}
[Fact]
public void Start_NoAvailableCharacterTearsDownExactScope()
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
Characters = new CharacterList.Parsed(
0u,
[new CharacterList.Character(0x50000001u, "Grey", 5u)],
[],
11,
"Canonical",
true,
true),
};
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(1, host.EventDetachCount);
Assert.Equal(1, host.DetachCount);
Assert.Equal(2, host.ResetCount);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
[Theory]
[InlineData("index")]
[InlineData("id")]
[InlineData("name")]
public void Start_SelectsConfiguredAvailableCharacter(string selectorKind)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionCharacterSelector selector = selectorKind switch
{
"index" => new(ActiveIndex: 1),
"id" => new(CharacterId: 0x50000002u),
"name" => new(CharacterName: "ready"),
_ => throw new ArgumentOutOfRangeException(nameof(selectorKind)),
};
LiveSessionStartResult result = controller.Start(
LiveOptions(selector: selector),
host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(0x50000002u, result.Selection!.CharacterId);
Assert.Contains("enter:1", calls);
}
[Fact]
public void Start_UnavailableConfiguredCharacterConvergesOffline()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(
LiveOptions(
selector: new LiveSessionCharacterSelector(
CharacterId: 0x50000001u)),
host);
Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
[Fact]
public void Start_ConnectFailureConvergesOffline()
{
var calls = new List<string>();
var operations = new TestOperations(calls) { ThrowOnConnect = true };
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains("connect failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(1, host.DetachCount);
}
[Theory]
[InlineData("create")]
[InlineData("bind")]
[InlineData("enter")]
public void Start_OtherConstructionAndEntryFailuresConvergeOffline(string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
ThrowOnCreate = phase == "create",
ThrowOnEnterWorld = phase == "enter",
};
var host = new TestHost(calls) { ThrowOnBind = phase == "bind" };
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains($"{phase} failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
if (phase == "create")
{
Assert.Empty(operations.Sessions);
Assert.Equal(1, host.ResetCount);
}
else
{
Assert.Single(operations.Sessions);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(2, host.ResetCount);
}
Assert.Equal(phase == "enter" ? 1 : 0, host.DetachCount);
}
[Fact]
public void Start_HealthyDuplicateIsIdempotent()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
LiveSessionStartResult duplicate = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, duplicate.Status);
Assert.Equal(first.Selection, duplicate.Selection);
Assert.Single(operations.Sessions);
Assert.Equal(1, host.ResetCount);
Assert.Equal(1, host.ActivateCount);
}
[Fact]
public void Reconnect_QuiescesAThenDisposesDetachesResetsBeforeConstructingB()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
TestCommandBus commandsA = host.CommandBuses[0];
calls.Clear();
LiveSessionStartResult result = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"deactivate", "detach-events", "dispose-session", "detach-session",
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"activate", "entered",
],
calls);
commandsA.Publish(new object());
Assert.Equal(0, commandsA.PublishCount);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Equal(2, operations.Sessions.Count);
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
[Fact]
public void ResetCallbacksCarryTheExactRetiringScopeGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult first =
controller.Start(LiveOptions(), host);
RuntimeGenerationToken firstGeneration = controller.Generation;
LiveSessionStartResult second =
controller.Reconnect(LiveOptions(), host);
RuntimeGenerationToken secondGeneration = controller.Generation;
controller.Stop();
Assert.Equal(LiveSessionStartStatus.Connected, first.Status);
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
Assert.Equal(
[
RuntimeGenerationToken.Initial,
firstGeneration,
secondGeneration,
],
host.ResetGenerations);
Assert.NotEqual(firstGeneration, secondGeneration);
}
[Theory]
[InlineData("detach", "stop")]
[InlineData("detach", "dispose")]
[InlineData("detach", "reconnect")]
[InlineData("reset", "stop")]
[InlineData("reset", "dispose")]
[InlineData("reset", "reconnect")]
public void Reconnect_ReentrantTeardownRequestNeverPublishesSupersededB(
string callbackPhase,
string request)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
bool requested = false;
Action callback = () =>
{
if (requested)
return;
requested = true;
switch (request)
{
case "stop": controller.Stop(); break;
case "dispose": controller.Dispose(); break;
case "reconnect":
Assert.Equal(
LiveSessionStartStatus.Deferred,
controller.Reconnect(LiveOptions(), host).Status);
break;
}
};
if (callbackPhase == "detach")
host.OnDetach = callback;
else
host.OnReset = callback;
LiveSessionStartResult outer = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
Assert.Equal(request == "reconnect", controller.IsInWorld);
if (request == "dispose")
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
controller.Dispose();
}
[Fact]
public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
using var mismatchedSession = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9001),
new TestTransport());
var host = new TestHost(calls)
{
BindingSessionOverride = mismatchedSession,
FailEventDetachOnce = true,
};
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains("different session", result.Error!.ToString());
Assert.Equal(0, operations.DisposeCounts.GetValueOrDefault(operations.Sessions[0]));
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(1, host.EventDetachCount);
controller.Stop();
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(2, host.EventDetachCount);
Assert.Equal(1, host.DetachCount);
controller.Dispose();
}
[Theory]
[InlineData(ReentrantStopPoint.Reset)]
[InlineData(ReentrantStopPoint.Resolve)]
[InlineData(ReentrantStopPoint.Create)]
[InlineData(ReentrantStopPoint.Bind)]
[InlineData(ReentrantStopPoint.Connecting)]
[InlineData(ReentrantStopPoint.Connect)]
[InlineData(ReentrantStopPoint.Connected)]
[InlineData(ReentrantStopPoint.Selected)]
[InlineData(ReentrantStopPoint.EnterWorld)]
[InlineData(ReentrantStopPoint.Activate)]
[InlineData(ReentrantStopPoint.Entered)]
public void ReentrantStop_InvalidatesOuterGenerationAndCannotResurrect(
ReentrantStopPoint point)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Action stop = controller.Stop;
switch (point)
{
case ReentrantStopPoint.Reset: host.OnReset = stop; break;
case ReentrantStopPoint.Resolve: operations.OnResolve = stop; break;
case ReentrantStopPoint.Create: operations.OnCreate = stop; break;
case ReentrantStopPoint.Bind: host.OnBind = stop; break;
case ReentrantStopPoint.Connecting: host.OnConnecting = stop; break;
case ReentrantStopPoint.Connect: operations.OnConnect = stop; break;
case ReentrantStopPoint.Connected: host.OnConnected = stop; break;
case ReentrantStopPoint.Selected: host.OnSelected = stop; break;
case ReentrantStopPoint.EnterWorld: operations.OnEnterWorld = stop; break;
case ReentrantStopPoint.Activate: host.OnActivate = stop; break;
case ReentrantStopPoint.Entered: host.OnEntered = stop; break;
}
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
if (operations.Sessions.Count == 0)
{
Assert.Contains(
point,
new[] { ReentrantStopPoint.Reset, ReentrantStopPoint.Resolve });
}
else
{
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
Assert.Equal(
point is ReentrantStopPoint.Reset
or ReentrantStopPoint.Resolve
or ReentrantStopPoint.Create
? 0
: 1,
host.DetachCount);
}
[Fact]
public void ReentrantDuplicateStartIsDeferredWithoutDisturbingOuterAttempt()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult? nested = null;
host.OnSelected = () => nested = controller.Start(LiveOptions(), host);
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
Assert.Equal(LiveSessionStartStatus.Connected, outer.Status);
Assert.True(controller.IsInWorld);
Assert.Single(operations.Sessions);
}
[Fact]
public void ReentrantStartFromFailingEnteredCallbackCannotObserveUncommittedSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult? nested = null;
host.OnEntered = () =>
{
nested = controller.Start(LiveOptions(), host);
throw new InvalidOperationException("entered failure");
};
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
Assert.Equal(LiveSessionStartStatus.Failed, outer.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
controller.Dispose();
}
[Theory]
[InlineData("reconnect")]
[InlineData("dispose")]
public void ReentrantLifecycleRequestDuringActivationDoesNotLeakActiveCommands(
string request)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
bool requested = false;
host.OnActivate = () =>
{
if (requested)
return;
requested = true;
if (request == "reconnect")
controller.Reconnect(LiveOptions(), host);
else
controller.Dispose();
};
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
Assert.False(host.CommandBuses[0].Active);
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
Assert.Equal(request == "reconnect", controller.IsInWorld);
controller.Dispose();
}
[Theory]
[InlineData("connecting")]
[InlineData("connected")]
[InlineData("characters")]
[InlineData("selected")]
[InlineData("activate")]
[InlineData("entered")]
public void Start_CallbackFailureConvergesOffline(string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
switch (phase)
{
case "connecting": host.ThrowOnConnecting = true; break;
case "connected": host.ThrowOnConnected = true; break;
case "characters": operations.ThrowOnCharacters = true; break;
case "selected": host.ThrowOnSelected = true; break;
case "activate": host.ThrowOnActivate = true; break;
case "entered": host.ThrowOnEntered = true; break;
}
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains($"{phase} failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
controller.Dispose();
}
[Theory]
[InlineData("deactivate")]
[InlineData("events")]
[InlineData("session")]
[InlineData("detach")]
[InlineData("reset")]
public void Stop_RetriesExactFailedTeardownStageWithoutRepeatingCompletedWork(
string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
switch (phase)
{
case "deactivate": host.FailDeactivateOnce = true; break;
case "events": host.FailEventDetachOnce = true; break;
case "session": operations.FailDisposeOnce = true; break;
case "detach": host.FailDetachOnce = true; break;
case "reset": host.FailResetOnce = true; break;
}
Assert.Throws<InvalidOperationException>(controller.Stop);
controller.Stop();
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Equal(phase == "detach" ? 2 : 1, host.DetachCount);
Assert.Equal(phase == "reset" ? 3 : 2, host.ResetCount);
controller.Dispose();
}
[Fact]
public void ReconnectRequestedDuringTickRunsAfterTickAndReplacesExactGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
bool requested = false;
operations.OnTick = () =>
{
if (requested)
return;
requested = true;
LiveSessionStartResult nested = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested.Status);
};
controller.Tick();
Assert.True(controller.IsInWorld);
Assert.Equal(2, operations.Sessions.Count);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
[Fact]
public void ResetFailureBlocksConstructionUntilRetryConverges()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls) { FailResetOnce = true };
var controller = new LiveSessionController(operations);
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
LiveSessionStartResult second = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
Assert.Equal(2, host.ResetCount);
Assert.Single(operations.Sessions);
}
[Fact]
public void FailedDetachRetainsRetiredScopeAndBlocksBFactoryUntilRetry()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls) { FailDetachOnce = true };
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
LiveSessionStartResult first = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Single(operations.Sessions);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.False(controller.IsInWorld);
LiveSessionStartResult retry = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, operations.Sessions.Count);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Equal(2, host.DetachCount);
}
[Fact]
public void TickFailureCleansScopeBeforeRethrowing()
{
var calls = new List<string>();
var operations = new TestOperations(calls) { ThrowOnTick = true };
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(controller.Tick);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Contains(
$"{nameof(TestOperations)}.{nameof(TestOperations.Tick)}",
error.StackTrace,
StringComparison.Ordinal);
}
// ── MUST-FIX 1 (Campaign OP OP1 review fix, mechanism lens,
// 2026-08-11): the TS-71 auto-save-timer and pre-logoff-flush hooks ────
[Fact]
public void Tick_InvokesConfiguredAutoSaveHook_WithTheCurrentSessionWhileInWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
WorldSession? seen = null;
controller.ConfigureAutoSaveTick(s => seen = s);
controller.Tick();
Assert.Same(session, seen);
}
[Fact]
public void Tick_DoesNotInvokeAutoSaveHook_WhenNotInWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
bool invoked = false;
controller.ConfigureAutoSaveTick(_ => invoked = true);
// Never started — Tick() early-returns before any hook can fire.
controller.Tick();
Assert.False(invoked);
Assert.Equal(0, operations.TickCount);
}
[Fact]
public void Tick_AutoSaveHookThrowing_DoesNotFailTheTickOrTearDownTheSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
controller.ConfigureAutoSaveTick(
_ => throw new InvalidOperationException("send failed"));
// Must not throw — a transient send error on a background auto-save
// must not tear down the whole live session.
controller.Tick();
Assert.True(controller.IsInWorld);
Assert.Same(session, controller.CurrentSession);
Assert.False(operations.DisposeCounts.ContainsKey(session));
}
[Fact]
public void Stop_InvokesConfiguredPreLogoffFlushHook_BeforeSessionDisposed()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
WorldSession? seen = null;
controller.ConfigurePreLogoffFlush(s =>
{
seen = s;
calls.Add("pre-logoff-flush");
});
controller.Stop();
Assert.Same(session, seen);
// Retail's CPlayerSystem::LogOffCharacter calls SaveToServer BEFORE
// the character-logoff wire request — the flush must precede
// WorldSession disposal.
int flushIndex = calls.IndexOf("pre-logoff-flush");
int disposeIndex = calls.IndexOf("dispose-session");
Assert.True(flushIndex >= 0);
Assert.True(disposeIndex >= 0);
Assert.True(flushIndex < disposeIndex);
}
[Fact]
public void Stop_DoesNotInvokePreLogoffFlushHook_WhenNeverEnteredWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls) { ThrowOnEnterWorld = true };
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
bool invoked = false;
controller.ConfigurePreLogoffFlush(_ => invoked = true);
// Fails before _inWorld ever becomes true — StartCore's own
// StopAfterFailure -> StopCore() runs, but the hook must not fire;
// matches retail's own call site, which only exists on an actual
// in-world character.
controller.Start(LiveOptions(), host);
Assert.False(invoked);
Assert.False(controller.IsInWorld);
}
[Fact]
public void Stop_PreLogoffFlushHookThrowing_DoesNotBlockGracefulTeardown()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
controller.ConfigurePreLogoffFlush(
_ => throw new InvalidOperationException("flush failed"));
// Must not throw — a failed flush must not block the graceful-
// shutdown sequence.
controller.Stop();
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
}
[Fact]
public void DisposeIsIdempotentAndMakesOldCommandsInert()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
TestCommandBus commands = host.CommandBuses[0];
controller.Dispose();
controller.Dispose();
commands.Publish(new object());
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Equal(0, commands.PublishCount);
Assert.False(controller.IsInWorld);
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
}
[Fact]
public void GenerationScopedStopAcknowledgesTheCompleteTeardownTransaction()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
RuntimeGenerationToken retired = controller.Generation;
RuntimeTeardownAcknowledgement acknowledgement =
controller.Stop(retired);
Assert.True(acknowledgement.IsComplete);
Assert.Equal(retired, acknowledgement.RetiredGeneration);
Assert.Equal(controller.Generation, acknowledgement.CurrentGeneration);
Assert.Equal(
RuntimeTeardownStage.Complete,
acknowledgement.CompletedStages);
}
[Fact]
public void FailedStopAcknowledgesOnlyCompletedPrefixAndRetryDrainsSuffix()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls)
{
FailEventDetachOnce = true,
};
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
RuntimeTeardownAcknowledgement failed =
controller.Stop(controller.Generation);
Assert.Equal(RuntimeCommandStatus.Rejected, failed.Status);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.CommandsInert) != 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.InboundDetached) == 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.TransportDisposed) == 0);
RuntimeTeardownAcknowledgement retry =
controller.Stop(failed.CurrentGeneration);
Assert.True(retry.IsComplete);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(2, host.EventDetachCount);
Assert.Single(operations.DisposeCounts);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user",
LiveSessionCharacterSelector? selector = null) =>
new(
live,
"127.0.0.1",
9000,
user ?? string.Empty,
"password",
selector);
private static CharacterList.Parsed AvailableCharacters() => new(
0u,
[
new CharacterList.Character(0x50000001u, "Grey", 10u),
new CharacterList.Character(0x50000002u, "Ready", 0u),
],
[new CharacterList.Character(0x50000003u, "Deleted", 0u)],
11,
"Canonical",
true,
true);
}