From 0e82cbf70053c911ca178ce38ea3553fd39eaed9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:22:17 +0200 Subject: [PATCH 1/3] 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); +} From 1b9e7e41f91935563d0ac4be4c081d47ca0c0165 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:55:48 +0200 Subject: [PATCH 2/3] fix(runtime): close Campaign LA7b review findings --- .../Runtime/CurrentGameRuntimeAdapter.cs | 191 +++++++++++++++++- src/AcDream.Core.Net/WorldSession.cs | 68 ++++++- src/AcDream.Runtime/GameRuntime.cs | 8 +- .../Session/LiveSessionController.cs | 10 +- .../Session/RuntimeCharacterSelectionState.cs | 63 +++++- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 99 ++++++++- .../Transport/FakeAceTransport.cs | 29 ++- .../Transport/FakeAceTransportTests.cs | 100 +++++++++ .../WorldSessionCharacterSelectionTests.cs | 70 +++++++ .../Session/LiveSessionControllerTests.cs | 28 ++- .../RuntimeCharacterSelectionStateTests.cs | 62 +++++- 11 files changed, 700 insertions(+), 28 deletions(-) diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 56e89e2a..6e656da7 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -20,6 +20,7 @@ internal sealed class CurrentGameRuntimeAdapter { private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; + private readonly CharacterSelectionProjection _characterSelection; private readonly IDisposable _hostLease; private readonly object _subscriptionGate = new(); private readonly HashSet _subscriptions = []; @@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter "graphical game-runtime command adapter"); try { + _characterSelection = new CharacterSelectionProjection(this); _commands = new CurrentGameRuntimeCommandAdapter( runtime.Session, sessionHost, @@ -61,7 +63,7 @@ internal sealed class CurrentGameRuntimeAdapter } private bool IsActive => - !_disposed + !Volatile.Read(ref _disposed) && !_runtime.Session.IsDisposalComplete; public RuntimeGenerationToken Generation => _runtime.Generation; @@ -90,7 +92,7 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeSocialView Social => _runtime.Social; public IRuntimeChatView Chat => _runtime.Chat; public IRuntimeCharacterSelectionView CharacterSelection => - _runtime.CharacterSelection; + _characterSelection; public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; @@ -100,9 +102,9 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeSessionCommands Session => _commands; public IRuntimeCharacterSelectionCommands CharacterSelectionCommands => - _runtime.Session; + _characterSelection; IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => - _runtime.Session; + _characterSelection; public IRuntimeSelectionCommands Selection => _commands; public IRuntimeCombatCommands Combat => _commands; public IRuntimeMagicCommands Magic => _commands; @@ -166,6 +168,187 @@ internal sealed class CurrentGameRuntimeAdapter _subscriptions.Remove(subscription); } + private RuntimeCharacterSelectionSnapshot CharacterSelectionSnapshot() + { + lock (_subscriptionGate) + { + if (IsActive) + return _runtime.CharacterSelection.Snapshot; + return new RuntimeCharacterSelectionSnapshot( + _runtime.Generation, + RuntimeCharacterSelectionLifecycle.Inactive, + Revision: 0, + AccountName: string.Empty, + SlotCount: 0, + RosterCount: 0, + HighlightedCharacterId: 0u, + HighlightedDisplayIndex: -1, + PendingDeleteCharacterId: 0u, + LastRestoreRequestedCharacterId: 0u, + Operation: RuntimeCharacterSelectionOperation.None, + Error: null, + Buttons: RuntimeCharacterSelectionButtons.None); + } + } + + private bool TryGetCharacterSelectionAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) + { + lock (_subscriptionGate) + { + if (IsActive) + { + return _runtime.CharacterSelection.TryGetAt( + displayIndex, + out character); + } + character = default; + return false; + } + } + + private bool TryGetCharacterSelection( + uint characterId, + out RuntimeCharacterSelectionEntry character) + { + lock (_subscriptionGate) + { + if (IsActive) + { + return _runtime.CharacterSelection.TryGet( + characterId, + out character); + } + character = default; + return false; + } + } + + private void VisitCharacterSelection( + IRuntimeCharacterSelectionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + lock (_subscriptionGate) + { + if (IsActive) + _runtime.CharacterSelection.Visit(visitor); + } + } + + private IDisposable SubscribeCharacterSelection( + IRuntimeCharacterSelectionObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_subscriptionGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var gated = new AdapterCharacterSelectionObserver(this, observer); + IDisposable runtimeSubscription = + _runtime.CharacterSelection.Subscribe(gated); + var subscription = new AdapterSubscription( + this, + runtimeSubscription); + _subscriptions.Add(subscription); + return subscription; + } + } + + private RuntimeCommandResult ExecuteCharacterSelection( + Func execute) + { + lock (_subscriptionGate) + { + if (!IsActive) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _runtime.Generation); + } + return execute(_runtime.Session); + } + } + + private void ForwardCharacterSelection( + IRuntimeCharacterSelectionObserver observer, + in RuntimeCharacterSelectionDelta delta) + { + lock (_subscriptionGate) + { + if (IsActive) + observer.OnCharacterSelectionChanged(in delta); + } + } + + private sealed class CharacterSelectionProjection( + CurrentGameRuntimeAdapter owner) + : IRuntimeCharacterSelectionView, + IRuntimeCharacterSelectionCommands + { + public RuntimeCharacterSelectionSnapshot Snapshot => + owner.CharacterSelectionSnapshot(); + + public bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) => + owner.TryGetCharacterSelectionAt(displayIndex, out character); + + public bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) => + owner.TryGetCharacterSelection(characterId, out character); + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) => + owner.VisitCharacterSelection(visitor); + + public IDisposable Subscribe( + IRuntimeCharacterSelectionObserver observer) => + owner.SubscribeCharacterSelection(observer); + + public RuntimeCommandResult Highlight( + RuntimeGenerationToken expectedGeneration, + uint characterId) => + owner.ExecuteCharacterSelection( + commands => commands.Highlight( + expectedGeneration, + characterId)); + + public RuntimeCommandResult Enter( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Enter(expectedGeneration)); + + public RuntimeCommandResult RequestDelete( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.RequestDelete(expectedGeneration)); + + public RuntimeCommandResult ConfirmDelete( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.ConfirmDelete(expectedGeneration)); + + public RuntimeCommandResult Restore( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Restore(expectedGeneration)); + + public RuntimeCommandResult Cancel( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Cancel(expectedGeneration)); + } + + private sealed class AdapterCharacterSelectionObserver( + CurrentGameRuntimeAdapter owner, + IRuntimeCharacterSelectionObserver observer) + : IRuntimeCharacterSelectionObserver + { + public void OnCharacterSelectionChanged( + in RuntimeCharacterSelectionDelta delta) => + owner.ForwardCharacterSelection(observer, in delta); + } + private sealed class AdapterSubscription( CurrentGameRuntimeAdapter owner, IDisposable runtimeSubscription) : IDisposable diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index cbe68e1c..8cfab9f5 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1125,11 +1125,12 @@ public sealed class WorldSession : IDisposable { var opcodes = new List(); ProcessDatagram(datagram.Memory, opcodes); - SweepTransport(); return opcodes.Contains(0xF7DFu) || _lastCharacterSelectionError is not null; }, - ReturnInboundDatagram); + ReturnInboundDatagram, + SweepTransport, + TimeSpan.FromMilliseconds(25)); } if (_lastCharacterSelectionError is { } selectionError) { @@ -1817,6 +1818,12 @@ public sealed class WorldSession : IDisposable { continue; } + // CharacterError::NumErrors is the enum-count sentinel, not + // a server rejection. Retail never presents it, and treating + // it as an EnterWorld failure would abort either handshake + // pump before a valid ServerReady later in the same packet. + if (parsed.AsCode == CharacterError.Code.NumErrors) + continue; _lastCharacterSelectionError = parsed; CharacterErrorReceived?.Invoke(parsed); } @@ -3339,10 +3346,15 @@ public sealed class WorldSession : IDisposable ChannelReader reader, TimeSpan timeout, Func processAndCheckConfirmation, - Action? release = null) + Action? release = null, + Action? periodicWork = null, + TimeSpan? periodicInterval = null) { ArgumentNullException.ThrowIfNull(reader); ArgumentNullException.ThrowIfNull(processAndCheckConfirmation); + TimeSpan cadence = periodicInterval ?? TimeSpan.FromMilliseconds(25); + if (periodicWork is not null && cadence <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(periodicInterval)); using var timeoutSource = new CancellationTokenSource(timeout); // The deadline is also read straight off the monotonic clock, not only @@ -3382,10 +3394,52 @@ public sealed class WorldSession : IDisposable return true; } - bool canRead = reader.WaitToReadAsync(timeoutSource.Token) - .AsTask() - .GetAwaiter() - .GetResult(); + periodicWork?.Invoke(); + if (timeoutSource.IsCancellationRequested || Expired()) + return false; + + bool canRead; + if (periodicWork is null) + { + canRead = reader.WaitToReadAsync(timeoutSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + else + { + TimeSpan wait = cadence; + if (bounded) + { + TimeSpan remaining = timeout + - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + return false; + if (remaining < wait) + wait = remaining; + } + + using var sliceSource = + CancellationTokenSource.CreateLinkedTokenSource( + timeoutSource.Token); + sliceSource.CancelAfter(wait); + try + { + canRead = reader.WaitToReadAsync(sliceSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + when (!timeoutSource.IsCancellationRequested + && !Expired()) + { + // This cadence is the paused selector's frame edge: + // keep reliable transport work moving even when no + // datagram arrives to wake the inbound queue. + continue; + } + } if (!canRead) return false; } diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 5b6e7e60..fadf5bd9 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -178,8 +178,12 @@ public sealed class GameRuntime faultInjection); context.Session = dependencies.SessionOperations is null - ? new LiveSessionController() - : new LiveSessionController(dependencies.SessionOperations); + ? new LiveSessionController( + ProductionLiveSessionOperations.Instance, + dependencies.TimeProvider) + : new LiveSessionController( + dependencies.SessionOperations, + dependencies.TimeProvider); construction.Own(context.Session); Fault( GameRuntimeConstructionPoint.SessionCreated, diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index b7c37555..8dae81a4 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -376,14 +376,17 @@ public sealed class LiveSessionController private Action? _preLogoffFlushHook; public LiveSessionController() - : this(ProductionLiveSessionOperations.Instance) + : this(ProductionLiveSessionOperations.Instance, null) { } - public LiveSessionController(ILiveSessionOperations operations) + public LiveSessionController( + ILiveSessionOperations operations, + TimeProvider? timeProvider = null) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); - CharacterSelectionState = new RuntimeCharacterSelectionState(); + CharacterSelectionState = new RuntimeCharacterSelectionState( + timeProvider); } public RuntimeCharacterSelectionState CharacterSelectionState { get; } @@ -583,6 +586,7 @@ public sealed class LiveSessionController _operations.Tick(scope.Session); if (!IsCurrent(scope, generation)) return; + CharacterSelectionState.SweepRestoreCorrelation(); } catch (Exception tickError) { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index db55e783..f88895bd 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -32,6 +32,7 @@ public enum RuntimeCharacterSelectionDeltaKind DeleteAcknowledged, RestoreRequested, RestoreCompleted, + RestoreCorrelationExpired, ErrorChanged, EnteringWorld, EnteredWorld, @@ -165,6 +166,9 @@ public interface IRuntimeCharacterSelectionCommands /// public sealed class RuntimeCharacterSelectionState : IDisposable { + internal static readonly TimeSpan RestoreCorrelationTimeout = + TimeSpan.FromSeconds(5); + private sealed class ViewProjection(RuntimeCharacterSelectionState owner) : IRuntimeCharacterSelectionView { @@ -191,6 +195,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private readonly object _gate = new(); private readonly RuntimeCharacterSelectionEventStream _events = new(); private readonly ViewProjection _view; + private readonly TimeProvider _timeProvider; private RuntimeCharacterSelectionEntry[] _entries = []; private RuntimeGenerationToken _generation; private RuntimeCharacterSelectionLifecycle _lifecycle; @@ -201,12 +206,15 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private uint _pendingDeleteCharacterId; private uint _lastRestoreRequestedCharacterId; private bool _restoreResponseArmed; + private long _restoreCorrelationStartedTimestamp; + private bool _flagOnlyRestoreResponseAmbiguous; private RuntimeCharacterSelectionOperation _operation; private RuntimeCharacterSelectionError? _error; private bool _disposed; - public RuntimeCharacterSelectionState() + public RuntimeCharacterSelectionState(TimeProvider? timeProvider = null) { + _timeProvider = timeProvider ?? TimeProvider.System; _view = new ViewProjection(this); } @@ -303,6 +311,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u; _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; + _flagOnlyRestoreResponseAmbiguous = false; _operation = RuntimeCharacterSelectionOperation.None; _error = null; _highlightedCharacterId = Contains(previous) @@ -408,6 +418,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable if (_disposed || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection || HasDeleteModalOrRequest() + || _restoreResponseArmed || index < 0 || !_entries[index].IsPendingDelete) { @@ -419,6 +430,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = character.CharacterId; _restoreResponseArmed = true; + _restoreCorrelationStartedTimestamp = + _timeProvider.GetTimestamp(); _operation = RuntimeCharacterSelectionOperation.RestoreRequested; _error = null; _revision++; @@ -429,6 +442,39 @@ public sealed class RuntimeCharacterSelectionState : IDisposable return true; } + /// + /// Releases the one outstanding restore correlation after a bounded local + /// window. ACE can intentionally send no response for an unknown GUID; + /// later flag-only responses carry no GUID, so once a request expires + /// they remain ambiguous and are never attributed to a newer request. + /// + internal bool SweepRestoreCorrelation() + { + uint characterId; + lock (_gate) + { + if (_disposed + || !_restoreResponseArmed + || _timeProvider.GetElapsedTime( + _restoreCorrelationStartedTimestamp) + < RestoreCorrelationTimeout) + { + return false; + } + + characterId = _lastRestoreRequestedCharacterId; + _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; + _flagOnlyRestoreResponseAmbiguous = true; + _operation = RuntimeCharacterSelectionOperation.None; + _revision++; + } + Publish( + RuntimeCharacterSelectionDeltaKind.RestoreCorrelationExpired, + characterId); + return true; + } + internal bool Cancel() { RuntimeCharacterSelectionDeltaKind kind; @@ -493,12 +539,18 @@ public sealed class RuntimeCharacterSelectionState : IDisposable || _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection || !_restoreResponseArmed) return; + if (response.Guid is null + && _flagOnlyRestoreResponseAmbiguous) + { + return; + } if (response.Guid is { } responseGuid && responseGuid != _lastRestoreRequestedCharacterId) { return; } _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; characterId = response.Guid ?? _lastRestoreRequestedCharacterId; @@ -555,7 +607,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable or RuntimeCharacterSelectionLifecycle.EnteringWorld)) return; _pendingDeleteCharacterId = 0u; + if (_restoreResponseArmed) + _flagOnlyRestoreResponseAmbiguous = true; _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; _operation = RuntimeCharacterSelectionOperation.None; _error = new RuntimeCharacterSelectionError( error.RawErrorCode, @@ -588,6 +643,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u; _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; + _flagOnlyRestoreResponseAmbiguous = false; _operation = RuntimeCharacterSelectionOperation.None; _error = null; _lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld; @@ -717,7 +774,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable return new RuntimeCharacterSelectionButtons( CanEnter: false, CanDelete: false, - CanRestore: true, + CanRestore: !_restoreResponseArmed, DeleteVisible: false, RestoreVisible: true); } @@ -833,6 +890,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u; _restoreResponseArmed = false; + _restoreCorrelationStartedTimestamp = 0; + _flagOnlyRestoreResponseAmbiguous = false; _operation = RuntimeCharacterSelectionOperation.None; _error = null; } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 493dd5d8..24e6948c 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -211,6 +211,74 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid); } + [Fact] + public void DisposedAdapterMakesRetainedSelectionRoutesInertWhileRuntimeLives() + { + using var harness = new Harness(awaitCharacterSelection: true); + using CurrentGameRuntimeAdapter survivor = + harness.CreateAdditionalAdapter(); + Assert.Equal( + RuntimeSessionStartStatus.AwaitingCharacterSelection, + harness.Runtime.Session.Start(harness.Runtime.Generation).Status); + + RuntimeGenerationToken generation = harness.Runtime.Generation; + IRuntimeCharacterSelectionView retainedView = + harness.Runtime.CharacterSelection; + IRuntimeCharacterSelectionCommands publicCommands = + harness.Runtime.CharacterSelectionCommands; + IRuntimeCharacterSelectionCommands interfaceCommands = + ((IGameRuntimeCommands)harness.Runtime).CharacterSelection; + var observer = new CharacterSelectionObserver(); + using IDisposable subscription = retainedView.Subscribe(observer); + RuntimeCharacterSelectionSnapshot before = + survivor.CharacterSelection.Snapshot; + + harness.Runtime.Dispose(); + + Assert.False(harness.Runtime.Lifecycle.HasTransport); + Assert.Equal( + RuntimeCharacterSelectionLifecycle.Inactive, + retainedView.Snapshot.Lifecycle); + Assert.Equal(0, retainedView.Snapshot.RosterCount); + Assert.False(retainedView.TryGetAt(0, out _)); + Assert.False(retainedView.TryGet(Harness.PlayerGuid, out _)); + var visitor = new CharacterSelectionVisitor(); + retainedView.Visit(visitor); + Assert.Empty(visitor.Entries); + Assert.Throws(() => + retainedView.Subscribe(new CharacterSelectionObserver())); + + RuntimeCommandStatus[] statuses = + [ + publicCommands.Highlight(generation, 0x50000001u).Status, + publicCommands.Enter(generation).Status, + publicCommands.RequestDelete(generation).Status, + interfaceCommands.ConfirmDelete(generation).Status, + interfaceCommands.Restore(generation).Status, + interfaceCommands.Cancel(generation).Status, + ]; + Assert.All(statuses, status => + Assert.Equal(RuntimeCommandStatus.Inactive, status)); + + RuntimeCharacterSelectionSnapshot after = + survivor.CharacterSelection.Snapshot; + Assert.Equal(before.Revision, after.Revision); + Assert.Equal(before.HighlightedCharacterId, after.HighlightedCharacterId); + Assert.Equal(before.Operation, after.Operation); + + // The second lease proves the canonical Runtime is still live, while + // the disposed adapter's already-returned observer stays detached. + Assert.True(survivor.CharacterSelectionCommands.Highlight( + generation, + 0x50000001u).Accepted); + Assert.Equal(0x50000001u, + survivor.CharacterSelection.Snapshot.HighlightedCharacterId); + Assert.Empty(observer.Deltas); + Assert.Equal( + RuntimeCharacterSelectionLifecycle.Inactive, + harness.Runtime.CharacterSelection.Snapshot.Lifecycle); + } + [Fact] public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown() { @@ -735,6 +803,7 @@ public sealed class CurrentGameRuntimeAdapterTests private readonly LiveSessionController _session; private readonly IDisposable _combatModeBinding; private readonly GameRuntime _gameRuntime; + private readonly SelectionInteractionController _selectionController; public Harness(bool awaitCharacterSelection = false) { @@ -789,7 +858,7 @@ public sealed class CurrentGameRuntimeAdapterTests sendWield: null, sendDrop: null); var query = new SelectionQuery(TargetGuid); - var selectionController = new SelectionInteractionController( + _selectionController = new SelectionInteractionController( Selection, query, _items, @@ -805,7 +874,7 @@ public sealed class CurrentGameRuntimeAdapterTests _gameRuntime, Host, Commands, - selectionController); + _selectionController); } public RuntimeOptions Options { get; } @@ -842,6 +911,13 @@ public sealed class CurrentGameRuntimeAdapterTests public LiveSessionHost Host { get; } public CurrentGameRuntimeAdapter Runtime { get; } + public CurrentGameRuntimeAdapter CreateAdditionalAdapter() => + new( + _gameRuntime, + Host, + Commands, + _selectionController); + public void Dispose() { Runtime.Dispose(); @@ -853,6 +929,25 @@ public sealed class CurrentGameRuntimeAdapterTests } } + private sealed class CharacterSelectionVisitor + : IRuntimeCharacterSelectionVisitor + { + public List Entries { get; } = []; + + public void Visit(in RuntimeCharacterSelectionEntry character) => + Entries.Add(character); + } + + private sealed class CharacterSelectionObserver + : IRuntimeCharacterSelectionObserver + { + public List Deltas { get; } = []; + + public void OnCharacterSelectionChanged( + in RuntimeCharacterSelectionDelta delta) => + Deltas.Add(delta); + } + private static void PrepareCombatAndMagic(Harness harness) { harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee); diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs index 480b78be..4810ec05 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs @@ -74,6 +74,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport /// public TimeSpan AutoAdvanceOnBlockingReceive { get; set; } + /// + /// Keeps the default ACE script intact while allowing focused handshake + /// tests to enqueue messages before the valid ServerReady response. + /// + public bool AutoReplyServerReady { get; set; } = true; + public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null) { Clock = clock ?? new VirtualClock(); @@ -101,7 +107,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8 // Server replies CharacterEnterWorldServerReady (0xF7DF) — // WorldSession.EnterWorld blocks on this opcode. - Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue); + if (AutoReplyServerReady) + { + Model.EnqueueGameMessage( + BuildOpcodeOnlyBody(0xF7DFu), + GameMessageGroup.UIQueue); + } break; case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id) // ACE echoes the opcode-only confirmation; WorldSession.Dispose @@ -142,6 +153,22 @@ internal sealed class FakeAceTransport : IWorldSessionTransport } } + /// + /// Enqueue and flush one model game message under the transport's model + /// lock. This is the race-free test seam for a server follower emitted + /// while the real WorldSession background receiver is active. + /// + public void EnqueueServerGameMessage( + byte[] body, + GameMessageGroup group) + { + lock (_gate) + { + Model.EnqueueGameMessage(body, group); + PumpServerLocked(); + } + } + /// /// N2 test hook: deliver raw bytes straight into the client's receive /// queue, bypassing both the model and the link. Used for late diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs index 0c817f0c..2d79544f 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs @@ -2,6 +2,7 @@ using System.Buffers.Binary; using System.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; namespace AcDream.Core.Net.Tests.Transport; @@ -165,6 +166,105 @@ public sealed class FakeAceTransportTests Assert.Equal(0, transport.Model.CrcDropCount); } + [Fact] + public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep() + { + var fake = new FakeAceTransport(); + // Random(~13) yields 4, 92, 55, 54: at 50%, only the first + // post-arm inbound datagram (ServerReady) is dropped; the follower, + // recovered resend, and graceful-logoff confirmation all land. + var lossy = new LossyTransportDecorator( + fake, + dropPercent: 50, + seed: 13, + NetDropDirection.In); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + lossy) + { + TransportClockSource = + (fake.Clock.GetTimestamp, fake.Clock.Frequency), + }; + using var enterRequest = new ManualResetEventSlim(); + fake.Model.MessageDispatched += body => + { + if (ReadOpcode(body) + == CharacterEnterWorld.EnterWorldRequestOpcode) + { + enterRequest.Set(); + } + }; + + try + { + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.StartCharacterSelectionReceive(); + + // Graphical selector frames continue before the user enters. + for (int i = 0; i < 3; i++) + { + fake.Clock.Advance(TimeSpan.FromMilliseconds(50)); + session.Tick(); + } + + Task gapDriver = Task.Run(() => + { + Assert.True(enterRequest.Wait(TimeSpan.FromSeconds(2))); + // This later sequenced packet passes the seeded loss gate, + // exposing the missing ServerReady and parking behind it. + fake.EnqueueServerGameMessage( + BuildServerMessage("post-ready follower"), + GameMessageGroup.UIQueue); + Assert.True(SpinWait.SpinUntil( + () => session.Transport?.Inbound.NakCount > 0, + TimeSpan.FromSeconds(2))); + + // No datagram follows this virtual-time edge. Recovery now + // requires paused EnterWorld's independent periodic sweep. + fake.Clock.Advance(TimeSpan.FromSeconds(1)); + }); + + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + await gapDriver; + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(1, lossy.InboundDropped); + Assert.True(session.Transport!.Stats.NaksSent > 0); + Assert.True(fake.Model.RetransmitsServed > 0); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(0, session.Transport.Outbound.PendingResendCount); + Assert.Equal( + new[] + { + CharacterEnterWorld.EnterWorldRequestOpcode, + CharacterEnterWorld.EnterWorldOpcode, + }, + fake.Model.DispatchedMessages.Select(ReadOpcode).ToArray()); + Assert.Equal(256, fake.Model.Crypto.Headroom); + Assert.Equal(0, fake.Model.Crypto.OrphanCount); + Assert.Equal(0, fake.Model.CrcDropCount); + Assert.False(fake.Model.IsTerminated); + } + finally + { + session.Dispose(); + } + + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + Assert.True(fake.Model.IsTerminated); + Assert.Equal( + AceTerminationReason.PacketHeaderDisconnect, + fake.Model.TerminationReason); + Assert.Equal( + CharacterLogOff.Opcode, + ReadOpcode(fake.Model.DispatchedMessages[^1])); + Assert.Equal(256, fake.Model.Crypto.Headroom); + Assert.Equal(0, fake.Model.Crypto.OrphanCount); + } + private static uint ReadOpcode(byte[] messageBody) => BinaryPrimitives.ReadUInt32LittleEndian(messageBody); diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 04ac26b4..71d5f787 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Reflection; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Tests.Transport; namespace AcDream.Core.Net.Tests; @@ -91,11 +92,80 @@ public sealed class WorldSessionCharacterSelectionTests Assert.Equal(1u, current.SecondsGreyedOut); } + [Fact] + public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() + { + var transport = new FakeAceTransport + { + AutoReplyServerReady = false, + }; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + int errors = 0; + session.CharacterErrorReceived += _ => errors++; + ConfigureSentinelThenServerReady(transport); + + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(0, errors); + } + + [Fact] + public void PausedEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() + { + var transport = new FakeAceTransport + { + AutoReplyServerReady = false, + }; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + int errors = 0; + session.CharacterErrorReceived += _ => errors++; + ConfigureSentinelThenServerReady(transport); + + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.StartCharacterSelectionReceive(); + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(0, errors); + } + private static WorldSession CreateSession() => new( new IPEndPoint(IPAddress.Loopback, 9000), new NullTransport()); + private static void ConfigureSentinelThenServerReady( + FakeAceTransport transport) + { + transport.Model.MessageDispatched += body => + { + if (BinaryPrimitives.ReadUInt32LittleEndian(body) + != CharacterEnterWorld.EnterWorldRequestOpcode) + { + return; + } + + transport.Model.EnqueueGameMessage( + BuildCharacterError(CharacterError.Code.NumErrors), + GameMessageGroup.UIQueue); + transport.Model.EnqueueGameMessage( + BitConverter.GetBytes(0xF7DFu), + GameMessageGroup.UIQueue); + }; + } + private static byte[] BuildRoster(uint secondsGreyedOut) { var writer = new PacketWriter(96); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 124190b3..2f0fe01d 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -8,6 +8,18 @@ namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionControllerTests { + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan elapsed) => + _timestamp += elapsed.Ticks; + } + private sealed class TestTransport : IWorldSessionTransport { public void Send(ReadOnlySpan datagram) { } @@ -466,7 +478,8 @@ public sealed class LiveSessionControllerTests var calls = new List(); var operations = new TestOperations(calls); var host = new TestHost(calls); - var controller = new LiveSessionController(operations); + var time = new ManualTimeProvider(); + var controller = new LiveSessionController(operations, time); Assert.Equal( LiveSessionStartStatus.AwaitingCharacterSelection, controller.Start(LiveOptions(awaitSelection: true), host).Status); @@ -499,8 +512,21 @@ public sealed class LiveSessionControllerTests // ACE may send no response for an unknown guid. The synchronous // command has already returned and does not gate later commands. Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.Restore(generation).Status); controller.Tick(); Assert.Equal(1, operations.TickCount); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + controller.Tick(); + Assert.Equal( + RuntimeCharacterSelectionOperation.None, + controller.CharacterSelection.Snapshot.Operation); + Assert.True(controller.CharacterSelection.Snapshot.Buttons.CanRestore); + Assert.True(controller.Restore(generation).Accepted); + Assert.Equal( + [0x50000001u, 0x50000002u], + operations.RestoreRequests); Assert.False(controller.IsInWorld); } diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs index 2b5f43e1..c8b79b30 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -23,6 +23,18 @@ public sealed class RuntimeCharacterSelectionStateTests onDelta(delta); } + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan elapsed) => + _timestamp += elapsed.Ticks; + } + [Fact] public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix() { @@ -154,9 +166,10 @@ public sealed class RuntimeCharacterSelectionStateTests } [Fact] - public void RestoreResponse_FromSupersededRequestCannotCompleteTheCurrentRequest() + public void DelayedFlagOnlyRestoreResponse_CannotCompleteNewerRequest() { - using var state = new RuntimeCharacterSelectionState(); + var time = new ManualTimeProvider(); + using var state = new RuntimeCharacterSelectionState(time); state.Begin(new RuntimeGenerationToken(12)); state.ApplyRoster(Roster( new(0x50000001u, "First", 1u), @@ -164,18 +177,55 @@ public sealed class RuntimeCharacterSelectionStateTests Assert.True(state.TryBeginRestore(out _)); Assert.True(state.TryHighlight(0x50000002u)); + Assert.False(state.TryBeginRestore(out _)); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + Assert.True(state.SweepRestoreCorrelation()); Assert.True(state.TryBeginRestore(out _)); + long revision = state.Snapshot.Revision; state.ApplyRestore(new CharacterRestore.Parsed( - 1u, - 0x50000001u, - "First", - 0u)); + VerificationFlag: 2u, + Guid: null, + Name: null, + SecondsGreyedOut: null)); Assert.Equal( RuntimeCharacterSelectionOperation.RestoreRequested, state.Snapshot.Operation); + Assert.Equal(0x50000002u, state.Snapshot.LastRestoreRequestedCharacterId); + Assert.Equal(revision, state.Snapshot.Revision); Assert.True(state.View.TryGet(0x50000001u, out var first)); Assert.True(first.IsPendingDelete); + Assert.True(state.View.TryGet(0x50000002u, out var second)); + Assert.True(second.IsPendingDelete); + Assert.False(state.Snapshot.Buttons.CanRestore); + } + + [Fact] + public void NoReplyRestoreTimeout_ReleasesOnlyRestoreGate() + { + var time = new ManualTimeProvider(); + using var state = new RuntimeCharacterSelectionState(time); + state.Begin(new RuntimeGenerationToken(13)); + state.ApplyRoster(Roster( + new(0x50000001u, "Pending", 1u), + new(0x50000002u, "Ready", 0u))); + + Assert.True(state.TryHighlight(0x50000001u)); + Assert.True(state.TryBeginRestore(out _)); + Assert.False(state.Snapshot.Buttons.CanRestore); + Assert.True(state.TryHighlight(0x50000002u)); + Assert.True(state.BeginEnter(out var ready)); + Assert.Equal(0x50000002u, ready.CharacterId); + + state.ReturnToSelection(); + Assert.True(state.TryHighlight(0x50000001u)); + Assert.True(state.TryBeginRestore(out _)); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + Assert.True(state.SweepRestoreCorrelation()); + Assert.Equal( + RuntimeCharacterSelectionOperation.None, + state.Snapshot.Operation); + Assert.True(state.Snapshot.Buttons.CanRestore); } [Fact] From ff40656293739f9c70dfc9fcd4f3c1f884d9e1fe Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:03:43 +0200 Subject: [PATCH 3/3] fix(runtime): isolate Campaign LA7b delete state --- .../Session/RuntimeCharacterSelectionState.cs | 22 +++- .../Session/LiveSessionControllerTests.cs | 121 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index f88895bd..18bfbc93 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -466,7 +466,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _restoreResponseArmed = false; _restoreCorrelationStartedTimestamp = 0; _flagOnlyRestoreResponseAmbiguous = true; - _operation = RuntimeCharacterSelectionOperation.None; + if (!HasConfirmedDelete()) + _operation = RuntimeCharacterSelectionOperation.None; _revision++; } Publish( @@ -533,6 +534,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable { uint characterId; uint errorCode = 0u; + bool deleteInFlight; lock (_gate) { if (_disposed @@ -553,6 +555,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _restoreCorrelationStartedTimestamp = 0; characterId = response.Guid ?? _lastRestoreRequestedCharacterId; + deleteInFlight = HasConfirmedDelete(); if (response.IsOk && response.Guid is { } guid @@ -573,13 +576,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable string.CompareOrdinal(left.Name, right.Name)); _entries = StablePartitionGreyedToTail(_entries); } - _operation = RuntimeCharacterSelectionOperation.RestoreSucceeded; + if (!deleteInFlight) + { + _operation = + RuntimeCharacterSelectionOperation.RestoreSucceeded; + } _error = null; } else { errorCode = response.VerificationFlag; - _operation = RuntimeCharacterSelectionOperation.RestoreRejected; + if (!deleteInFlight) + { + _operation = + RuntimeCharacterSelectionOperation.RestoreRejected; + } _error = new RuntimeCharacterSelectionError( response.VerificationFlag, CharacterError.Code.Undefined, @@ -804,7 +815,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private bool HasDeleteModalOrRequest() => _pendingDeleteCharacterId != 0u - || _operation is RuntimeCharacterSelectionOperation.DeleteRequested + || HasConfirmedDelete(); + + private bool HasConfirmedDelete() => + _operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged; private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail( diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 2f0fe01d..02d8bee4 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -530,6 +530,127 @@ public sealed class LiveSessionControllerTests Assert.False(controller.IsInWorld); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck( + bool acknowledgeBeforeCompletion) + { + 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, 0x50000001u).Accepted); + Assert.True(controller.Restore(generation).Accepted); + Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.True(controller.RequestDelete(generation).Accepted); + Assert.True(controller.ConfirmDelete(generation).Accepted); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + + if (acknowledgeBeforeCompletion) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + controller.CharacterSelectionState.ApplyRestore( + new CharacterRestore.Parsed( + VerificationFlag: 1u, + Guid: 0x50000001u, + Name: "Restored", + SecondsGreyedOut: 0u)); + + Assert.Equal( + acknowledgeBeforeCompletion + ? RuntimeCharacterSelectionOperation.DeleteAcknowledged + : RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.True(controller.CharacterSelection.TryGet( + 0x50000001u, + out RuntimeCharacterSelectionEntry restored)); + Assert.False(restored.IsPendingDelete); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.ConfirmDelete(generation).Status); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (!acknowledgeBeforeCompletion) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteAcknowledged, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck( + bool acknowledgeBeforeTimeout) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var time = new ManualTimeProvider(); + var controller = new LiveSessionController(operations, time); + Assert.Equal( + LiveSessionStartStatus.AwaitingCharacterSelection, + controller.Start(LiveOptions(awaitSelection: true), host).Status); + RuntimeGenerationToken generation = controller.Generation; + + Assert.True(controller.Highlight(generation, 0x50000001u).Accepted); + Assert.True(controller.Restore(generation).Accepted); + Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.True(controller.RequestDelete(generation).Accepted); + Assert.True(controller.ConfirmDelete(generation).Accepted); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (acknowledgeBeforeTimeout) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + controller.Tick(); + + Assert.Equal( + acknowledgeBeforeTimeout + ? RuntimeCharacterSelectionOperation.DeleteAcknowledged + : RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.ConfirmDelete(generation).Status); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (!acknowledgeBeforeTimeout) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteAcknowledged, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + } + [Fact] public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop() {