From 0e82cbf70053c911ca178ce38ea3553fd39eaed9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:22:17 +0200 Subject: [PATCH] feat(runtime): own character selection flow --- docs/architecture/acdream-architecture.md | 3 + docs/architecture/code-structure.md | 2 +- ...-08-14-la7b-character-selection-runtime.md | 109 ++ .../Composition/SessionPlayerComposition.cs | 4 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 6 + src/AcDream.Core.Net/WorldSession.cs | 195 +++- src/AcDream.Runtime/GameRuntime.cs | 2 + src/AcDream.Runtime/GameRuntimeCommands.cs | 5 + src/AcDream.Runtime/GameRuntimeViews.cs | 5 + .../DirectGameRuntimeCommandAdapter.cs | 2 + .../Session/LiveSessionContracts.cs | 9 +- .../Session/LiveSessionController.cs | 388 ++++++- .../Session/LiveSessionHost.cs | 2 + .../Session/RuntimeCharacterSelectionState.cs | 991 ++++++++++++++++++ .../SessionPlayerCompositionTests.cs | 24 + .../RuntimeOptionsSessionConfigTests.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 56 +- .../WorldSessionCharacterSelectionTests.cs | 175 ++++ .../Session/LiveSessionControllerTests.cs | 202 +++- .../RuntimeCharacterSelectionStateTests.cs | 247 +++++ 20 files changed, 2396 insertions(+), 33 deletions(-) create mode 100644 docs/research/2026-08-14-la7b-character-selection-runtime.md create mode 100644 src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4696577b..a5b4530d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index a56b00b8..e4dedd97 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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`). | diff --git a/docs/research/2026-08-14-la7b-character-selection-runtime.md b/docs/research/2026-08-14-la7b-character-selection-runtime.md new file mode 100644 index 00000000..22a72cb8 --- /dev/null +++ b/docs/research/2026-08-14-la7b-character-selection-runtime.md @@ -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. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index cacc23d5..a2812b93 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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 diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index e11df45d..56e89e2a 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -89,6 +89,8 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeCharacterView Character => _runtime.Character; public IRuntimeSocialView Social => _runtime.Social; public IRuntimeChatView Chat => _runtime.Chat; + public IRuntimeCharacterSelectionView CharacterSelection => + _runtime.CharacterSelection; public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; @@ -97,6 +99,10 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimePortalView Portal => _runtime.Portal; public IRuntimeSessionCommands Session => _commands; + public IRuntimeCharacterSelectionCommands CharacterSelectionCommands => + _runtime.Session; + IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => + _runtime.Session; public IRuntimeSelectionCommands Selection => _commands; public IRuntimeCombatCommands Combat => _commands; public IRuntimeMagicCommands Magic => _commands; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6833825b..cbe68e1c 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport; namespace AcDream.Core.Net; +/// +/// EnterWorld was rejected by the server while the transport remains a valid +/// character-select session. Callers may surface and let +/// the user choose another character instead of tearing down the connection. +/// +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 datagram); @@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable /// Raised every time the state machine transitions. public event Action? StateChanged; + /// + /// 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. + /// + public event Action? CharacterListReceived; + public event Action? CharacterDeleteAcknowledged; + public event Action? CharacterRestoreReceived; + public event Action? CharacterErrorReceived; + /// /// 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"); } + + } + + /// + /// Starts the sole asynchronous receive loop while the session remains at + /// character selection. Graphical hosts call this only when they actually + /// pause before ; immediate and headless entry keep + /// the original blocking handshake pump until ServerReady is accepted. + /// + public void StartCharacterSelectionReceive() + { + if (CurrentState != State.InCharacterSelect) + throw new InvalidOperationException( + "character-selection receive requires InCharacterSelect state"); + + EnsureNetReceiveLoopStarted(); } /// @@ -1045,14 +1086,56 @@ 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 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(); + ProcessDatagram(datagram.Memory, opcodes); + SweepTransport(); + return opcodes.Contains(0xF7DFu) + || _lastCharacterSelectionError is not null; + }, + ReturnInboundDatagram); + } + 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 +1150,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 +1223,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 +1772,53 @@ 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; + } + _lastCharacterSelectionError = parsed; + CharacterErrorReceived?.Invoke(parsed); } else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?" { @@ -2040,6 +2168,29 @@ public sealed class WorldSession : IDisposable SendGameMessage(gameActionBody); } + /// + /// Send retail CharacterDelete through the login/logon queue. The caller + /// supplies the selected entry's active CharacterSet slot, not its guid. + /// + 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); + } + + /// + /// Send retail CharacterRestore through the control queue. This is + /// deliberately non-blocking because ACE silently drops unknown guids. + /// + public void SendRestoreCharacter(uint characterId) => + SendControlMessage(CharacterRestore.BuildRequestBody(characterId)); + /// /// Phase I.3: test-only hook. When non-null, /// invokes this instead of writing to the wire. Lets unit tests verify @@ -2049,6 +2200,9 @@ public sealed class WorldSession : IDisposable /// internal Action? GameActionCapture { get; set; } + /// LA7b unit-test seam for queue-sensitive pre-world sends. + internal Action? GameMessageCapture { get; set; } + /// /// Phase B.2: get and increment the game-action sequence counter. /// Call once per outbound movement message; pass the returned value @@ -2895,6 +3049,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 diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 90ae8e84..5b6e7e60 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -527,6 +527,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; diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index db177ed1..017b24c4 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus /// exit-code mapping, not a failure. /// 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; } diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index ce17bcc9..ca973e34 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -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; } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index bc07ca53..0c6eb5ce 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -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; diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs index e8f81d4b..d38f1e2a 100644 --- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs +++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs @@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions( /// itself does not enforce that pairing — the headless config loader /// does, before a is ever built. /// - bool Probe = false); + bool Probe = false, + /// + /// 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. + /// + bool AwaitCharacterSelection = false); public interface IRuntimeLiveSessionFramePhase { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 935de09d..b7c37555 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -14,6 +14,11 @@ public enum LiveSessionStartStatus Deferred, Failed, /// + /// The account transport is connected and Runtime owns a live roster, + /// but EnterWorld has deliberately not run yet. + /// + AwaitingCharacterSelection, + /// /// Campaign LA slice LA2: a /// 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 /// public sealed class LiveSessionController : IDisposable, - IRuntimeLiveSessionFramePhase + IRuntimeLiveSessionFramePhase, + IRuntimeCharacterSelectionCommands { + private sealed class CharacterSelectionWireBinding : IDisposable + { + private WorldSession? _session; + private readonly Action _roster; + private readonly Action _delete; + private readonly Action _restore; + private readonly Action _error; + + public CharacterSelectionWireBinding( + WorldSession session, + Action roster, + Action delete, + Action restore, + Action 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; } @@ -315,8 +383,14 @@ public sealed class LiveSessionController public LiveSessionController(ILiveSessionOperations operations) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); + CharacterSelectionState = new RuntimeCharacterSelectionState(); } + public RuntimeCharacterSelectionState CharacterSelectionState { get; } + + public IRuntimeCharacterSelectionView CharacterSelection => + CharacterSelectionState.View; + public WorldSession? CurrentSession { get { lock (_gate) return _scope?.Session; } @@ -405,6 +479,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 +571,7 @@ public sealed class LiveSessionController { if (_disposed) return; - if (!_inWorld || _scope is null || _operationDepth != 0) + if (_scope is null || _operationDepth != 0) return; RunTopLevel(() => @@ -517,7 +596,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 +663,8 @@ public sealed class LiveSessionController { RuntimeGenerationToken resetGeneration = new(_generation); ulong generation = ++_generation; + RuntimeGenerationToken activeGeneration = new(generation); + CharacterSelectionState.Reset(activeGeneration); try { DrainRetiredScope(); @@ -608,6 +690,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 +727,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 +737,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 +759,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 +788,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 +809,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 +843,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 +1125,7 @@ public sealed class LiveSessionController ++_generation; _inWorld = false; _activeSelection = null; + CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation)); if (_scope is { } scope) { _scope = null; @@ -889,6 +1266,7 @@ public sealed class LiveSessionController private void DisposeCore() { StopCore(); + CharacterSelectionState.Dispose(); _disposed = true; } diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 5ceb04e2..62cf5441 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -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, diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs new file mode 100644 index 00000000..db55e783 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -0,0 +1,991 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Session; + +public enum RuntimeCharacterSelectionLifecycle +{ + Inactive, + Connecting, + AwaitingSelection, + EnteringWorld, + InWorld, +} + +public enum RuntimeCharacterSelectionOperation +{ + None, + DeleteRequested, + DeleteAcknowledged, + RestoreRequested, + RestoreSucceeded, + RestoreRejected, +} + +public enum RuntimeCharacterSelectionDeltaKind +{ + Reset, + RosterChanged, + HighlightChanged, + DeleteConfirmationOpened, + DeleteConfirmationCancelled, + DeleteRequested, + DeleteAcknowledged, + RestoreRequested, + RestoreCompleted, + ErrorChanged, + EnteringWorld, + EnteredWorld, +} + +public readonly record struct RuntimeCharacterSelectionEntry( + int ActiveIndex, + uint CharacterId, + string Name, + uint SecondsGreyedOut) +{ + /// + /// ACE currently sends a constant one throughout the delete grace window. + /// The retail field is therefore exposed, but its only supported semantic + /// is the non-zero pending-delete predicate. + /// + public bool IsPendingDelete => SecondsGreyedOut != 0u; + + public bool CanEnter => CharacterId != 0u && !IsPendingDelete; +} + +public readonly record struct RuntimeCharacterSelectionButtons( + bool CanEnter, + bool CanDelete, + bool CanRestore, + bool DeleteVisible, + bool RestoreVisible) +{ + public static RuntimeCharacterSelectionButtons None { get; } = + new(false, false, false, true, false); +} + +public readonly record struct RuntimeCharacterSelectionError( + uint RawCode, + CharacterError.Code Code, + string Message); + +public readonly record struct RuntimeCharacterSelectionSnapshot( + RuntimeGenerationToken Generation, + RuntimeCharacterSelectionLifecycle Lifecycle, + long Revision, + string AccountName, + int SlotCount, + int RosterCount, + uint HighlightedCharacterId, + int HighlightedDisplayIndex, + uint PendingDeleteCharacterId, + uint LastRestoreRequestedCharacterId, + RuntimeCharacterSelectionOperation Operation, + RuntimeCharacterSelectionError? Error, + RuntimeCharacterSelectionButtons Buttons) +{ + public bool IsActive => + Lifecycle is RuntimeCharacterSelectionLifecycle.AwaitingSelection + or RuntimeCharacterSelectionLifecycle.EnteringWorld; +} + +public readonly record struct RuntimeCharacterSelectionDelta( + RuntimeGenerationToken Generation, + ulong Sequence, + long Revision, + RuntimeCharacterSelectionDeltaKind Kind, + uint CharacterId = 0u, + uint ErrorCode = 0u); + +public interface IRuntimeCharacterSelectionVisitor +{ + void Visit(in RuntimeCharacterSelectionEntry character); +} + +public interface IRuntimeCharacterSelectionObserver +{ + void OnCharacterSelectionChanged( + in RuntimeCharacterSelectionDelta delta); +} + +public interface IRuntimeCharacterSelectionEventSource +{ + IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer); +} + +/// +/// Borrowed view of the one Runtime-owned pre-world selection graph. Roster +/// entries are in retail display order (ordinal name order, then every +/// greyed/pending-delete entry moved to the tail); retains the original wire +/// slot used by character delete and EnterWorld. +/// +public interface IRuntimeCharacterSelectionView + : IRuntimeCharacterSelectionEventSource +{ + RuntimeCharacterSelectionSnapshot Snapshot { get; } + + bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character); + + bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character); + + void Visit(IRuntimeCharacterSelectionVisitor visitor); +} + +public interface IRuntimeCharacterSelectionCommands +{ + RuntimeCommandResult Highlight( + RuntimeGenerationToken expectedGeneration, + uint characterId); + + RuntimeCommandResult Enter( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult RequestDelete( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult ConfirmDelete( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult Restore( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult Cancel( + RuntimeGenerationToken expectedGeneration); +} + +/// +/// Sole mutable owner of character-selection state. It contains no App or UI +/// types; presentation borrows and issues generation-gated +/// commands through . +/// +public sealed class RuntimeCharacterSelectionState : IDisposable +{ + private sealed class ViewProjection(RuntimeCharacterSelectionState owner) + : IRuntimeCharacterSelectionView + { + public RuntimeCharacterSelectionSnapshot Snapshot => owner.Snapshot; + + public bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) => + owner.TryGetAt(displayIndex, out character); + + public bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) => + owner.TryGet(characterId, out character); + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) => + owner.Visit(visitor); + + public IDisposable Subscribe( + IRuntimeCharacterSelectionObserver observer) => + owner._events.Subscribe(observer); + } + + private readonly object _gate = new(); + private readonly RuntimeCharacterSelectionEventStream _events = new(); + private readonly ViewProjection _view; + private RuntimeCharacterSelectionEntry[] _entries = []; + private RuntimeGenerationToken _generation; + private RuntimeCharacterSelectionLifecycle _lifecycle; + private long _revision; + private string _accountName = string.Empty; + private int _slotCount; + private uint _highlightedCharacterId; + private uint _pendingDeleteCharacterId; + private uint _lastRestoreRequestedCharacterId; + private bool _restoreResponseArmed; + private RuntimeCharacterSelectionOperation _operation; + private RuntimeCharacterSelectionError? _error; + private bool _disposed; + + public RuntimeCharacterSelectionState() + { + _view = new ViewProjection(this); + } + + public IRuntimeCharacterSelectionView View => _view; + + public RuntimeCharacterSelectionSnapshot Snapshot + { + get + { + lock (_gate) + { + int selectedIndex = FindDisplayIndex(_highlightedCharacterId); + RuntimeCharacterSelectionButtons buttons = + BuildButtons(selectedIndex); + return new RuntimeCharacterSelectionSnapshot( + _generation, + _lifecycle, + _revision, + _accountName, + _slotCount, + _entries.Length, + _highlightedCharacterId, + selectedIndex, + _pendingDeleteCharacterId, + _lastRestoreRequestedCharacterId, + _operation, + _error, + buttons); + } + } + } + + internal void Begin(RuntimeGenerationToken generation) + { + lock (_gate) + { + ThrowIfDisposed(); + _generation = generation; + _lifecycle = RuntimeCharacterSelectionLifecycle.Connecting; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.Reset); + } + + /// + /// Ports the stateful parts of retail + /// gmCharacterManagementUI::RebuildCharacterList @ 0x004ec3a0: retain the + /// prior selected guid when it still exists; otherwise choose the first + /// non-greyed active entry in CharacterSet wire order (or the first entry + /// when every entry is greyed, which keeps Restore reachable); sort + /// displayed names with wcscmp semantics and then move greyed entries to + /// the tail. + /// + internal void ApplyRoster(LiveSessionRosterReport roster) + { + ArgumentNullException.ThrowIfNull(roster); + uint selected; + lock (_gate) + { + ThrowIfDisposed(); + uint previous = _highlightedCharacterId; + var wireEntries = new RuntimeCharacterSelectionEntry[ + roster.Entries.Count]; + uint fallback = 0u; + bool foundAvailableFallback = false; + for (int i = 0; i < wireEntries.Length; i++) + { + LiveSessionRosterEntry source = roster.Entries[i]; + wireEntries[i] = new RuntimeCharacterSelectionEntry( + i, + source.Id, + source.Name, + source.SecondsGreyedOut); + if (fallback == 0u && source.Id != 0u) + fallback = source.Id; + if (!foundAvailableFallback + && source.Id != 0u + && source.SecondsGreyedOut == 0u) + { + fallback = source.Id; + foundAvailableFallback = true; + } + } + + Array.Sort( + wireEntries, + static (left, right) => + string.CompareOrdinal(left.Name, right.Name)); + _entries = StablePartitionGreyedToTail(wireEntries); + _accountName = roster.AccountName; + _slotCount = roster.SlotCount; + _lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection; + _pendingDeleteCharacterId = 0u; + _lastRestoreRequestedCharacterId = 0u; + _restoreResponseArmed = false; + _operation = RuntimeCharacterSelectionOperation.None; + _error = null; + _highlightedCharacterId = Contains(previous) + ? previous + : fallback; + selected = _highlightedCharacterId; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.RosterChanged, + selected); + } + + internal bool TryHighlight(uint characterId) + { + lock (_gate) + { + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || HasDeleteModalOrRequest() + || !Contains(characterId)) + { + return false; + } + if (_highlightedCharacterId == characterId) + return true; + + _highlightedCharacterId = characterId; + _pendingDeleteCharacterId = 0u; + _error = null; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.HighlightChanged, + characterId); + return true; + } + + internal bool TryRequestDelete(out uint characterId) + { + lock (_gate) + { + int index = FindDisplayIndex(_highlightedCharacterId); + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || HasDeleteModalOrRequest() + || index < 0 + || !_entries[index].CanEnter) + { + characterId = 0u; + return false; + } + characterId = _entries[index].CharacterId; + _pendingDeleteCharacterId = characterId; + _error = null; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.DeleteConfirmationOpened, + characterId); + return true; + } + + internal bool TryTakeDeleteConfirmation( + out RuntimeCharacterSelectionEntry character, + out string accountName) + { + lock (_gate) + { + int index = FindDisplayIndex(_pendingDeleteCharacterId); + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || _pendingDeleteCharacterId == 0u + || _operation is RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged + || index < 0 + || !_entries[index].CanEnter) + { + character = default; + accountName = string.Empty; + return false; + } + + character = _entries[index]; + accountName = _accountName; + _pendingDeleteCharacterId = 0u; + _operation = RuntimeCharacterSelectionOperation.DeleteRequested; + _error = null; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.DeleteRequested, + character.CharacterId); + return true; + } + + internal bool TryBeginRestore( + out RuntimeCharacterSelectionEntry character) + { + lock (_gate) + { + int index = FindDisplayIndex(_highlightedCharacterId); + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || HasDeleteModalOrRequest() + || index < 0 + || !_entries[index].IsPendingDelete) + { + character = default; + return false; + } + + character = _entries[index]; + _pendingDeleteCharacterId = 0u; + _lastRestoreRequestedCharacterId = character.CharacterId; + _restoreResponseArmed = true; + _operation = RuntimeCharacterSelectionOperation.RestoreRequested; + _error = null; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.RestoreRequested, + character.CharacterId); + return true; + } + + internal bool Cancel() + { + RuntimeCharacterSelectionDeltaKind kind; + uint characterId; + lock (_gate) + { + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection) + { + return false; + } + + if (_pendingDeleteCharacterId != 0u) + { + characterId = _pendingDeleteCharacterId; + _pendingDeleteCharacterId = 0u; + kind = RuntimeCharacterSelectionDeltaKind.DeleteConfirmationCancelled; + } + else if (_error is not null) + { + characterId = 0u; + _error = null; + kind = RuntimeCharacterSelectionDeltaKind.ErrorChanged; + } + else + { + return false; + } + _revision++; + } + Publish(kind, characterId); + return true; + } + + internal void ApplyDeleteAcknowledged() + { + uint characterId; + lock (_gate) + { + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || _operation != RuntimeCharacterSelectionOperation.DeleteRequested) + { + return; + } + _operation = RuntimeCharacterSelectionOperation.DeleteAcknowledged; + characterId = _highlightedCharacterId; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.DeleteAcknowledged, + characterId); + } + + internal void ApplyRestore(CharacterRestore.Parsed response) + { + uint characterId; + uint errorCode = 0u; + lock (_gate) + { + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || !_restoreResponseArmed) + return; + if (response.Guid is { } responseGuid + && responseGuid != _lastRestoreRequestedCharacterId) + { + return; + } + _restoreResponseArmed = false; + characterId = response.Guid + ?? _lastRestoreRequestedCharacterId; + + if (response.IsOk + && response.Guid is { } guid + && response.SecondsGreyedOut is { } seconds) + { + int index = FindDisplayIndex(guid); + if (index >= 0) + { + RuntimeCharacterSelectionEntry current = _entries[index]; + _entries[index] = current with + { + Name = response.Name ?? current.Name, + SecondsGreyedOut = seconds, + }; + Array.Sort( + _entries, + static (left, right) => + string.CompareOrdinal(left.Name, right.Name)); + _entries = StablePartitionGreyedToTail(_entries); + } + _operation = RuntimeCharacterSelectionOperation.RestoreSucceeded; + _error = null; + } + else + { + errorCode = response.VerificationFlag; + _operation = RuntimeCharacterSelectionOperation.RestoreRejected; + _error = new RuntimeCharacterSelectionError( + response.VerificationFlag, + CharacterError.Code.Undefined, + $"The character could not be restored (verification 0x{response.VerificationFlag:X8})."); + } + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.RestoreCompleted, + characterId, + errorCode); + } + + internal void ApplyError(CharacterError.Parsed error) + { + if (error.AsCode == CharacterError.Code.NumErrors) + return; + + string message = MapError(error.RawErrorCode, error.AsCode); + lock (_gate) + { + if (_disposed + || _lifecycle is not ( + RuntimeCharacterSelectionLifecycle.AwaitingSelection + or RuntimeCharacterSelectionLifecycle.EnteringWorld)) + return; + _pendingDeleteCharacterId = 0u; + _restoreResponseArmed = false; + _operation = RuntimeCharacterSelectionOperation.None; + _error = new RuntimeCharacterSelectionError( + error.RawErrorCode, + error.AsCode, + message); + if (_lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld) + _lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.ErrorChanged, + errorCode: error.RawErrorCode); + } + + internal bool BeginEnter(out RuntimeCharacterSelectionEntry character) + { + lock (_gate) + { + int index = FindDisplayIndex(_highlightedCharacterId); + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection + || HasDeleteModalOrRequest() + || index < 0 + || !_entries[index].CanEnter) + { + character = default; + return false; + } + character = _entries[index]; + _pendingDeleteCharacterId = 0u; + _lastRestoreRequestedCharacterId = 0u; + _restoreResponseArmed = false; + _operation = RuntimeCharacterSelectionOperation.None; + _error = null; + _lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.EnteringWorld, + character.CharacterId); + return true; + } + + internal void CompleteEnter(uint characterId) + { + lock (_gate) + { + if (_disposed) + return; + _lifecycle = RuntimeCharacterSelectionLifecycle.InWorld; + _highlightedCharacterId = characterId; + _operation = RuntimeCharacterSelectionOperation.None; + _error = null; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.EnteredWorld, + characterId); + } + + internal void ReturnToSelection() + { + lock (_gate) + { + if (_disposed + || _lifecycle != RuntimeCharacterSelectionLifecycle.EnteringWorld) + { + return; + } + _lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection; + _revision++; + } + } + + internal void Reset(RuntimeGenerationToken generation) + { + lock (_gate) + { + if (_disposed) + return; + _generation = generation; + _lifecycle = RuntimeCharacterSelectionLifecycle.Inactive; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.Reset); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _lifecycle = RuntimeCharacterSelectionLifecycle.Inactive; + ClearSessionState(); + _revision++; + } + _events.Dispose(); + } + + private bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) + { + lock (_gate) + { + if ((uint)displayIndex >= (uint)_entries.Length) + { + character = default; + return false; + } + character = _entries[displayIndex]; + return true; + } + } + + private bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) + { + lock (_gate) + { + int index = FindDisplayIndex(characterId); + if (index < 0) + { + character = default; + return false; + } + character = _entries[index]; + return true; + } + } + + private void Visit(IRuntimeCharacterSelectionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + lock (_gate) + { + foreach (RuntimeCharacterSelectionEntry character in _entries) + visitor.Visit(in character); + } + } + + private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex) + { + if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged) + { + return RuntimeCharacterSelectionButtons.None; + } + if (selectedIndex < 0) + return RuntimeCharacterSelectionButtons.None; + + RuntimeCharacterSelectionEntry selected = _entries[selectedIndex]; + if (selected.IsPendingDelete) + { + return new RuntimeCharacterSelectionButtons( + CanEnter: false, + CanDelete: false, + CanRestore: true, + DeleteVisible: false, + RestoreVisible: true); + } + + return new RuntimeCharacterSelectionButtons( + CanEnter: selected.CanEnter, + CanDelete: selected.CanEnter, + CanRestore: false, + DeleteVisible: true, + RestoreVisible: false); + } + + private int FindDisplayIndex(uint characterId) + { + if (characterId == 0u) + return -1; + for (int i = 0; i < _entries.Length; i++) + { + if (_entries[i].CharacterId == characterId) + return i; + } + return -1; + } + + private bool Contains(uint characterId) => + FindDisplayIndex(characterId) >= 0; + + private bool HasDeleteModalOrRequest() => + _pendingDeleteCharacterId != 0u + || _operation is RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged; + + private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail( + RuntimeCharacterSelectionEntry[] sorted) + { + if (sorted.Length < 2) + return sorted; + var result = new RuntimeCharacterSelectionEntry[sorted.Length]; + int position = 0; + foreach (RuntimeCharacterSelectionEntry entry in sorted) + { + if (!entry.IsPendingDelete) + result[position++] = entry; + } + foreach (RuntimeCharacterSelectionEntry entry in sorted) + { + if (entry.IsPendingDelete) + result[position++] = entry; + } + return result; + } + + private static string MapError( + uint rawCode, + CharacterError.Code code) => + code switch + { + CharacterError.Code.Logon => + "Another account is already logged on from this client.", + CharacterError.Code.LoggedOn => + "This account is already logged on.", + CharacterError.Code.AccountLogon => + "The server could not access the account. Please try again shortly.", + CharacterError.Code.ServerCrash or CharacterError.Code.AccountInUse => + "The server disconnected. Please try again shortly.", + CharacterError.Code.Logoff => + "The server could not log off the character.", + CharacterError.Code.Delete => + "The server could not delete the character.", + CharacterError.Code.NoPremade => + "No premade character is available.", + CharacterError.Code.AccountInvalid => + "The account name is not valid.", + CharacterError.Code.AccountDoesntExist => + "The account does not exist.", + CharacterError.Code.EnterGameGeneric => + "The character could not enter the world.", + CharacterError.Code.EnterGameStressAccount => + "A stress-test character cannot enter the world.", + CharacterError.Code.EnterGameCharacterInWorld => + "One of this account's characters is still in the world. Please try again shortly.", + CharacterError.Code.EnterGamePlayerAccountMissing => + "The server could not find the player account. Please try again later.", + CharacterError.Code.EnterGameCharacterNotOwned => + "This account does not own the selected character.", + CharacterError.Code.EnterGameCharacterInWorldServer => + "One of this account's characters is already in the world.", + CharacterError.Code.EnterGameOldCharacter => + "The selected character must be updated before entering the world.", + CharacterError.Code.EnterGameCorruptCharacter => + "The selected character's data is corrupt.", + CharacterError.Code.EnterGameStartServerDown => + "The selected character's starting server is unavailable.", + CharacterError.Code.EnterGameCouldntPlaceCharacter => + "The selected character could not be placed in the world. Please try again shortly.", + CharacterError.Code.LogonServerFull => + "The server is currently full. Please try again later.", + CharacterError.Code.CharacterIsBooted => + "The selected character is temporarily unavailable.", + CharacterError.Code.EnterGameCharacterLocked => + "A save of the selected character is still in progress. Please try again later.", + CharacterError.Code.SubscriptionExpired => + "The account subscription has expired.", + _ => $"Character selection failed (error 0x{rawCode:X8}).", + }; + + private void ClearSessionState() + { + _entries = []; + _accountName = string.Empty; + _slotCount = 0; + _highlightedCharacterId = 0u; + _pendingDeleteCharacterId = 0u; + _lastRestoreRequestedCharacterId = 0u; + _restoreResponseArmed = false; + _operation = RuntimeCharacterSelectionOperation.None; + _error = null; + } + + private void Publish( + RuntimeCharacterSelectionDeltaKind kind, + uint characterId = 0u, + uint errorCode = 0u) + { + RuntimeGenerationToken generation; + long revision; + lock (_gate) + { + if (_disposed) + return; + generation = _generation; + revision = _revision; + } + _events.Publish(generation, revision, kind, characterId, errorCode); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); +} + +internal sealed class RuntimeCharacterSelectionEventStream : IDisposable +{ + private readonly object _gate = new(); + private readonly List _pending = []; + private IRuntimeCharacterSelectionObserver[] _observers = []; + private ulong _sequence; + private bool _dispatching; + private bool _disposed; + + public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Array.IndexOf(_observers, observer) >= 0) + { + throw new InvalidOperationException( + "The character-selection observer is already subscribed."); + } + var replacement = new IRuntimeCharacterSelectionObserver[ + _observers.Length + 1]; + Array.Copy(_observers, replacement, _observers.Length); + replacement[^1] = observer; + Volatile.Write(ref _observers, replacement); + } + return new Subscription(this, observer); + } + + public void Publish( + RuntimeGenerationToken generation, + long revision, + RuntimeCharacterSelectionDeltaKind kind, + uint characterId, + uint errorCode) + { + lock (_gate) + { + if (_disposed) + return; + _pending.Add(new RuntimeCharacterSelectionDelta( + generation, + unchecked(++_sequence), + revision, + kind, + characterId, + errorCode)); + if (_dispatching) + return; + _dispatching = true; + } + + int index = 0; + while (true) + { + RuntimeCharacterSelectionDelta delta; + lock (_gate) + { + if (index >= _pending.Count) + { + _pending.Clear(); + _dispatching = false; + return; + } + delta = _pending[index++]; + } + + foreach (IRuntimeCharacterSelectionObserver observer + in Volatile.Read(ref _observers)) + { + try + { + observer.OnCharacterSelectionChanged(in delta); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"runtime: character-selection observer failed: {error.Message}"); + } + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _pending.Clear(); + _dispatching = false; + Volatile.Write(ref _observers, []); + } + } + + private void Unsubscribe(IRuntimeCharacterSelectionObserver observer) + { + lock (_gate) + { + int index = Array.IndexOf(_observers, observer); + if (index < 0) + return; + var replacement = new IRuntimeCharacterSelectionObserver[ + _observers.Length - 1]; + if (index > 0) + Array.Copy(_observers, 0, replacement, 0, index); + if (index < _observers.Length - 1) + { + Array.Copy( + _observers, + index + 1, + replacement, + index, + _observers.Length - index - 1); + } + Volatile.Write(ref _observers, replacement); + } + } + + private sealed class Subscription( + RuntimeCharacterSelectionEventStream owner, + IRuntimeCharacterSelectionObserver observer) + : IDisposable + { + private RuntimeCharacterSelectionEventStream? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); + } +} diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs index e858eff3..807c007e 100644 --- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -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 calls, diff --git a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs index 9cbe54e0..ceba50ad 100644 --- a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs +++ b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs @@ -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 diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 33dafd1b..493dd5d8 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -173,6 +173,44 @@ 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 GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown() { @@ -698,7 +736,7 @@ public sealed class CurrentGameRuntimeAdapterTests private readonly IDisposable _combatModeBinding; private readonly GameRuntime _gameRuntime; - public Harness() + public Harness(bool awaitCharacterSelection = false) { CombatAttackOperations = new CombatAttackOperationsSlot(); CombatModeOperations = new RuntimeCombatModeOperationsSlot(); @@ -758,7 +796,11 @@ public sealed class CurrentGameRuntimeAdapterTests new SelectionTransport(() => _session?.IsInWorld == true), new NoopInteractionMovement()); - Host = CreateHost(_session, Commands, Identity); + Host = CreateHost( + _session, + Commands, + Identity, + awaitCharacterSelection); Runtime = new CurrentGameRuntimeAdapter( _gameRuntime, Host, @@ -873,7 +915,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 +968,8 @@ public sealed class CurrentGameRuntimeAdapterTests "127.0.0.1", 9000, "user", - "password")); + "password", + AwaitCharacterSelection: awaitCharacterSelection)); } private static RuntimeOptions LiveOptions() @@ -1039,6 +1083,10 @@ public sealed class CurrentGameRuntimeAdapterTests true, true); + public void StartCharacterSelectionReceive(WorldSession session) + { + } + public void EnterWorld(WorldSession session, int activeCharacterIndex) { } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs new file mode 100644 index 00000000..04ac26b4 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -0,0 +1,175 @@ +using System.Buffers.Binary; +using System.Net; +using System.Reflection; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionCharacterSelectionTests +{ + private sealed class NullTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromCanceled(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(); + 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); + } + + private static WorldSession CreateSession() => + new( + new IPEndPoint(IPAddress.Loopback, 9000), + new NullTransport()); + + 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(datagram), null, true]); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 10b4e18f..124190b3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -57,10 +57,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 Sessions { get; } = []; public Dictionary DisposeCounts { get; } = []; + public List<(string Account, int ActiveIndex)> DeleteRequests { get; } = []; + public List RestoreRequests { get; } = []; public int EnterWorldCount { get; private set; } public int TickCount { get; private set; } @@ -98,15 +101,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 +363,179 @@ public sealed class LiveSessionControllerTests Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected")); } + [Fact] + public void Start_GraphicalNoSelectorStopsBeforeEnterWorldThenTypedEnterContinuesSameScope() + { + var calls = new List(); + 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(); + 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(); + 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(); + 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, 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); + controller.Tick(); + Assert.Equal(1, operations.TickCount); + Assert.False(controller.IsInWorld); + } + + [Fact] + public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop() + { + var calls = new List(); + 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 +1428,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 +1437,8 @@ public sealed class LiveSessionControllerTests user ?? string.Empty, "password", selector, - probe); + probe, + awaitSelection); private static CharacterList.Parsed AvailableCharacters() => new( 0u, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs new file mode 100644 index 00000000..2b5f43e1 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -0,0 +1,247 @@ +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 Entries { get; } = []; + + public void Visit(in RuntimeCharacterSelectionEntry character) => + Entries.Add(character); + } + + private sealed class Observer( + Action onDelta) + : IRuntimeCharacterSelectionObserver + { + public void OnCharacterSelectionChanged( + in RuntimeCharacterSelectionDelta delta) => + onDelta(delta); + } + + [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 RestoreResponse_FromSupersededRequestCannotCompleteTheCurrentRequest() + { + using var state = new RuntimeCharacterSelectionState(); + 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.True(state.TryBeginRestore(out _)); + state.ApplyRestore(new CharacterRestore.Parsed( + 1u, + 0x50000001u, + "First", + 0u)); + + Assert.Equal( + RuntimeCharacterSelectionOperation.RestoreRequested, + state.Snapshot.Operation); + Assert.True(state.View.TryGet(0x50000001u, out var first)); + Assert.True(first.IsPendingDelete); + } + + [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(); + using IDisposable subscription = state.View.Subscribe( + new Observer(deltas.Add)); + + state.ApplyError(new CharacterError.Parsed( + (uint)CharacterError.Code.Delete)); + RuntimeCharacterSelectionError known = + Assert.IsType(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(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(); + 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); +}