merge: Campaign LA LA7b - selection state and flow review-closed

This commit is contained in:
Erik 2026-08-14 19:05:03 +02:00
commit 7691cf75e2
22 changed files with 3216 additions and 46 deletions

View file

@ -219,6 +219,9 @@ src/
generation + teardown
Session/ -> J2 canonical session lifetime, ordered
inbound routing + retryable teardown
RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world
roster/highlight/delete/restore/error owner;
borrowed view + ordered deltas + typed commands
Entities/
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
RuntimeEntityRecord.cs -> presentation-free accepted entity state

View file

@ -467,7 +467,7 @@ useful ordering seam, but its ownership status is **partial**.
| Area | Status | Current truth |
|---|---|---|
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
| Network session | **Complete Runtime ownership** | `RuntimeLiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and one borrowed inertable UI command projection—no mirrored session or reset plan (`75930787`). |
| Network session | **Complete Runtime ownership** | `LiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/pre-world selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Its `RuntimeCharacterSelectionState` owns the full active roster (including greyed entries and retained wire slots), highlight, delete confirmation, restore/delete/error state, generation/lifecycle, borrowed view, ordered deltas, and typed commands. A selector-free graphical launch pauses on that owner; explicit and headless selection retain the established fallback. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and borrowed projections—no mirrored session, selection state, or reset plan. |
| World environment | **J6.1 complete Runtime ownership** | `RuntimeWorldEnvironmentState` owns the instance-scoped Dereth calendar, synchronized clock, weather progression/state, selected day group, AdminEnvirons state, and typed debug overrides. App converts immutable DAT sky definitions once and projects the borrowed Runtime snapshot into rendering; no process-global Region origin or second App clock/weather owner remains (`902076c0`). TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. |
| Live identity/lifetime | **J3 complete** | `RuntimeEntityObjectLifetime` owns the sole `RuntimeEntityDirectory`, live `ClientObjectTable`, direct views, and ordered entity/object stream. The directory owns canonical GUID/incarnation/local-ID identity, accepted snapshots/timestamps, parent state, operation versions, and tombstones. `LiveEntityProjectionStore` owns App graphical sidecars by exact `RuntimeEntityKey`; hydration, presentation components, `GpuWorldState` residence/visibility, and retryable teardown preserve that key without another authority. Exact receipts precede fallible callbacks, re-entrant commits drain synchronously in sequence, and stable reset/disposal must converge the complete ledger to zero (`f46ddb5c`, `420e5eea`, `e937cc36`, `5ef8b537`, `ce3ac310`, `119b7c11`). |
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |

View file

@ -0,0 +1,109 @@
# LA7b character-selection Runtime evidence
Date: 2026-08-14
This note records the retail evidence and the presentation-independent state
and flow implemented by Campaign LA slice LA7b. The retained character screen
is deliberately deferred to LA8.
## Named-retail evidence
The implementation was derived from
`docs/research/named-retail/acclient_2013_pseudo_c.txt` before code was
written. The controlling functions are:
- `gmCharacterManagementUI::ResetPreviouslySelectedCharacterSlot`
(`0x004ebff0`): clears the persisted selected avatar, selected guid, list
index, and character-generation slot.
- `gmCharacterManagementUI::SelectCharacter` (`0x004ec160`): resolves the
clicked guid back to the `CharacterSet` slot and persists that exact slot.
- `gmCharacterManagementUI::UpdateButtons` (`0x004ec240`): a missing or
greyed selection disables Enter and Delete; a selected greyed character
hides Delete and shows Restore.
- `gmCharacterManagementUI::RebuildCharacterList` (`0x004ec3a0`): emits every
active `CharacterSet` entry, records the previous/slot selection, chooses
the first non-greyed entry as the ordinary fallback (the first row remains
the all-grey fallback), sorts names with `wcscmp`, and then moves greyed
rows to the tail without losing their identity.
- `gmCharacterManagementUI::MakeDeleteCharacterConfirmationDialog`
(`0x004ecca0`) and `CloseDeleteCharacterDialog` (`0x004ed4a0`): deletion is
modal, and confirmation sends the persisted selected avatar.
- `gmCharacterManagementUI::ListenToElementMessage` (`0x004ed5a0`): Restore
sends the selected guid, Enter is accepted from the button and list-row
activation, and both delete/restore open retail's wait dialog.
- `gmCharacterManagementUI::EnterGame` (`0x004ed440`): entry is allowed only
for a non-zero selected guid whose `GetGreyedOutFor(GetSlot(guid))` value is
zero.
- `CharacterSet::GetSlot` (`0x004fdf60`) and `GetGreyedOutFor`
(`0x004fdfa0`) prove that delete/enter retain the original wire slot even
after display sorting, and that any non-zero grey value is disabled.
- `CPlayerSystem::Handle_CharacterError` (`0x0055d5d0`) clears the ready and
awaiting-logon latches before forwarding the typed error. The downstream
`gmUIFlow::RecvNotice_CharacterError` (`0x0047a7c0`) bounds its real display
switch to enum values below `CHAR_ERROR_NUM_ERRORS`; the `0x19` member is a
count sentinel, not a message.
Equivalent state pseudocode:
```text
on roster:
remember selected guid
rows = every active CharacterSet entry, retaining original slot
fallback = first row, replaced by first non-greyed row in wire order
sort rows by ordinal/wcscmp name
stable-partition non-greyed before greyed
selected = previous guid if still present, otherwise fallback
buttons(selected):
none or greyed -> Enter disabled, Delete disabled
greyed -> Delete hidden, Restore visible/enabled
active -> Delete visible/enabled, Restore hidden
enter(selected):
require guid != 0 and grey == 0
use retained wire slot through the existing EnterWorld transaction
```
The `CharacterList.DeletedCharacters` array is not projected into this list:
retail rebuilds from the active `CharacterSet`; pending-delete identities are
the greyed entries in that active set. No entry is filtered merely because it
is greyed.
## Runtime ownership and flow
`GameRuntime` owns one `LiveSessionController`, which owns one
`RuntimeCharacterSelectionState` for the exact `WorldSession` generation.
The owner contains the canonical roster, wire slots, highlight, delete
confirmation, delete/restore progress, mapped error, revision, lifecycle, and
generation. App receives only the borrowed `IRuntimeCharacterSelectionView`;
all mutation uses synchronous generation-gated typed commands. Ordered deltas
use one monotonic sequence and preserve re-entrant publication order while
isolating observer failures.
A graphical launch with no explicit selector sets
`AwaitCharacterSelection`. Connect reports and adopts the roster, starts the
sole pre-world receive loop, and returns `AwaitingCharacterSelection` without
calling EnterWorld. A typed Enter command continues the same session and
generation through the established two-phase EnterWorld path. Explicit
graphical selectors and direct/headless callers retain the established
first-available fallback because the new flag defaults to false.
Immediate/headless entry keeps the original blocking receive/sweep pump until
ServerReady. Only a host that actually pauses at character selection starts
the asynchronous pre-world receiver. This preserves reliable-transport
resend/NAK timing and avoids two concurrent socket readers.
LA7a's wire contracts are routed as follows:
- delete `0xF655`: account plus retained active slot, LoginQueue;
- restore `0xF7D9`: selected guid, ControlQueue (AD-97 remains the recorded
guid-only adaptation);
- delete ack, restore `0xF643`, refreshed `CharacterList`, and
`CharacterError 0xF659`: ordered UIQueue input.
ACE can silently return from restore for an unknown guid. Restore is therefore
a fire-and-observe command: it installs no command gate and never waits for a
reply. A later matching response updates the same owner; a fresh roster,
entry attempt, reset, reconnect, or disposal disarms stale response
correlation. `CharacterError.NumErrors` is ignored without a revision or
delta and can never become presentation text.

View file

@ -1139,7 +1139,9 @@ internal sealed class SessionPlayerCompositionPhase
d.Options.LivePort,
d.Options.LiveUser ?? string.Empty,
d.Options.LivePass ?? string.Empty,
d.Options.LiveCharacterSelector));
d.Options.LiveCharacterSelector,
AwaitCharacterSelection:
d.Options.LiveCharacterSelector is null));
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
// The ImGui developer-tools debug toast sink was removed at Campaign V

View file

@ -20,6 +20,7 @@ internal sealed class CurrentGameRuntimeAdapter
{
private readonly GameRuntime _runtime;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private readonly CharacterSelectionProjection _characterSelection;
private readonly IDisposable _hostLease;
private readonly object _subscriptionGate = new();
private readonly HashSet<AdapterSubscription> _subscriptions = [];
@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
"graphical game-runtime command adapter");
try
{
_characterSelection = new CharacterSelectionProjection(this);
_commands = new CurrentGameRuntimeCommandAdapter(
runtime.Session,
sessionHost,
@ -61,7 +63,7 @@ internal sealed class CurrentGameRuntimeAdapter
}
private bool IsActive =>
!_disposed
!Volatile.Read(ref _disposed)
&& !_runtime.Session.IsDisposalComplete;
public RuntimeGenerationToken Generation => _runtime.Generation;
@ -89,6 +91,8 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeCharacterView Character => _runtime.Character;
public IRuntimeSocialView Social => _runtime.Social;
public IRuntimeChatView Chat => _runtime.Chat;
public IRuntimeCharacterSelectionView CharacterSelection =>
_characterSelection;
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
public IRuntimeActionView Actions => _runtime.Actions;
@ -97,6 +101,10 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimePortalView Portal => _runtime.Portal;
public IRuntimeSessionCommands Session => _commands;
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
_characterSelection;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
_characterSelection;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMagicCommands Magic => _commands;
@ -160,6 +168,187 @@ internal sealed class CurrentGameRuntimeAdapter
_subscriptions.Remove(subscription);
}
private RuntimeCharacterSelectionSnapshot CharacterSelectionSnapshot()
{
lock (_subscriptionGate)
{
if (IsActive)
return _runtime.CharacterSelection.Snapshot;
return new RuntimeCharacterSelectionSnapshot(
_runtime.Generation,
RuntimeCharacterSelectionLifecycle.Inactive,
Revision: 0,
AccountName: string.Empty,
SlotCount: 0,
RosterCount: 0,
HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: RuntimeCharacterSelectionButtons.None);
}
}
private bool TryGetCharacterSelectionAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character)
{
lock (_subscriptionGate)
{
if (IsActive)
{
return _runtime.CharacterSelection.TryGetAt(
displayIndex,
out character);
}
character = default;
return false;
}
}
private bool TryGetCharacterSelection(
uint characterId,
out RuntimeCharacterSelectionEntry character)
{
lock (_subscriptionGate)
{
if (IsActive)
{
return _runtime.CharacterSelection.TryGet(
characterId,
out character);
}
character = default;
return false;
}
}
private void VisitCharacterSelection(
IRuntimeCharacterSelectionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
lock (_subscriptionGate)
{
if (IsActive)
_runtime.CharacterSelection.Visit(visitor);
}
}
private IDisposable SubscribeCharacterSelection(
IRuntimeCharacterSelectionObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_subscriptionGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var gated = new AdapterCharacterSelectionObserver(this, observer);
IDisposable runtimeSubscription =
_runtime.CharacterSelection.Subscribe(gated);
var subscription = new AdapterSubscription(
this,
runtimeSubscription);
_subscriptions.Add(subscription);
return subscription;
}
}
private RuntimeCommandResult ExecuteCharacterSelection(
Func<IRuntimeCharacterSelectionCommands, RuntimeCommandResult> execute)
{
lock (_subscriptionGate)
{
if (!IsActive)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
_runtime.Generation);
}
return execute(_runtime.Session);
}
}
private void ForwardCharacterSelection(
IRuntimeCharacterSelectionObserver observer,
in RuntimeCharacterSelectionDelta delta)
{
lock (_subscriptionGate)
{
if (IsActive)
observer.OnCharacterSelectionChanged(in delta);
}
}
private sealed class CharacterSelectionProjection(
CurrentGameRuntimeAdapter owner)
: IRuntimeCharacterSelectionView,
IRuntimeCharacterSelectionCommands
{
public RuntimeCharacterSelectionSnapshot Snapshot =>
owner.CharacterSelectionSnapshot();
public bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGetCharacterSelectionAt(displayIndex, out character);
public bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGetCharacterSelection(characterId, out character);
public void Visit(IRuntimeCharacterSelectionVisitor visitor) =>
owner.VisitCharacterSelection(visitor);
public IDisposable Subscribe(
IRuntimeCharacterSelectionObserver observer) =>
owner.SubscribeCharacterSelection(observer);
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId) =>
owner.ExecuteCharacterSelection(
commands => commands.Highlight(
expectedGeneration,
characterId));
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Enter(expectedGeneration));
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.RequestDelete(expectedGeneration));
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.ConfirmDelete(expectedGeneration));
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Restore(expectedGeneration));
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Cancel(expectedGeneration));
}
private sealed class AdapterCharacterSelectionObserver(
CurrentGameRuntimeAdapter owner,
IRuntimeCharacterSelectionObserver observer)
: IRuntimeCharacterSelectionObserver
{
public void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta) =>
owner.ForwardCharacterSelection(observer, in delta);
}
private sealed class AdapterSubscription(
CurrentGameRuntimeAdapter owner,
IDisposable runtimeSubscription) : IDisposable

View file

@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net;
/// <summary>
/// EnterWorld was rejected by the server while the transport remains a valid
/// character-select session. Callers may surface <see cref="Error"/> and let
/// the user choose another character instead of tearing down the connection.
/// </summary>
public sealed class CharacterSelectionRejectedException(
CharacterError.Parsed error)
: InvalidOperationException(
$"The server rejected character entry with error 0x{error.RawErrorCode:X8}.")
{
public CharacterError.Parsed Error { get; } = error;
}
internal interface IWorldSessionTransport : IDisposable
{
void Send(ReadOnlySpan<byte> datagram);
@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged;
/// <summary>
/// Pre-world character-management replies. All are decoded on the same
/// caller thread and in the same fragment order as ordinary world events.
/// ACE routes these replies on UIQueue; the queue is consumed by the
/// transport before this typed boundary.
/// </summary>
public event Action<CharacterList.Parsed>? CharacterListReceived;
public event Action? CharacterDeleteAcknowledged;
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
/// <summary>
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
/// handler is registered here (by GameWindow / UI layer / chat
@ -667,6 +691,7 @@ public sealed class WorldSession : IDisposable
}
public CharacterList.Parsed? Characters { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError;
private readonly IWorldSessionTransport _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
@ -1019,6 +1044,22 @@ public sealed class WorldSession : IDisposable
SweepTransport();
}
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
}
/// <summary>
/// Starts the sole asynchronous receive loop while the session remains at
/// character selection. Graphical hosts call this only when they actually
/// pause before <see cref="EnterWorld"/>; immediate and headless entry keep
/// the original blocking handshake pump until ServerReady is accepted.
/// </summary>
public void StartCharacterSelectionReceive()
{
if (CurrentState != State.InCharacterSelect)
throw new InvalidOperationException(
"character-selection receive requires InCharacterSelect state");
EnsureNetReceiveLoopStarted();
}
/// <summary>
@ -1045,14 +1086,57 @@ public sealed class WorldSession : IDisposable
// the blocking pump (campaign landmine #8): the EnterWorld
// CreateObject flood — and any NAK it provokes — precedes the
// first Tick().
bool serverReady = false;
while (DateTime.UtcNow < deadline && !serverReady)
_lastCharacterSelectionError = null;
bool serverReady;
if (_netReceiveTask is null)
{
var drained = PumpOnce(out var opcodes);
SweepTransport();
if (!drained) continue;
foreach (var op in opcodes)
if (op == 0xF7DFu) { serverReady = true; break; }
// Immediate/headless entry deliberately preserves the blocking
// transport pump. Besides matching the established handshake
// contract, Receive supplies the clock edge used by the reliable
// transport's resend/NAK sweep on otherwise quiet connections.
serverReady = false;
while (DateTime.UtcNow < deadline
&& !serverReady
&& _lastCharacterSelectionError is null)
{
bool drained = PumpOnce(out List<uint> opcodes);
SweepTransport();
if (!drained)
continue;
foreach (uint opcode in opcodes)
{
if (opcode == 0xF7DFu)
{
serverReady = true;
break;
}
}
}
}
else
{
TimeSpan remaining = deadline - DateTime.UtcNow;
serverReady = remaining > TimeSpan.Zero
&& WaitForCharacterLogOffConfirmation(
_inboundQueue.Reader,
remaining,
datagram =>
{
var opcodes = new List<uint>();
ProcessDatagram(datagram.Memory, opcodes);
return opcodes.Contains(0xF7DFu)
|| _lastCharacterSelectionError is not null;
},
ReturnInboundDatagram,
SweepTransport,
TimeSpan.FromMilliseconds(25));
}
if (_lastCharacterSelectionError is { } selectionError)
{
Transition(State.InCharacterSelect);
EnsureNetReceiveLoopStarted();
throw new CharacterSelectionRejectedException(selectionError);
}
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
@ -1067,14 +1151,15 @@ public sealed class WorldSession : IDisposable
// Hidden/pink-bubble login state.
Transition(State.InWorld);
// Phase A.3: start the background receive thread now that the
// handshake is complete and the session is fully established.
// During Connect() and EnterWorld(), PumpOnce() read directly
// from the socket (blocking). From here on, Tick() drains the
// channel instead.
_netReceiveTask = NetReceiveLoopAsync();
// A paused selector already owns the socket through the background
// receiver. Immediate/headless entry starts that same sole receiver
// only after its blocking ServerReady handshake has completed.
EnsureNetReceiveLoopStarted();
}
private void EnsureNetReceiveLoopStarted() =>
_netReceiveTask ??= NetReceiveLoopAsync();
internal readonly record struct EnterWorldSelection(
CharacterList.Character Character,
byte[] EnterWorldBody);
@ -1139,8 +1224,9 @@ public sealed class WorldSession : IDisposable
ReturnInboundDatagram(datagram);
}
processed++;
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
// (the async receive owner starts at Transition(State.InWorld)).
// Bound ONLY in-world: immediate/headless handshakes use blocking
// PumpOnce, while a deliberately paused selector uses Tick without
// an in-world flood budget so management replies drain promptly.
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
@ -1687,10 +1773,59 @@ public sealed class WorldSession : IDisposable
if (!dispatchWorldEvents)
continue;
if (op == CharacterList.Opcode && Characters is null)
if (op == CharacterList.Opcode)
{
try { Characters = CharacterList.Parse(body); }
catch { /* malformed — ignore and keep draining */ }
CharacterList.Parsed parsed;
try
{
parsed = CharacterList.Parse(body);
}
catch
{
// Malformed management messages do not poison the
// remaining ordered UIQueue fragments.
continue;
}
Characters = parsed;
CharacterListReceived?.Invoke(parsed);
}
else if (op == CharacterDelete.Opcode
&& CharacterDelete.IsAcknowledgement(body))
{
CharacterDeleteAcknowledged?.Invoke();
}
else if (op == CharacterRestore.ResponseOpcode)
{
CharacterRestore.Parsed parsed;
try
{
parsed = CharacterRestore.Parse(body);
}
catch
{
continue;
}
CharacterRestoreReceived?.Invoke(parsed);
}
else if (op == CharacterError.Opcode)
{
CharacterError.Parsed parsed;
try
{
parsed = CharacterError.Parse(body);
}
catch
{
continue;
}
// CharacterError::NumErrors is the enum-count sentinel, not
// a server rejection. Retail never presents it, and treating
// it as an EnterWorld failure would abort either handshake
// pump before a valid ServerReady later in the same packet.
if (parsed.AsCode == CharacterError.Code.NumErrors)
continue;
_lastCharacterSelectionError = parsed;
CharacterErrorReceived?.Invoke(parsed);
}
else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
{
@ -2040,6 +2175,29 @@ public sealed class WorldSession : IDisposable
SendGameMessage(gameActionBody);
}
/// <summary>
/// Send retail CharacterDelete through the login/logon queue. The caller
/// supplies the selected entry's active CharacterSet slot, not its guid.
/// </summary>
public void SendDeleteCharacter(string accountName, int activeIndex)
{
ArgumentNullException.ThrowIfNull(accountName);
if (activeIndex < 0)
throw new ArgumentOutOfRangeException(nameof(activeIndex));
SendGameMessage(
CharacterDelete.BuildRequestBody(
accountName,
checked((uint)activeIndex)),
GameMessageGroup.LoginQueue);
}
/// <summary>
/// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids.
/// </summary>
public void SendRestoreCharacter(uint characterId) =>
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
/// <summary>
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
/// invokes this instead of writing to the wire. Lets unit tests verify
@ -2049,6 +2207,9 @@ public sealed class WorldSession : IDisposable
/// </summary>
internal Action<byte[]>? GameActionCapture { get; set; }
/// <summary>LA7b unit-test seam for queue-sensitive pre-world sends.</summary>
internal Action<byte[], GameMessageGroup>? GameMessageCapture { get; set; }
/// <summary>
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
@ -2895,6 +3056,11 @@ public sealed class WorldSession : IDisposable
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
if (GameMessageCapture is { } capture)
{
capture(gameMessageBody, queue);
return;
}
// #260 probe: log the send BEFORE the sequence counters are consumed
// so the line carries the values this datagram will actually use. The
// exception filter below logs a wire-write fault WITHOUT catching it
@ -3180,10 +3346,15 @@ public sealed class WorldSession : IDisposable
ChannelReader<T> reader,
TimeSpan timeout,
Func<T, bool> processAndCheckConfirmation,
Action<T>? release = null)
Action<T>? release = null,
Action? periodicWork = null,
TimeSpan? periodicInterval = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
TimeSpan cadence = periodicInterval ?? TimeSpan.FromMilliseconds(25);
if (periodicWork is not null && cadence <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(periodicInterval));
using var timeoutSource = new CancellationTokenSource(timeout);
// The deadline is also read straight off the monotonic clock, not only
@ -3223,10 +3394,52 @@ public sealed class WorldSession : IDisposable
return true;
}
bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
periodicWork?.Invoke();
if (timeoutSource.IsCancellationRequested || Expired())
return false;
bool canRead;
if (periodicWork is null)
{
canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
else
{
TimeSpan wait = cadence;
if (bounded)
{
TimeSpan remaining = timeout
- Stopwatch.GetElapsedTime(started);
if (remaining <= TimeSpan.Zero)
return false;
if (remaining < wait)
wait = remaining;
}
using var sliceSource =
CancellationTokenSource.CreateLinkedTokenSource(
timeoutSource.Token);
sliceSource.CancelAfter(wait);
try
{
canRead = reader.WaitToReadAsync(sliceSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
catch (OperationCanceledException)
when (!timeoutSource.IsCancellationRequested
&& !Expired())
{
// This cadence is the paused selector's frame edge:
// keep reliable transport work moving even when no
// datagram arrives to wake the inbound queue.
continue;
}
}
if (!canRead)
return false;
}

View file

@ -178,8 +178,12 @@ public sealed class GameRuntime
faultInjection);
context.Session = dependencies.SessionOperations is null
? new LiveSessionController()
: new LiveSessionController(dependencies.SessionOperations);
? new LiveSessionController(
ProductionLiveSessionOperations.Instance,
dependencies.TimeProvider)
: new LiveSessionController(
dependencies.SessionOperations,
dependencies.TimeProvider);
construction.Own(context.Session);
Fault(
GameRuntimeConstructionPoint.SessionCreated,
@ -527,6 +531,8 @@ public sealed class GameRuntime
public IRuntimeCharacterView Character => CharacterOwner.View;
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeCharacterSelectionView CharacterSelection =>
Session.CharacterSelection;
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;

View file

@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus
/// exit-code mapping, not a failure.
/// </summary>
ProbeComplete,
AwaitingCharacterSelection,
}
public readonly record struct RuntimeSessionStartResult(
@ -381,6 +382,10 @@ public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
Session.IRuntimeCharacterSelectionCommands CharacterSelection =>
throw new NotSupportedException(
"This command adapter does not project character selection.");
IRuntimeSelectionCommands Selection { get; }
IRuntimeCombatCommands Combat { get; }

View file

@ -1,5 +1,6 @@
using AcDream.Core.Physics;
using AcDream.Runtime.World;
using AcDream.Runtime.Session;
namespace AcDream.Runtime;
@ -275,6 +276,10 @@ public interface IGameRuntimeView
IRuntimeChatView Chat { get; }
IRuntimeCharacterSelectionView CharacterSelection =>
throw new NotSupportedException(
"This runtime view does not project character selection.");
IRuntimeFellowshipView Fellowship { get; }
IRuntimeAllegianceView Allegiance { get; }

View file

@ -72,6 +72,8 @@ public sealed class DirectGameRuntimeCommandAdapter
}
public IRuntimeSessionCommands Session => this;
public IRuntimeCharacterSelectionCommands CharacterSelection =>
_runtime.Session;
public IRuntimeSelectionCommands Selection => this;
public IRuntimeCombatCommands Combat => this;
public IRuntimeMagicCommands Magic => this;

View file

@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions(
/// itself does not enforce that pairing — the headless config loader
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
/// </summary>
bool Probe = false);
bool Probe = false,
/// <summary>
/// Keep the connected transport in Runtime's pre-world selection state
/// instead of applying the legacy first-available fallback. Graphical
/// composition enables this only when no explicit selector was supplied;
/// direct/headless callers retain the existing default behavior.
/// </summary>
bool AwaitCharacterSelection = false);
public interface IRuntimeLiveSessionFramePhase
{

View file

@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
Deferred,
Failed,
/// <summary>
/// The account transport is connected and Runtime owns a live roster,
/// but EnterWorld has deliberately not run yet.
/// </summary>
AwaitingCharacterSelection,
/// <summary>
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
@ -167,7 +172,16 @@ public interface ILiveSessionOperations
WorldSession CreateSession(IPEndPoint endpoint);
void Connect(WorldSession session, string user, string password);
CharacterList.Parsed? GetCharacters(WorldSession session);
void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive();
void EnterWorld(WorldSession session, int activeCharacterIndex);
void DeleteCharacter(
WorldSession session,
string accountName,
int activeCharacterIndex) =>
session.SendDeleteCharacter(accountName, activeCharacterIndex);
void RestoreCharacter(WorldSession session, uint characterId) =>
session.SendRestoreCharacter(characterId);
void Tick(WorldSession session);
void DisposeSession(WorldSession session);
}
@ -203,6 +217,9 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
public void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive();
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
session.EnterWorld(activeCharacterIndex);
@ -219,8 +236,49 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// </summary>
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase
IRuntimeLiveSessionFramePhase,
IRuntimeCharacterSelectionCommands
{
private sealed class CharacterSelectionWireBinding : IDisposable
{
private WorldSession? _session;
private readonly Action<CharacterList.Parsed> _roster;
private readonly Action _delete;
private readonly Action<CharacterRestore.Parsed> _restore;
private readonly Action<CharacterError.Parsed> _error;
public CharacterSelectionWireBinding(
WorldSession session,
Action<CharacterList.Parsed> roster,
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error)
{
_session = session;
_roster = roster;
_delete = delete;
_restore = restore;
_error = error;
session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error;
}
public bool IsDisposed => _session is null;
public void Dispose()
{
WorldSession? session = Interlocked.Exchange(ref _session, null);
if (session is null)
return;
session.CharacterListReceived -= _roster;
session.CharacterDeleteAcknowledged -= _delete;
session.CharacterRestoreReceived -= _restore;
session.CharacterErrorReceived -= _error;
}
}
private sealed class SessionScope(
WorldSession session,
ILiveSessionLifecycleHost host,
@ -232,6 +290,11 @@ public sealed class LiveSessionController
public ILiveSessionLifecycleHost Host { get; } = host;
public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; }
public CharacterSelectionWireBinding? CharacterSelectionBinding
{
get;
set;
}
public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; }
@ -242,13 +305,18 @@ public sealed class LiveSessionController
try
{
Binding?.Dispose();
CharacterSelectionBinding?.Dispose();
}
finally
{
if (Binding is null || Binding.CommandsDeactivated)
CompletedStages |= RuntimeTeardownStage.CommandsInert;
if (Binding is null || Binding.EventsDetached)
if ((Binding is null || Binding.EventsDetached)
&& (CharacterSelectionBinding is null
|| CharacterSelectionBinding.IsDisposed))
{
CompletedStages |= RuntimeTeardownStage.InboundDetached;
}
}
_teardownStage = 1;
}
@ -308,15 +376,24 @@ public sealed class LiveSessionController
private Action<WorldSession>? _preLogoffFlushHook;
public LiveSessionController()
: this(ProductionLiveSessionOperations.Instance)
: this(ProductionLiveSessionOperations.Instance, null)
{
}
public LiveSessionController(ILiveSessionOperations operations)
public LiveSessionController(
ILiveSessionOperations operations,
TimeProvider? timeProvider = null)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState(
timeProvider);
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
public IRuntimeCharacterSelectionView CharacterSelection =>
CharacterSelectionState.View;
public WorldSession? CurrentSession
{
get { lock (_gate) return _scope?.Session; }
@ -405,6 +482,11 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
if (_inWorld)
return ConnectedResult();
if (_scope is not null)
{
return new LiveSessionStartResult(
LiveSessionStartStatus.AwaitingCharacterSelection);
}
return RunTopLevel(() => StartCore(options, host, resetHost: true));
}
}
@ -492,7 +574,7 @@ public sealed class LiveSessionController
{
if (_disposed)
return;
if (!_inWorld || _scope is null || _operationDepth != 0)
if (_scope is null || _operationDepth != 0)
return;
RunTopLevel(() =>
@ -504,6 +586,7 @@ public sealed class LiveSessionController
_operations.Tick(scope.Session);
if (!IsCurrent(scope, generation))
return;
CharacterSelectionState.SweepRestoreCorrelation();
}
catch (Exception tickError)
{
@ -517,7 +600,8 @@ public sealed class LiveSessionController
// the protocol pump above and only when the scope/generation
// are still current — a reconnect that happened mid-tick
// must not flush against a retired session.
InvokeAutoSaveTick(scope.Session);
if (_inWorld)
InvokeAutoSaveTick(scope.Session);
});
}
}
@ -583,6 +667,8 @@ public sealed class LiveSessionController
{
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
RuntimeGenerationToken activeGeneration = new(generation);
CharacterSelectionState.Reset(activeGeneration);
try
{
DrainRetiredScope();
@ -608,6 +694,8 @@ public sealed class LiveSessionController
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
CharacterSelectionState.Begin(activeGeneration);
SessionScope? scope = null;
try
{
@ -643,6 +731,9 @@ public sealed class LiveSessionController
_operations.Connect(session, options.User, options.Password);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
scope.CharacterSelectionBinding = BindCharacterSelection(
scope,
generation);
host.ReportConnected();
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -650,7 +741,9 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is not null)
{
host.ReportRoster(BuildRosterReport(characters));
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
@ -670,6 +763,19 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
}
if (options.AwaitCharacterSelection
&& options.Character is null
&& characters is not null)
{
_operations.StartCharacterSelectionReceive(session);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine(
"live: awaiting character selection before EnterWorld");
return new LiveSessionStartResult(
LiveSessionStartStatus.AwaitingCharacterSelection);
}
if (characters is null
|| !TrySelectCharacter(
characters,
@ -686,6 +792,12 @@ public sealed class LiveSessionController
selected.Character.Id,
selected.Character.Name,
characters.AccountName);
if (!CharacterSelectionState.TryHighlight(selection.CharacterId)
|| !CharacterSelectionState.BeginEnter(out _))
{
throw new InvalidOperationException(
"Runtime character selection rejected the validated active character.");
}
host.ApplySelectedCharacter(selection);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -701,6 +813,7 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
_inWorld = true;
_activeSelection = selection;
CharacterSelectionState.CompleteEnter(selection.CharacterId);
host.ApplyEnteredWorld(selection);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -734,6 +847,273 @@ public sealed class LiveSessionController
}
}
private CharacterSelectionWireBinding BindCharacterSelection(
SessionScope scope,
ulong generation) =>
new(
scope.Session,
roster =>
{
lock (_gate)
{
if (!IsCurrent(scope, generation))
return;
LiveSessionRosterReport report = BuildRosterReport(roster);
CharacterSelectionState.ApplyRoster(report);
scope.Host.ReportRoster(report);
}
},
() =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyDeleteAcknowledged();
}
},
restore =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyRestore(restore);
}
},
error =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyError(error);
}
});
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
RuntimeCommandStatus status =
CharacterSelectionState.TryHighlight(characterId)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
return CharacterSelectionResult(status, characterId);
}
}
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (_operationDepth != 0)
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
return RunTopLevel(EnterSelectedCore);
}
}
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
RuntimeCommandStatus status =
CharacterSelectionState.TryRequestDelete(out uint characterId)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
return CharacterSelectionResult(status, characterId);
}
}
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (!CharacterSelectionState.TryTakeDeleteConfirmation(
out RuntimeCharacterSelectionEntry character,
out string accountName))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.DeleteCharacter(
_scope!.Session,
accountName,
character.ActiveIndex);
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch
{
CharacterSelectionState.ApplyError(
new CharacterError.Parsed(
(uint)CharacterError.Code.Delete));
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
}
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (!CharacterSelectionState.TryBeginRestore(
out RuntimeCharacterSelectionEntry character))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.RestoreCharacter(
_scope!.Session,
character.CharacterId);
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch
{
CharacterSelectionState.ApplyError(
new CharacterError.Parsed(
(uint)CharacterError.Code.Undefined));
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
}
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
return CharacterSelectionResult(
CharacterSelectionState.Cancel()
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected);
}
}
private RuntimeCommandResult EnterSelectedCore()
{
SessionScope scope = _scope!;
ulong generation = _generation;
if (!CharacterSelectionState.BeginEnter(
out RuntimeCharacterSelectionEntry character))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
RuntimeCharacterSelectionSnapshot snapshot =
CharacterSelectionState.Snapshot;
var selection = new LiveSessionCharacterSelection(
character.ActiveIndex,
character.CharacterId,
character.Name,
snapshot.AccountName);
try
{
scope.Host.ApplySelectedCharacter(selection);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_operations.EnterWorld(scope.Session, character.ActiveIndex);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
scope.Binding!.ActivateCommands();
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_inWorld = true;
_activeSelection = selection;
CharacterSelectionState.CompleteEnter(character.CharacterId);
scope.Host.ApplyEnteredWorld(selection);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
Console.WriteLine("live: in world — CreateObject stream active");
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch (CharacterSelectionRejectedException rejected)
{
if (CharacterSelectionState.Snapshot.Error?.RawCode
!= rejected.Error.RawErrorCode)
{
CharacterSelectionState.ApplyError(rejected.Error);
}
CharacterSelectionState.ReturnToSelection();
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
catch (Exception error)
{
_ = StopAfterFailure(error);
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
private RuntimeCommandStatus ValidateCharacterSelectionCommand(
RuntimeGenerationToken expectedGeneration)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
return RuntimeCommandStatus.StaleGeneration;
if (_disposed || _disposeRequested || _scope is null || _inWorld)
return RuntimeCommandStatus.Inactive;
return CharacterSelectionState.Snapshot.Lifecycle
== RuntimeCharacterSelectionLifecycle.AwaitingSelection
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Inactive;
}
private RuntimeCommandResult CharacterSelectionResult(
RuntimeCommandStatus status,
uint characterId = 0u) =>
new(
status,
new RuntimeGenerationToken(_generation),
characterId);
private void StopCore()
{
// MUST-FIX 1: TS-71's logout-flush half — retail's
@ -749,6 +1129,7 @@ public sealed class LiveSessionController
++_generation;
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
if (_scope is { } scope)
{
_scope = null;
@ -889,6 +1270,7 @@ public sealed class LiveSessionController
private void DisposeCore()
{
StopCore();
CharacterSelectionState.Dispose();
_disposed = true;
}

View file

@ -318,6 +318,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
RuntimeSessionStartStatus.Failed,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
LiveSessionStartStatus.AwaitingCharacterSelection =>
RuntimeSessionStartStatus.AwaitingCharacterSelection,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,

File diff suppressed because it is too large Load diff

View file

@ -134,6 +134,30 @@ public sealed class SessionPlayerCompositionTests
"LiveSessionRuntimeFactory.cs")), StringComparison.Ordinal);
}
[Fact]
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
{
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains(
"AwaitCharacterSelection:",
phase,
StringComparison.Ordinal);
Assert.Contains(
"d.Options.LiveCharacterSelector is null",
phase,
StringComparison.Ordinal);
Assert.DoesNotContain(
"CharacterList.TrySelectFirstAvailable",
phase,
StringComparison.Ordinal);
}
private sealed class RetryBinding(
string name,
List<string> calls,

View file

@ -62,7 +62,7 @@ public sealed class RuntimeOptionsSessionConfigTests
}
[Fact]
public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
public void AbsentCharacterSelectorRemainsNullForGraphicalSelectionFlow()
{
var config = new SessionConfiguration { Version = 1 };
var session = new SessionDescriptor

View file

@ -173,6 +173,112 @@ public sealed class CurrentGameRuntimeAdapterTests
.ToArray());
}
[Fact]
public void AdapterBorrowsPreWorldSelectionAndCommandsEnterTheSameRuntimeOwner()
{
using var harness = new Harness(awaitCharacterSelection: true);
RuntimeGenerationToken initial = harness.Runtime.Generation;
RuntimeSessionStartResult start = harness.Runtime.Session.Start(initial);
Assert.Equal(
RuntimeSessionStartStatus.AwaitingCharacterSelection,
start.Status);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
Assert.Equal(2, harness.Runtime.CharacterSelection.Snapshot.RosterCount);
RuntimeGenerationToken generation = harness.Runtime.Generation;
Assert.True(harness.Runtime.CharacterSelectionCommands.Highlight(
generation,
0x50000001u).Accepted);
Assert.True(
harness.Runtime.CharacterSelection.Snapshot.Buttons.CanRestore);
Assert.Equal(
RuntimeCommandStatus.Rejected,
harness.Runtime.CharacterSelectionCommands.Enter(generation).Status);
Assert.True(harness.Runtime.CharacterSelectionCommands.Highlight(
generation,
Harness.PlayerGuid).Accepted);
Assert.True(harness.Runtime.CharacterSelectionCommands.Enter(
generation).Accepted);
Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.InWorld,
harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
}
[Fact]
public void DisposedAdapterMakesRetainedSelectionRoutesInertWhileRuntimeLives()
{
using var harness = new Harness(awaitCharacterSelection: true);
using CurrentGameRuntimeAdapter survivor =
harness.CreateAdditionalAdapter();
Assert.Equal(
RuntimeSessionStartStatus.AwaitingCharacterSelection,
harness.Runtime.Session.Start(harness.Runtime.Generation).Status);
RuntimeGenerationToken generation = harness.Runtime.Generation;
IRuntimeCharacterSelectionView retainedView =
harness.Runtime.CharacterSelection;
IRuntimeCharacterSelectionCommands publicCommands =
harness.Runtime.CharacterSelectionCommands;
IRuntimeCharacterSelectionCommands interfaceCommands =
((IGameRuntimeCommands)harness.Runtime).CharacterSelection;
var observer = new CharacterSelectionObserver();
using IDisposable subscription = retainedView.Subscribe(observer);
RuntimeCharacterSelectionSnapshot before =
survivor.CharacterSelection.Snapshot;
harness.Runtime.Dispose();
Assert.False(harness.Runtime.Lifecycle.HasTransport);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.Inactive,
retainedView.Snapshot.Lifecycle);
Assert.Equal(0, retainedView.Snapshot.RosterCount);
Assert.False(retainedView.TryGetAt(0, out _));
Assert.False(retainedView.TryGet(Harness.PlayerGuid, out _));
var visitor = new CharacterSelectionVisitor();
retainedView.Visit(visitor);
Assert.Empty(visitor.Entries);
Assert.Throws<ObjectDisposedException>(() =>
retainedView.Subscribe(new CharacterSelectionObserver()));
RuntimeCommandStatus[] statuses =
[
publicCommands.Highlight(generation, 0x50000001u).Status,
publicCommands.Enter(generation).Status,
publicCommands.RequestDelete(generation).Status,
interfaceCommands.ConfirmDelete(generation).Status,
interfaceCommands.Restore(generation).Status,
interfaceCommands.Cancel(generation).Status,
];
Assert.All(statuses, status =>
Assert.Equal(RuntimeCommandStatus.Inactive, status));
RuntimeCharacterSelectionSnapshot after =
survivor.CharacterSelection.Snapshot;
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.HighlightedCharacterId, after.HighlightedCharacterId);
Assert.Equal(before.Operation, after.Operation);
// The second lease proves the canonical Runtime is still live, while
// the disposed adapter's already-returned observer stays detached.
Assert.True(survivor.CharacterSelectionCommands.Highlight(
generation,
0x50000001u).Accepted);
Assert.Equal(0x50000001u,
survivor.CharacterSelection.Snapshot.HighlightedCharacterId);
Assert.Empty(observer.Deltas);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.Inactive,
harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
}
[Fact]
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
{
@ -697,8 +803,9 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly LiveSessionController _session;
private readonly IDisposable _combatModeBinding;
private readonly GameRuntime _gameRuntime;
private readonly SelectionInteractionController _selectionController;
public Harness()
public Harness(bool awaitCharacterSelection = false)
{
CombatAttackOperations = new CombatAttackOperationsSlot();
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
@ -751,19 +858,23 @@ public sealed class CurrentGameRuntimeAdapterTests
sendWield: null,
sendDrop: null);
var query = new SelectionQuery(TargetGuid);
var selectionController = new SelectionInteractionController(
_selectionController = new SelectionInteractionController(
Selection,
query,
_items,
new SelectionTransport(() => _session?.IsInWorld == true),
new NoopInteractionMovement());
Host = CreateHost(_session, Commands, Identity);
Host = CreateHost(
_session,
Commands,
Identity,
awaitCharacterSelection);
Runtime = new CurrentGameRuntimeAdapter(
_gameRuntime,
Host,
Commands,
selectionController);
_selectionController);
}
public RuntimeOptions Options { get; }
@ -800,6 +911,13 @@ public sealed class CurrentGameRuntimeAdapterTests
public LiveSessionHost Host { get; }
public CurrentGameRuntimeAdapter Runtime { get; }
public CurrentGameRuntimeAdapter CreateAdditionalAdapter() =>
new(
_gameRuntime,
Host,
Commands,
_selectionController);
public void Dispose()
{
Runtime.Dispose();
@ -811,6 +929,25 @@ public sealed class CurrentGameRuntimeAdapterTests
}
}
private sealed class CharacterSelectionVisitor
: IRuntimeCharacterSelectionVisitor
{
public List<RuntimeCharacterSelectionEntry> Entries { get; } = [];
public void Visit(in RuntimeCharacterSelectionEntry character) =>
Entries.Add(character);
}
private sealed class CharacterSelectionObserver
: IRuntimeCharacterSelectionObserver
{
public List<RuntimeCharacterSelectionDelta> Deltas { get; } = [];
public void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta) =>
Deltas.Add(delta);
}
private static void PrepareCombatAndMagic(Harness harness)
{
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
@ -873,7 +1010,8 @@ public sealed class CurrentGameRuntimeAdapterTests
private static LiveSessionHost CreateHost(
LiveSessionController controller,
RecordingCommandRouting commands,
LocalPlayerIdentityState identity)
LocalPlayerIdentityState identity,
bool awaitCharacterSelection = false)
{
Action noop = static () => { };
var reset = new LiveSessionResetBindings
@ -925,7 +1063,8 @@ public sealed class CurrentGameRuntimeAdapterTests
"127.0.0.1",
9000,
"user",
"password"));
"password",
AwaitCharacterSelection: awaitCharacterSelection));
}
private static RuntimeOptions LiveOptions()
@ -1039,6 +1178,10 @@ public sealed class CurrentGameRuntimeAdapterTests
true,
true);
public void StartCharacterSelectionReceive(WorldSession session)
{
}
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
}

View file

@ -74,6 +74,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
/// </summary>
public TimeSpan AutoAdvanceOnBlockingReceive { get; set; }
/// <summary>
/// Keeps the default ACE script intact while allowing focused handshake
/// tests to enqueue messages before the valid ServerReady response.
/// </summary>
public bool AutoReplyServerReady { get; set; } = true;
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
{
Clock = clock ?? new VirtualClock();
@ -101,7 +107,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
// WorldSession.EnterWorld blocks on this opcode.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
if (AutoReplyServerReady)
{
Model.EnqueueGameMessage(
BuildOpcodeOnlyBody(0xF7DFu),
GameMessageGroup.UIQueue);
}
break;
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
@ -142,6 +153,22 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
}
}
/// <summary>
/// Enqueue and flush one model game message under the transport's model
/// lock. This is the race-free test seam for a server follower emitted
/// while the real WorldSession background receiver is active.
/// </summary>
public void EnqueueServerGameMessage(
byte[] body,
GameMessageGroup group)
{
lock (_gate)
{
Model.EnqueueGameMessage(body, group);
PumpServerLocked();
}
}
/// <summary>
/// N2 test hook: deliver raw bytes straight into the client's receive
/// queue, bypassing both the model and the link. Used for late

View file

@ -2,6 +2,7 @@ using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net.Tests.Transport;
@ -165,6 +166,105 @@ public sealed class FakeAceTransportTests
Assert.Equal(0, transport.Model.CrcDropCount);
}
[Fact]
public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep()
{
var fake = new FakeAceTransport();
// Random(~13) yields 4, 92, 55, 54: at 50%, only the first
// post-arm inbound datagram (ServerReady) is dropped; the follower,
// recovered resend, and graceful-logoff confirmation all land.
var lossy = new LossyTransportDecorator(
fake,
dropPercent: 50,
seed: 13,
NetDropDirection.In);
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
lossy)
{
TransportClockSource =
(fake.Clock.GetTimestamp, fake.Clock.Frequency),
};
using var enterRequest = new ManualResetEventSlim();
fake.Model.MessageDispatched += body =>
{
if (ReadOpcode(body)
== CharacterEnterWorld.EnterWorldRequestOpcode)
{
enterRequest.Set();
}
};
try
{
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.StartCharacterSelectionReceive();
// Graphical selector frames continue before the user enters.
for (int i = 0; i < 3; i++)
{
fake.Clock.Advance(TimeSpan.FromMilliseconds(50));
session.Tick();
}
Task gapDriver = Task.Run(() =>
{
Assert.True(enterRequest.Wait(TimeSpan.FromSeconds(2)));
// This later sequenced packet passes the seeded loss gate,
// exposing the missing ServerReady and parking behind it.
fake.EnqueueServerGameMessage(
BuildServerMessage("post-ready follower"),
GameMessageGroup.UIQueue);
Assert.True(SpinWait.SpinUntil(
() => session.Transport?.Inbound.NakCount > 0,
TimeSpan.FromSeconds(2)));
// No datagram follows this virtual-time edge. Recovery now
// requires paused EnterWorld's independent periodic sweep.
fake.Clock.Advance(TimeSpan.FromSeconds(1));
});
session.EnterWorld(0, TimeSpan.FromSeconds(5));
await gapDriver;
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(1, lossy.InboundDropped);
Assert.True(session.Transport!.Stats.NaksSent > 0);
Assert.True(fake.Model.RetransmitsServed > 0);
Assert.Equal(0, session.Transport.Inbound.NakCount);
Assert.Equal(0, session.Transport.Outbound.PendingResendCount);
Assert.Equal(
new[]
{
CharacterEnterWorld.EnterWorldRequestOpcode,
CharacterEnterWorld.EnterWorldOpcode,
},
fake.Model.DispatchedMessages.Select(ReadOpcode).ToArray());
Assert.Equal(256, fake.Model.Crypto.Headroom);
Assert.Equal(0, fake.Model.Crypto.OrphanCount);
Assert.Equal(0, fake.Model.CrcDropCount);
Assert.False(fake.Model.IsTerminated);
}
finally
{
session.Dispose();
}
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
Assert.True(fake.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.PacketHeaderDisconnect,
fake.Model.TerminationReason);
Assert.Equal(
CharacterLogOff.Opcode,
ReadOpcode(fake.Model.DispatchedMessages[^1]));
Assert.Equal(256, fake.Model.Crypto.Headroom);
Assert.Equal(0, fake.Model.Crypto.OrphanCount);
}
private static uint ReadOpcode(byte[] messageBody) =>
BinaryPrimitives.ReadUInt32LittleEndian(messageBody);

View file

@ -0,0 +1,245 @@
using System.Buffers.Binary;
using System.Net;
using System.Reflection;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Tests.Transport;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionCharacterSelectionTests
{
private sealed class NullTransport : 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) =>
ValueTask.FromCanceled<NetReceiveResult>(cancellationToken);
public void Dispose() { }
}
[Fact]
public void CharacterManagementSends_UseRetailQueuesAndExactBodies()
{
using var session = CreateSession();
var sent = new List<(byte[] Body, GameMessageGroup Queue)>();
session.GameMessageCapture =
(body, queue) => sent.Add((body, queue));
session.SendDeleteCharacter("Canonical", activeIndex: 3);
session.SendRestoreCharacter(0x50000001u);
Assert.Collection(
sent,
delete =>
{
Assert.Equal(GameMessageGroup.LoginQueue, delete.Queue);
Assert.Equal(
CharacterDelete.BuildRequestBody("Canonical", 3u),
delete.Body);
},
restore =>
{
Assert.Equal(GameMessageGroup.ControlQueue, restore.Queue);
Assert.Equal(
CharacterRestore.BuildRequestBody(0x50000001u),
restore.Body);
});
}
[Fact]
public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters()
{
using var session = CreateSession();
var events = new List<string>();
session.CharacterListReceived += roster =>
events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");
session.CharacterDeleteAcknowledged += () => events.Add("delete");
session.CharacterRestoreReceived += restore =>
events.Add($"restore:{restore.Guid:X8}");
session.CharacterErrorReceived += error =>
events.Add($"error:{error.RawErrorCode:X}");
byte[] packet = BuildPacket(
BuildRoster(secondsGreyedOut: 0u),
BitConverter.GetBytes(CharacterDelete.Opcode),
BuildRestoreResponse(),
BuildCharacterError(CharacterError.Code.Delete),
BuildRoster(secondsGreyedOut: 1u));
InvokeProcessDatagram(session, packet);
Assert.Equal(
[
"roster:0",
"delete",
"restore:50000001",
"error:6",
"roster:1",
],
events);
CharacterList.Character current =
Assert.Single(session.Characters!.Characters);
Assert.Equal(1u, current.SecondsGreyedOut);
}
[Fact]
public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady()
{
var transport = new FakeAceTransport
{
AutoReplyServerReady = false,
};
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
int errors = 0;
session.CharacterErrorReceived += _ => errors++;
ConfigureSentinelThenServerReady(transport);
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.EnterWorld(0, TimeSpan.FromSeconds(5));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(0, errors);
}
[Fact]
public void PausedEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady()
{
var transport = new FakeAceTransport
{
AutoReplyServerReady = false,
};
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
int errors = 0;
session.CharacterErrorReceived += _ => errors++;
ConfigureSentinelThenServerReady(transport);
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.StartCharacterSelectionReceive();
session.EnterWorld(0, TimeSpan.FromSeconds(5));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(0, errors);
}
private static WorldSession CreateSession() =>
new(
new IPEndPoint(IPAddress.Loopback, 9000),
new NullTransport());
private static void ConfigureSentinelThenServerReady(
FakeAceTransport transport)
{
transport.Model.MessageDispatched += body =>
{
if (BinaryPrimitives.ReadUInt32LittleEndian(body)
!= CharacterEnterWorld.EnterWorldRequestOpcode)
{
return;
}
transport.Model.EnqueueGameMessage(
BuildCharacterError(CharacterError.Code.NumErrors),
GameMessageGroup.UIQueue);
transport.Model.EnqueueGameMessage(
BitConverter.GetBytes(0xF7DFu),
GameMessageGroup.UIQueue);
};
}
private static byte[] BuildRoster(uint secondsGreyedOut)
{
var writer = new PacketWriter(96);
writer.WriteUInt32(CharacterList.Opcode);
writer.WriteUInt32(0u);
writer.WriteUInt32(1u);
writer.WriteUInt32(0x50000001u);
writer.WriteString16L("Character");
writer.WriteUInt32(secondsGreyedOut);
writer.WriteUInt32(0u);
writer.WriteUInt32(11u);
writer.WriteString16L("Canonical");
writer.WriteUInt32(1u);
writer.WriteUInt32(1u);
return writer.ToArray();
}
private static byte[] BuildRestoreResponse()
{
var writer = new PacketWriter(64);
writer.WriteUInt32(CharacterRestore.ResponseOpcode);
writer.WriteUInt32(1u);
writer.WriteUInt32(0x50000001u);
writer.WriteString16L("Character");
writer.WriteUInt32(0u);
return writer.ToArray();
}
private static byte[] BuildCharacterError(CharacterError.Code error)
{
byte[] body = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(
body,
CharacterError.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(4),
(uint)error);
return body;
}
private static byte[] BuildPacket(params byte[][] messages)
{
int length = messages.Sum(message =>
MessageFragmentHeader.Size + message.Length);
var fragments = new byte[length];
int position = 0;
uint sequence = 1u;
foreach (byte[] message in messages)
{
position += GameMessageFragment.WriteSingleFragment(
fragments.AsSpan(position),
sequence++,
GameMessageGroup.UIQueue,
message);
}
return PacketCodec.Encode(
new PacketHeader
{
Sequence = 1u,
Flags = PacketHeaderFlags.BlobFragments,
},
fragments,
outboundIsaac: null);
}
private static void InvokeProcessDatagram(
WorldSession session,
byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(
"ProcessDatagram",
BindingFlags.NonPublic | BindingFlags.Instance)!;
method.Invoke(
session,
[new ReadOnlyMemory<byte>(datagram), null, true]);
}
}

View file

@ -8,6 +8,18 @@ namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionControllerTests
{
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan elapsed) =>
_timestamp += elapsed.Ticks;
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
@ -57,10 +69,13 @@ public sealed class LiveSessionControllerTests
public bool ThrowOnCreate { get; set; }
public bool ThrowOnCharacters { get; set; }
public bool ThrowOnEnterWorld { get; set; }
public CharacterError.Parsed? EnterWorldRejection { get; set; }
public bool ThrowOnTick { get; set; }
public bool FailDisposeOnce { get; set; }
public List<WorldSession> Sessions { get; } = [];
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
public List<(string Account, int ActiveIndex)> DeleteRequests { get; } = [];
public List<uint> RestoreRequests { get; } = [];
public int EnterWorldCount { get; private set; }
public int TickCount { get; private set; }
@ -98,15 +113,35 @@ public sealed class LiveSessionControllerTests
return Characters;
}
public void StartCharacterSelectionReceive(WorldSession session) =>
calls.Add("start-character-selection-receive");
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
calls.Add($"enter:{activeCharacterIndex}");
EnterWorldCount++;
OnEnterWorld?.Invoke();
if (EnterWorldRejection is { } rejection)
throw new CharacterSelectionRejectedException(rejection);
if (ThrowOnEnterWorld)
throw new InvalidOperationException("enter failure");
}
public void DeleteCharacter(
WorldSession session,
string accountName,
int activeCharacterIndex)
{
calls.Add($"delete:{activeCharacterIndex}");
DeleteRequests.Add((accountName, activeCharacterIndex));
}
public void RestoreCharacter(WorldSession session, uint characterId)
{
calls.Add($"restore:{characterId:X8}");
RestoreRequests.Add(characterId);
}
public void Tick(WorldSession session)
{
calls.Add("tick");
@ -340,6 +375,314 @@ public sealed class LiveSessionControllerTests
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
}
[Fact]
public void Start_GraphicalNoSelectorStopsBeforeEnterWorldThenTypedEnterContinuesSameScope()
{
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(awaitSelection: true),
host);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
result.Status);
Assert.False(controller.IsInWorld);
Assert.NotNull(controller.CurrentSession);
Assert.Equal(0, operations.EnterWorldCount);
Assert.False(host.CommandBuses[0].Active);
RuntimeGenerationToken generation = controller.Generation;
Assert.Equal(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
controller.CharacterSelection.Snapshot.Lifecycle);
// Greyed entries remain highlightable but cannot enter.
Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.Enter(generation).Status);
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.True(controller.Enter(generation).Accepted);
Assert.True(controller.IsInWorld);
Assert.Equal(1, operations.EnterWorldCount);
Assert.Contains("enter:1", calls);
Assert.True(host.CommandBuses[0].Active);
Assert.Same(operations.Sessions[0], controller.CurrentSession);
}
[Fact]
public void Start_ExplicitSelectorStillEntersWhenGraphicalPauseFlagIsSet()
{
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: 0x50000002u),
awaitSelection: true),
host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.True(controller.IsInWorld);
Assert.Equal(1, operations.EnterWorldCount);
}
[Fact]
public void EnterRejection_SurfacesErrorAndKeepsExactPreWorldScopeRetryable()
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
EnterWorldRejection = new CharacterError.Parsed(
(uint)CharacterError.Code.EnterGameCharacterLocked),
};
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
controller.Start(LiveOptions(awaitSelection: true), host).Status);
RuntimeGenerationToken generation = controller.Generation;
WorldSession session = controller.CurrentSession!;
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.Enter(generation).Status);
Assert.Same(session, controller.CurrentSession);
Assert.Equal(generation, controller.Generation);
Assert.False(controller.IsInWorld);
RuntimeCharacterSelectionSnapshot rejected =
controller.CharacterSelection.Snapshot;
Assert.Equal(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
rejected.Lifecycle);
Assert.Equal(
CharacterError.Code.EnterGameCharacterLocked,
rejected.Error!.Value.Code);
operations.EnterWorldRejection = null;
Assert.True(controller.Enter(generation).Accepted);
Assert.True(controller.IsInWorld);
Assert.Same(session, controller.CurrentSession);
}
[Fact]
public async Task CharacterCommands_UseWireSlotAndRestoreNeverWaitsForReply()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var time = new ManualTimeProvider();
var controller = new LiveSessionController(operations, time);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
controller.Start(LiveOptions(awaitSelection: true), host).Status);
RuntimeGenerationToken generation = controller.Generation;
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.True(controller.RequestDelete(generation).Accepted);
Assert.Equal(0x50000002u,
controller.CharacterSelection.Snapshot.PendingDeleteCharacterId);
Assert.True(controller.Cancel(generation).Accepted);
Assert.True(controller.RequestDelete(generation).Accepted);
Assert.True(controller.ConfirmDelete(generation).Accepted);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
Assert.False(controller.CharacterSelection.Snapshot.Buttons.CanEnter);
controller.CharacterSelectionState.ApplyDeleteAcknowledged();
controller.CharacterSelectionState.ApplyRoster(new LiveSessionRosterReport(
"Canonical",
11,
[
new(0x50000001u, "Grey", 10u),
new(0x50000002u, "Ready", 1u),
]));
Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
RuntimeCommandResult restore = await Task.Run(
() => controller.Restore(generation))
.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(restore.Accepted);
Assert.Equal([0x50000001u], operations.RestoreRequests);
// ACE may send no response for an unknown guid. The synchronous
// command has already returned and does not gate later commands.
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.Restore(generation).Status);
controller.Tick();
Assert.Equal(1, operations.TickCount);
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
controller.Tick();
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
controller.CharacterSelection.Snapshot.Operation);
Assert.True(controller.CharacterSelection.Snapshot.Buttons.CanRestore);
Assert.True(controller.Restore(generation).Accepted);
Assert.Equal(
[0x50000001u, 0x50000002u],
operations.RestoreRequests);
Assert.False(controller.IsInWorld);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck(
bool acknowledgeBeforeCompletion)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
controller.Start(LiveOptions(awaitSelection: true), host).Status);
RuntimeGenerationToken generation = controller.Generation;
Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
Assert.True(controller.Restore(generation).Accepted);
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.True(controller.RequestDelete(generation).Accepted);
Assert.True(controller.ConfirmDelete(generation).Accepted);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
controller.CharacterSelection.Snapshot.Buttons);
if (acknowledgeBeforeCompletion)
controller.CharacterSelectionState.ApplyDeleteAcknowledged();
controller.CharacterSelectionState.ApplyRestore(
new CharacterRestore.Parsed(
VerificationFlag: 1u,
Guid: 0x50000001u,
Name: "Restored",
SecondsGreyedOut: 0u));
Assert.Equal(
acknowledgeBeforeCompletion
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
controller.CharacterSelection.Snapshot.Buttons);
Assert.True(controller.CharacterSelection.TryGet(
0x50000001u,
out RuntimeCharacterSelectionEntry restored));
Assert.False(restored.IsPendingDelete);
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.ConfirmDelete(generation).Status);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
if (!acknowledgeBeforeCompletion)
controller.CharacterSelectionState.ApplyDeleteAcknowledged();
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck(
bool acknowledgeBeforeTimeout)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var time = new ManualTimeProvider();
var controller = new LiveSessionController(operations, time);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
controller.Start(LiveOptions(awaitSelection: true), host).Status);
RuntimeGenerationToken generation = controller.Generation;
Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
Assert.True(controller.Restore(generation).Accepted);
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.True(controller.RequestDelete(generation).Accepted);
Assert.True(controller.ConfirmDelete(generation).Accepted);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
if (acknowledgeBeforeTimeout)
controller.CharacterSelectionState.ApplyDeleteAcknowledged();
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
controller.Tick();
Assert.Equal(
acknowledgeBeforeTimeout
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.ConfirmDelete(generation).Status);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
if (!acknowledgeBeforeTimeout)
controller.CharacterSelectionState.ApplyDeleteAcknowledged();
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}
[Fact]
public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(awaitSelection: true), host);
RuntimeGenerationToken first = controller.Generation;
LiveSessionStartResult reconnect = controller.Reconnect(
LiveOptions(awaitSelection: true),
host);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
reconnect.Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.Highlight(first, 0x50000002u).Status);
RuntimeGenerationToken second = controller.Generation;
Assert.True(controller.Highlight(second, 0x50000002u).Accepted);
controller.Stop();
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.Enter(second).Status);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.Inactive,
controller.CharacterSelection.Snapshot.Lifecycle);
Assert.Equal(0, controller.CharacterSelection.Snapshot.RosterCount);
}
[Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{
@ -1232,7 +1575,8 @@ public sealed class LiveSessionControllerTests
bool live = true,
string? user = "user",
LiveSessionCharacterSelector? selector = null,
bool probe = false) =>
bool probe = false,
bool awaitSelection = false) =>
new(
live,
"127.0.0.1",
@ -1240,7 +1584,8 @@ public sealed class LiveSessionControllerTests
user ?? string.Empty,
"password",
selector,
probe);
probe,
awaitSelection);
private static CharacterList.Parsed AvailableCharacters() => new(
0u,

View file

@ -0,0 +1,297 @@
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class RuntimeCharacterSelectionStateTests
{
private sealed class Collector : IRuntimeCharacterSelectionVisitor
{
public List<RuntimeCharacterSelectionEntry> Entries { get; } = [];
public void Visit(in RuntimeCharacterSelectionEntry character) =>
Entries.Add(character);
}
private sealed class Observer(
Action<RuntimeCharacterSelectionDelta> onDelta)
: IRuntimeCharacterSelectionObserver
{
public void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta) =>
onDelta(delta);
}
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan elapsed) =>
_timestamp += elapsed.Ticks;
}
[Fact]
public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(7));
state.ApplyRoster(Roster(
new(0x50000001u, "Zulu", 1u),
new(0x50000002u, "Bravo", 0u),
new(0x50000003u, "Alpha", 0u),
new(0x50000004u, "Able", 9u)));
RuntimeCharacterSelectionSnapshot snapshot = state.Snapshot;
Assert.Equal(RuntimeCharacterSelectionLifecycle.AwaitingSelection, snapshot.Lifecycle);
// Retail falls back in CharacterSet wire order, independently of the
// alphabetized display order.
Assert.Equal(0x50000002u, snapshot.HighlightedCharacterId);
Assert.True(snapshot.Buttons.CanEnter);
Assert.True(snapshot.Buttons.CanDelete);
Assert.False(snapshot.Buttons.CanRestore);
Assert.True(snapshot.Buttons.DeleteVisible);
Assert.False(snapshot.Buttons.RestoreVisible);
var collector = new Collector();
state.View.Visit(collector);
Assert.Equal(
[
(0x50000003u, 2, false),
(0x50000002u, 1, false),
(0x50000004u, 3, true),
(0x50000001u, 0, true),
],
collector.Entries.Select(entry =>
(entry.CharacterId, entry.ActiveIndex, entry.IsPendingDelete)));
Assert.True(state.TryHighlight(0x50000004u));
snapshot = state.Snapshot;
Assert.False(snapshot.Buttons.CanEnter);
Assert.False(snapshot.Buttons.CanDelete);
Assert.True(snapshot.Buttons.CanRestore);
Assert.False(snapshot.Buttons.DeleteVisible);
Assert.True(snapshot.Buttons.RestoreVisible);
}
[Fact]
public void ApplyRoster_PreservesHighlightedGuidAcrossDeleteRefresh()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(3));
state.ApplyRoster(Roster(
new(0x50000001u, "One", 0u),
new(0x50000002u, "Two", 0u)));
Assert.True(state.TryHighlight(0x50000002u));
Assert.True(state.TryRequestDelete(out uint requested));
Assert.Equal(0x50000002u, requested);
Assert.True(state.TryTakeDeleteConfirmation(
out RuntimeCharacterSelectionEntry deleted,
out string account));
Assert.Equal(1, deleted.ActiveIndex);
Assert.Equal("Canonical", account);
state.ApplyDeleteAcknowledged();
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
state.Snapshot.Operation);
state.ApplyRoster(Roster(
new(0x50000001u, "One", 0u),
new(0x50000002u, "Two", 1u)));
RuntimeCharacterSelectionSnapshot snapshot = state.Snapshot;
Assert.Equal(0x50000002u, snapshot.HighlightedCharacterId);
Assert.Equal(RuntimeCharacterSelectionOperation.None, snapshot.Operation);
Assert.True(snapshot.Buttons.CanRestore);
Assert.False(snapshot.Buttons.CanEnter);
}
[Fact]
public void DeleteConfirmation_CancelIsIdempotentlyGenerationLocal()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(4));
state.ApplyRoster(Roster(
new LiveSessionRosterEntry(0x50000001u, "One", 0u)));
Assert.True(state.TryRequestDelete(out _));
Assert.Equal(0x50000001u, state.Snapshot.PendingDeleteCharacterId);
Assert.False(state.TryHighlight(0x50000001u));
Assert.False(state.BeginEnter(out _));
Assert.True(state.Cancel());
Assert.Equal(0u, state.Snapshot.PendingDeleteCharacterId);
Assert.False(state.Cancel());
state.Reset(new RuntimeGenerationToken(5));
Assert.Equal(RuntimeCharacterSelectionLifecycle.Inactive, state.Snapshot.Lifecycle);
Assert.Equal(0, state.Snapshot.RosterCount);
Assert.Equal(new RuntimeGenerationToken(5), state.Snapshot.Generation);
}
[Fact]
public void Restore_IsFireAndObserveAndSuccessUpdatesTheCanonicalEntry()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(11));
state.ApplyRoster(Roster(
new LiveSessionRosterEntry(0x50000001u, "Grey", 1u)));
Assert.True(state.TryBeginRestore(out RuntimeCharacterSelectionEntry request));
Assert.Equal(0x50000001u, request.CharacterId);
Assert.Equal(
RuntimeCharacterSelectionOperation.RestoreRequested,
state.Snapshot.Operation);
// No response is required to keep using the state. ACE's unknown-guid
// branch intentionally sends no reply.
Assert.True(state.TryHighlight(0x50000001u));
state.ApplyRestore(new CharacterRestore.Parsed(
VerificationFlag: 1u,
Guid: 0x50000001u,
Name: "Restored",
SecondsGreyedOut: 0u));
Assert.True(state.View.TryGet(0x50000001u, out var restored));
Assert.Equal("Restored", restored.Name);
Assert.False(restored.IsPendingDelete);
Assert.True(state.Snapshot.Buttons.CanEnter);
Assert.Equal(
RuntimeCharacterSelectionOperation.RestoreSucceeded,
state.Snapshot.Operation);
}
[Fact]
public void DelayedFlagOnlyRestoreResponse_CannotCompleteNewerRequest()
{
var time = new ManualTimeProvider();
using var state = new RuntimeCharacterSelectionState(time);
state.Begin(new RuntimeGenerationToken(12));
state.ApplyRoster(Roster(
new(0x50000001u, "First", 1u),
new(0x50000002u, "Second", 1u)));
Assert.True(state.TryBeginRestore(out _));
Assert.True(state.TryHighlight(0x50000002u));
Assert.False(state.TryBeginRestore(out _));
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
Assert.True(state.SweepRestoreCorrelation());
Assert.True(state.TryBeginRestore(out _));
long revision = state.Snapshot.Revision;
state.ApplyRestore(new CharacterRestore.Parsed(
VerificationFlag: 2u,
Guid: null,
Name: null,
SecondsGreyedOut: null));
Assert.Equal(
RuntimeCharacterSelectionOperation.RestoreRequested,
state.Snapshot.Operation);
Assert.Equal(0x50000002u, state.Snapshot.LastRestoreRequestedCharacterId);
Assert.Equal(revision, state.Snapshot.Revision);
Assert.True(state.View.TryGet(0x50000001u, out var first));
Assert.True(first.IsPendingDelete);
Assert.True(state.View.TryGet(0x50000002u, out var second));
Assert.True(second.IsPendingDelete);
Assert.False(state.Snapshot.Buttons.CanRestore);
}
[Fact]
public void NoReplyRestoreTimeout_ReleasesOnlyRestoreGate()
{
var time = new ManualTimeProvider();
using var state = new RuntimeCharacterSelectionState(time);
state.Begin(new RuntimeGenerationToken(13));
state.ApplyRoster(Roster(
new(0x50000001u, "Pending", 1u),
new(0x50000002u, "Ready", 0u)));
Assert.True(state.TryHighlight(0x50000001u));
Assert.True(state.TryBeginRestore(out _));
Assert.False(state.Snapshot.Buttons.CanRestore);
Assert.True(state.TryHighlight(0x50000002u));
Assert.True(state.BeginEnter(out var ready));
Assert.Equal(0x50000002u, ready.CharacterId);
state.ReturnToSelection();
Assert.True(state.TryHighlight(0x50000001u));
Assert.True(state.TryBeginRestore(out _));
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
Assert.True(state.SweepRestoreCorrelation());
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
state.Snapshot.Operation);
Assert.True(state.Snapshot.Buttons.CanRestore);
}
[Fact]
public void CharacterErrors_MapKnownAndUnknownButNeverPublishNumErrorsSentinel()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(2));
state.ApplyRoster(Roster(
new LiveSessionRosterEntry(0x50000001u, "One", 0u)));
var deltas = new List<RuntimeCharacterSelectionDelta>();
using IDisposable subscription = state.View.Subscribe(
new Observer(deltas.Add));
state.ApplyError(new CharacterError.Parsed(
(uint)CharacterError.Code.Delete));
RuntimeCharacterSelectionError known =
Assert.IsType<RuntimeCharacterSelectionError>(state.Snapshot.Error);
Assert.Equal(CharacterError.Code.Delete, known.Code);
Assert.Contains("delete", known.Message, StringComparison.OrdinalIgnoreCase);
Assert.True(state.Cancel());
state.ApplyError(new CharacterError.Parsed(0xDEADBEEFu));
RuntimeCharacterSelectionError unknown =
Assert.IsType<RuntimeCharacterSelectionError>(state.Snapshot.Error);
Assert.Contains("0xDEADBEEF", unknown.Message, StringComparison.Ordinal);
long revision = state.Snapshot.Revision;
int count = deltas.Count;
state.ApplyError(new CharacterError.Parsed(
(uint)CharacterError.Code.NumErrors));
Assert.Equal(revision, state.Snapshot.Revision);
Assert.Equal(count, deltas.Count);
Assert.Equal(0xDEADBEEFu, state.Snapshot.Error!.Value.RawCode);
}
[Fact]
public void Deltas_AreMonotonicAndReentrantMutationsStayOrdered()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(9));
var deltas = new List<RuntimeCharacterSelectionDelta>();
var observer = new Observer(delta =>
{
deltas.Add(delta);
if (delta.Kind == RuntimeCharacterSelectionDeltaKind.RosterChanged)
Assert.True(state.TryHighlight(0x50000002u));
});
using IDisposable subscription = state.View.Subscribe(observer);
state.ApplyRoster(Roster(
new(0x50000001u, "Ready", 0u),
new(0x50000002u, "Grey", 1u)));
Assert.Equal(
[
RuntimeCharacterSelectionDeltaKind.RosterChanged,
RuntimeCharacterSelectionDeltaKind.HighlightChanged,
],
deltas.Select(delta => delta.Kind));
Assert.True(deltas[0].Sequence < deltas[1].Sequence);
Assert.All(
deltas,
delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation));
}
private static LiveSessionRosterReport Roster(
params LiveSessionRosterEntry[] entries) =>
new("Canonical", 11, entries);
}