feat(runtime): own character selection flow
This commit is contained in:
parent
6c4cd2bbc6
commit
0e82cbf700
20 changed files with 2396 additions and 33 deletions
|
|
@ -219,6 +219,9 @@ src/
|
||||||
generation + teardown
|
generation + teardown
|
||||||
Session/ -> J2 canonical session lifetime, ordered
|
Session/ -> J2 canonical session lifetime, ordered
|
||||||
inbound routing + retryable teardown
|
inbound routing + retryable teardown
|
||||||
|
RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world
|
||||||
|
roster/highlight/delete/restore/error owner;
|
||||||
|
borrowed view + ordered deltas + typed commands
|
||||||
Entities/
|
Entities/
|
||||||
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
|
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
|
||||||
RuntimeEntityRecord.cs -> presentation-free accepted entity state
|
RuntimeEntityRecord.cs -> presentation-free accepted entity state
|
||||||
|
|
|
||||||
|
|
@ -467,7 +467,7 @@ useful ordering seam, but its ownership status is **partial**.
|
||||||
| Area | Status | Current truth |
|
| Area | Status | Current truth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
|
| 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. |
|
| 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`). |
|
| 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`). |
|
| 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`). |
|
||||||
|
|
|
||||||
109
docs/research/2026-08-14-la7b-character-selection-runtime.md
Normal file
109
docs/research/2026-08-14-la7b-character-selection-runtime.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# LA7b character-selection Runtime evidence
|
||||||
|
|
||||||
|
Date: 2026-08-14
|
||||||
|
|
||||||
|
This note records the retail evidence and the presentation-independent state
|
||||||
|
and flow implemented by Campaign LA slice LA7b. The retained character screen
|
||||||
|
is deliberately deferred to LA8.
|
||||||
|
|
||||||
|
## Named-retail evidence
|
||||||
|
|
||||||
|
The implementation was derived from
|
||||||
|
`docs/research/named-retail/acclient_2013_pseudo_c.txt` before code was
|
||||||
|
written. The controlling functions are:
|
||||||
|
|
||||||
|
- `gmCharacterManagementUI::ResetPreviouslySelectedCharacterSlot`
|
||||||
|
(`0x004ebff0`): clears the persisted selected avatar, selected guid, list
|
||||||
|
index, and character-generation slot.
|
||||||
|
- `gmCharacterManagementUI::SelectCharacter` (`0x004ec160`): resolves the
|
||||||
|
clicked guid back to the `CharacterSet` slot and persists that exact slot.
|
||||||
|
- `gmCharacterManagementUI::UpdateButtons` (`0x004ec240`): a missing or
|
||||||
|
greyed selection disables Enter and Delete; a selected greyed character
|
||||||
|
hides Delete and shows Restore.
|
||||||
|
- `gmCharacterManagementUI::RebuildCharacterList` (`0x004ec3a0`): emits every
|
||||||
|
active `CharacterSet` entry, records the previous/slot selection, chooses
|
||||||
|
the first non-greyed entry as the ordinary fallback (the first row remains
|
||||||
|
the all-grey fallback), sorts names with `wcscmp`, and then moves greyed
|
||||||
|
rows to the tail without losing their identity.
|
||||||
|
- `gmCharacterManagementUI::MakeDeleteCharacterConfirmationDialog`
|
||||||
|
(`0x004ecca0`) and `CloseDeleteCharacterDialog` (`0x004ed4a0`): deletion is
|
||||||
|
modal, and confirmation sends the persisted selected avatar.
|
||||||
|
- `gmCharacterManagementUI::ListenToElementMessage` (`0x004ed5a0`): Restore
|
||||||
|
sends the selected guid, Enter is accepted from the button and list-row
|
||||||
|
activation, and both delete/restore open retail's wait dialog.
|
||||||
|
- `gmCharacterManagementUI::EnterGame` (`0x004ed440`): entry is allowed only
|
||||||
|
for a non-zero selected guid whose `GetGreyedOutFor(GetSlot(guid))` value is
|
||||||
|
zero.
|
||||||
|
- `CharacterSet::GetSlot` (`0x004fdf60`) and `GetGreyedOutFor`
|
||||||
|
(`0x004fdfa0`) prove that delete/enter retain the original wire slot even
|
||||||
|
after display sorting, and that any non-zero grey value is disabled.
|
||||||
|
- `CPlayerSystem::Handle_CharacterError` (`0x0055d5d0`) clears the ready and
|
||||||
|
awaiting-logon latches before forwarding the typed error. The downstream
|
||||||
|
`gmUIFlow::RecvNotice_CharacterError` (`0x0047a7c0`) bounds its real display
|
||||||
|
switch to enum values below `CHAR_ERROR_NUM_ERRORS`; the `0x19` member is a
|
||||||
|
count sentinel, not a message.
|
||||||
|
|
||||||
|
Equivalent state pseudocode:
|
||||||
|
|
||||||
|
```text
|
||||||
|
on roster:
|
||||||
|
remember selected guid
|
||||||
|
rows = every active CharacterSet entry, retaining original slot
|
||||||
|
fallback = first row, replaced by first non-greyed row in wire order
|
||||||
|
sort rows by ordinal/wcscmp name
|
||||||
|
stable-partition non-greyed before greyed
|
||||||
|
selected = previous guid if still present, otherwise fallback
|
||||||
|
|
||||||
|
buttons(selected):
|
||||||
|
none or greyed -> Enter disabled, Delete disabled
|
||||||
|
greyed -> Delete hidden, Restore visible/enabled
|
||||||
|
active -> Delete visible/enabled, Restore hidden
|
||||||
|
|
||||||
|
enter(selected):
|
||||||
|
require guid != 0 and grey == 0
|
||||||
|
use retained wire slot through the existing EnterWorld transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CharacterList.DeletedCharacters` array is not projected into this list:
|
||||||
|
retail rebuilds from the active `CharacterSet`; pending-delete identities are
|
||||||
|
the greyed entries in that active set. No entry is filtered merely because it
|
||||||
|
is greyed.
|
||||||
|
|
||||||
|
## Runtime ownership and flow
|
||||||
|
|
||||||
|
`GameRuntime` owns one `LiveSessionController`, which owns one
|
||||||
|
`RuntimeCharacterSelectionState` for the exact `WorldSession` generation.
|
||||||
|
The owner contains the canonical roster, wire slots, highlight, delete
|
||||||
|
confirmation, delete/restore progress, mapped error, revision, lifecycle, and
|
||||||
|
generation. App receives only the borrowed `IRuntimeCharacterSelectionView`;
|
||||||
|
all mutation uses synchronous generation-gated typed commands. Ordered deltas
|
||||||
|
use one monotonic sequence and preserve re-entrant publication order while
|
||||||
|
isolating observer failures.
|
||||||
|
|
||||||
|
A graphical launch with no explicit selector sets
|
||||||
|
`AwaitCharacterSelection`. Connect reports and adopts the roster, starts the
|
||||||
|
sole pre-world receive loop, and returns `AwaitingCharacterSelection` without
|
||||||
|
calling EnterWorld. A typed Enter command continues the same session and
|
||||||
|
generation through the established two-phase EnterWorld path. Explicit
|
||||||
|
graphical selectors and direct/headless callers retain the established
|
||||||
|
first-available fallback because the new flag defaults to false.
|
||||||
|
|
||||||
|
Immediate/headless entry keeps the original blocking receive/sweep pump until
|
||||||
|
ServerReady. Only a host that actually pauses at character selection starts
|
||||||
|
the asynchronous pre-world receiver. This preserves reliable-transport
|
||||||
|
resend/NAK timing and avoids two concurrent socket readers.
|
||||||
|
|
||||||
|
LA7a's wire contracts are routed as follows:
|
||||||
|
|
||||||
|
- delete `0xF655`: account plus retained active slot, LoginQueue;
|
||||||
|
- restore `0xF7D9`: selected guid, ControlQueue (AD-97 remains the recorded
|
||||||
|
guid-only adaptation);
|
||||||
|
- delete ack, restore `0xF643`, refreshed `CharacterList`, and
|
||||||
|
`CharacterError 0xF659`: ordered UIQueue input.
|
||||||
|
|
||||||
|
ACE can silently return from restore for an unknown guid. Restore is therefore
|
||||||
|
a fire-and-observe command: it installs no command gate and never waits for a
|
||||||
|
reply. A later matching response updates the same owner; a fresh roster,
|
||||||
|
entry attempt, reset, reconnect, or disposal disarms stale response
|
||||||
|
correlation. `CharacterError.NumErrors` is ignored without a revision or
|
||||||
|
delta and can never become presentation text.
|
||||||
|
|
@ -1139,7 +1139,9 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
d.Options.LivePort,
|
d.Options.LivePort,
|
||||||
d.Options.LiveUser ?? string.Empty,
|
d.Options.LiveUser ?? string.Empty,
|
||||||
d.Options.LivePass ?? string.Empty,
|
d.Options.LivePass ?? string.Empty,
|
||||||
d.Options.LiveCharacterSelector));
|
d.Options.LiveCharacterSelector,
|
||||||
|
AwaitCharacterSelection:
|
||||||
|
d.Options.LiveCharacterSelector is null));
|
||||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||||
|
|
||||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimeCharacterView Character => _runtime.Character;
|
public IRuntimeCharacterView Character => _runtime.Character;
|
||||||
public IRuntimeSocialView Social => _runtime.Social;
|
public IRuntimeSocialView Social => _runtime.Social;
|
||||||
public IRuntimeChatView Chat => _runtime.Chat;
|
public IRuntimeChatView Chat => _runtime.Chat;
|
||||||
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
|
_runtime.CharacterSelection;
|
||||||
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
||||||
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
||||||
public IRuntimeActionView Actions => _runtime.Actions;
|
public IRuntimeActionView Actions => _runtime.Actions;
|
||||||
|
|
@ -97,6 +99,10 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimePortalView Portal => _runtime.Portal;
|
public IRuntimePortalView Portal => _runtime.Portal;
|
||||||
|
|
||||||
public IRuntimeSessionCommands Session => _commands;
|
public IRuntimeSessionCommands Session => _commands;
|
||||||
|
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
|
||||||
|
_runtime.Session;
|
||||||
|
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
|
||||||
|
_runtime.Session;
|
||||||
public IRuntimeSelectionCommands Selection => _commands;
|
public IRuntimeSelectionCommands Selection => _commands;
|
||||||
public IRuntimeCombatCommands Combat => _commands;
|
public IRuntimeCombatCommands Combat => _commands;
|
||||||
public IRuntimeMagicCommands Magic => _commands;
|
public IRuntimeMagicCommands Magic => _commands;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport;
|
||||||
|
|
||||||
namespace AcDream.Core.Net;
|
namespace AcDream.Core.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EnterWorld was rejected by the server while the transport remains a valid
|
||||||
|
/// character-select session. Callers may surface <see cref="Error"/> and let
|
||||||
|
/// the user choose another character instead of tearing down the connection.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CharacterSelectionRejectedException(
|
||||||
|
CharacterError.Parsed error)
|
||||||
|
: InvalidOperationException(
|
||||||
|
$"The server rejected character entry with error 0x{error.RawErrorCode:X8}.")
|
||||||
|
{
|
||||||
|
public CharacterError.Parsed Error { get; } = error;
|
||||||
|
}
|
||||||
|
|
||||||
internal interface IWorldSessionTransport : IDisposable
|
internal interface IWorldSessionTransport : IDisposable
|
||||||
{
|
{
|
||||||
void Send(ReadOnlySpan<byte> datagram);
|
void Send(ReadOnlySpan<byte> datagram);
|
||||||
|
|
@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable
|
||||||
/// <summary>Raised every time the state machine transitions.</summary>
|
/// <summary>Raised every time the state machine transitions.</summary>
|
||||||
public event Action<State>? StateChanged;
|
public event Action<State>? StateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-world character-management replies. All are decoded on the same
|
||||||
|
/// caller thread and in the same fragment order as ordinary world events.
|
||||||
|
/// ACE routes these replies on UIQueue; the queue is consumed by the
|
||||||
|
/// transport before this typed boundary.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<CharacterList.Parsed>? CharacterListReceived;
|
||||||
|
public event Action? CharacterDeleteAcknowledged;
|
||||||
|
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
|
||||||
|
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
|
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
|
||||||
/// handler is registered here (by GameWindow / UI layer / chat
|
/// 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; }
|
public CharacterList.Parsed? Characters { get; private set; }
|
||||||
|
private CharacterError.Parsed? _lastCharacterSelectionError;
|
||||||
|
|
||||||
private readonly IWorldSessionTransport _net;
|
private readonly IWorldSessionTransport _net;
|
||||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||||
|
|
@ -1019,6 +1044,22 @@ public sealed class WorldSession : IDisposable
|
||||||
SweepTransport();
|
SweepTransport();
|
||||||
}
|
}
|
||||||
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
|
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the sole asynchronous receive loop while the session remains at
|
||||||
|
/// character selection. Graphical hosts call this only when they actually
|
||||||
|
/// pause before <see cref="EnterWorld"/>; immediate and headless entry keep
|
||||||
|
/// the original blocking handshake pump until ServerReady is accepted.
|
||||||
|
/// </summary>
|
||||||
|
public void StartCharacterSelectionReceive()
|
||||||
|
{
|
||||||
|
if (CurrentState != State.InCharacterSelect)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"character-selection receive requires InCharacterSelect state");
|
||||||
|
|
||||||
|
EnsureNetReceiveLoopStarted();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1045,14 +1086,56 @@ public sealed class WorldSession : IDisposable
|
||||||
// the blocking pump (campaign landmine #8): the EnterWorld
|
// the blocking pump (campaign landmine #8): the EnterWorld
|
||||||
// CreateObject flood — and any NAK it provokes — precedes the
|
// CreateObject flood — and any NAK it provokes — precedes the
|
||||||
// first Tick().
|
// first Tick().
|
||||||
bool serverReady = false;
|
_lastCharacterSelectionError = null;
|
||||||
while (DateTime.UtcNow < deadline && !serverReady)
|
bool serverReady;
|
||||||
|
if (_netReceiveTask is null)
|
||||||
{
|
{
|
||||||
var drained = PumpOnce(out var opcodes);
|
// Immediate/headless entry deliberately preserves the blocking
|
||||||
SweepTransport();
|
// transport pump. Besides matching the established handshake
|
||||||
if (!drained) continue;
|
// contract, Receive supplies the clock edge used by the reliable
|
||||||
foreach (var op in opcodes)
|
// transport's resend/NAK sweep on otherwise quiet connections.
|
||||||
if (op == 0xF7DFu) { serverReady = true; break; }
|
serverReady = false;
|
||||||
|
while (DateTime.UtcNow < deadline
|
||||||
|
&& !serverReady
|
||||||
|
&& _lastCharacterSelectionError is null)
|
||||||
|
{
|
||||||
|
bool drained = PumpOnce(out List<uint> opcodes);
|
||||||
|
SweepTransport();
|
||||||
|
if (!drained)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (uint opcode in opcodes)
|
||||||
|
{
|
||||||
|
if (opcode == 0xF7DFu)
|
||||||
|
{
|
||||||
|
serverReady = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TimeSpan remaining = deadline - DateTime.UtcNow;
|
||||||
|
serverReady = remaining > TimeSpan.Zero
|
||||||
|
&& WaitForCharacterLogOffConfirmation(
|
||||||
|
_inboundQueue.Reader,
|
||||||
|
remaining,
|
||||||
|
datagram =>
|
||||||
|
{
|
||||||
|
var opcodes = new List<uint>();
|
||||||
|
ProcessDatagram(datagram.Memory, opcodes);
|
||||||
|
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"); }
|
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.
|
// Hidden/pink-bubble login state.
|
||||||
Transition(State.InWorld);
|
Transition(State.InWorld);
|
||||||
|
|
||||||
// Phase A.3: start the background receive thread now that the
|
// A paused selector already owns the socket through the background
|
||||||
// handshake is complete and the session is fully established.
|
// receiver. Immediate/headless entry starts that same sole receiver
|
||||||
// During Connect() and EnterWorld(), PumpOnce() read directly
|
// only after its blocking ServerReady handshake has completed.
|
||||||
// from the socket (blocking). From here on, Tick() drains the
|
EnsureNetReceiveLoopStarted();
|
||||||
// channel instead.
|
|
||||||
_netReceiveTask = NetReceiveLoopAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureNetReceiveLoopStarted() =>
|
||||||
|
_netReceiveTask ??= NetReceiveLoopAsync();
|
||||||
|
|
||||||
internal readonly record struct EnterWorldSelection(
|
internal readonly record struct EnterWorldSelection(
|
||||||
CharacterList.Character Character,
|
CharacterList.Character Character,
|
||||||
byte[] EnterWorldBody);
|
byte[] EnterWorldBody);
|
||||||
|
|
@ -1139,8 +1223,9 @@ public sealed class WorldSession : IDisposable
|
||||||
ReturnInboundDatagram(datagram);
|
ReturnInboundDatagram(datagram);
|
||||||
}
|
}
|
||||||
processed++;
|
processed++;
|
||||||
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
|
// Bound ONLY in-world: immediate/headless handshakes use blocking
|
||||||
// (the async receive owner starts at Transition(State.InWorld)).
|
// 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
|
// 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
|
// 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
|
// 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)
|
if (!dispatchWorldEvents)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (op == CharacterList.Opcode && Characters is null)
|
if (op == CharacterList.Opcode)
|
||||||
{
|
{
|
||||||
try { Characters = CharacterList.Parse(body); }
|
CharacterList.Parsed parsed;
|
||||||
catch { /* malformed — ignore and keep draining */ }
|
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?"
|
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);
|
SendGameMessage(gameActionBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail CharacterDelete through the login/logon queue. The caller
|
||||||
|
/// supplies the selected entry's active CharacterSet slot, not its guid.
|
||||||
|
/// </summary>
|
||||||
|
public void SendDeleteCharacter(string accountName, int activeIndex)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(accountName);
|
||||||
|
if (activeIndex < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(activeIndex));
|
||||||
|
SendGameMessage(
|
||||||
|
CharacterDelete.BuildRequestBody(
|
||||||
|
accountName,
|
||||||
|
checked((uint)activeIndex)),
|
||||||
|
GameMessageGroup.LoginQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail CharacterRestore through the control queue. This is
|
||||||
|
/// deliberately non-blocking because ACE silently drops unknown guids.
|
||||||
|
/// </summary>
|
||||||
|
public void SendRestoreCharacter(uint characterId) =>
|
||||||
|
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
|
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
|
||||||
/// invokes this instead of writing to the wire. Lets unit tests verify
|
/// invokes this instead of writing to the wire. Lets unit tests verify
|
||||||
|
|
@ -2049,6 +2200,9 @@ public sealed class WorldSession : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal Action<byte[]>? GameActionCapture { get; set; }
|
internal Action<byte[]>? GameActionCapture { get; set; }
|
||||||
|
|
||||||
|
/// <summary>LA7b unit-test seam for queue-sensitive pre-world sends.</summary>
|
||||||
|
internal Action<byte[], GameMessageGroup>? GameMessageCapture { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase B.2: get and increment the game-action sequence counter.
|
/// Phase B.2: get and increment the game-action sequence counter.
|
||||||
/// Call once per outbound movement message; pass the returned value
|
/// 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)
|
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
|
// #260 probe: log the send BEFORE the sequence counters are consumed
|
||||||
// so the line carries the values this datagram will actually use. The
|
// so the line carries the values this datagram will actually use. The
|
||||||
// exception filter below logs a wire-write fault WITHOUT catching it
|
// exception filter below logs a wire-write fault WITHOUT catching it
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,8 @@ public sealed class GameRuntime
|
||||||
public IRuntimeCharacterView Character => CharacterOwner.View;
|
public IRuntimeCharacterView Character => CharacterOwner.View;
|
||||||
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
||||||
public IRuntimeChatView Chat => CommunicationOwner.View;
|
public IRuntimeChatView Chat => CommunicationOwner.View;
|
||||||
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
|
Session.CharacterSelection;
|
||||||
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
||||||
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus
|
||||||
/// exit-code mapping, not a failure.
|
/// exit-code mapping, not a failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ProbeComplete,
|
ProbeComplete,
|
||||||
|
AwaitingCharacterSelection,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeSessionStartResult(
|
public readonly record struct RuntimeSessionStartResult(
|
||||||
|
|
@ -381,6 +382,10 @@ public interface IGameRuntimeCommands
|
||||||
{
|
{
|
||||||
IRuntimeSessionCommands Session { get; }
|
IRuntimeSessionCommands Session { get; }
|
||||||
|
|
||||||
|
Session.IRuntimeCharacterSelectionCommands CharacterSelection =>
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"This command adapter does not project character selection.");
|
||||||
|
|
||||||
IRuntimeSelectionCommands Selection { get; }
|
IRuntimeSelectionCommands Selection { get; }
|
||||||
|
|
||||||
IRuntimeCombatCommands Combat { get; }
|
IRuntimeCombatCommands Combat { get; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Runtime.World;
|
using AcDream.Runtime.World;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.Runtime;
|
namespace AcDream.Runtime;
|
||||||
|
|
||||||
|
|
@ -275,6 +276,10 @@ public interface IGameRuntimeView
|
||||||
|
|
||||||
IRuntimeChatView Chat { get; }
|
IRuntimeChatView Chat { get; }
|
||||||
|
|
||||||
|
IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"This runtime view does not project character selection.");
|
||||||
|
|
||||||
IRuntimeFellowshipView Fellowship { get; }
|
IRuntimeFellowshipView Fellowship { get; }
|
||||||
|
|
||||||
IRuntimeAllegianceView Allegiance { get; }
|
IRuntimeAllegianceView Allegiance { get; }
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ public sealed class DirectGameRuntimeCommandAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
public IRuntimeSessionCommands Session => this;
|
public IRuntimeSessionCommands Session => this;
|
||||||
|
public IRuntimeCharacterSelectionCommands CharacterSelection =>
|
||||||
|
_runtime.Session;
|
||||||
public IRuntimeSelectionCommands Selection => this;
|
public IRuntimeSelectionCommands Selection => this;
|
||||||
public IRuntimeCombatCommands Combat => this;
|
public IRuntimeCombatCommands Combat => this;
|
||||||
public IRuntimeMagicCommands Magic => this;
|
public IRuntimeMagicCommands Magic => this;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions(
|
||||||
/// itself does not enforce that pairing — the headless config loader
|
/// itself does not enforce that pairing — the headless config loader
|
||||||
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
|
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool Probe = false);
|
bool Probe = false,
|
||||||
|
/// <summary>
|
||||||
|
/// Keep the connected transport in Runtime's pre-world selection state
|
||||||
|
/// instead of applying the legacy first-available fallback. Graphical
|
||||||
|
/// composition enables this only when no explicit selector was supplied;
|
||||||
|
/// direct/headless callers retain the existing default behavior.
|
||||||
|
/// </summary>
|
||||||
|
bool AwaitCharacterSelection = false);
|
||||||
|
|
||||||
public interface IRuntimeLiveSessionFramePhase
|
public interface IRuntimeLiveSessionFramePhase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
|
||||||
Deferred,
|
Deferred,
|
||||||
Failed,
|
Failed,
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// The account transport is connected and Runtime owns a live roster,
|
||||||
|
/// but EnterWorld has deliberately not run yet.
|
||||||
|
/// </summary>
|
||||||
|
AwaitingCharacterSelection,
|
||||||
|
/// <summary>
|
||||||
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
|
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
|
||||||
/// session connected, received (and reported) the character roster, and
|
/// session connected, received (and reported) the character roster, and
|
||||||
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
|
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
|
||||||
|
|
@ -167,7 +172,16 @@ public interface ILiveSessionOperations
|
||||||
WorldSession CreateSession(IPEndPoint endpoint);
|
WorldSession CreateSession(IPEndPoint endpoint);
|
||||||
void Connect(WorldSession session, string user, string password);
|
void Connect(WorldSession session, string user, string password);
|
||||||
CharacterList.Parsed? GetCharacters(WorldSession session);
|
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||||
|
void StartCharacterSelectionReceive(WorldSession session) =>
|
||||||
|
session.StartCharacterSelectionReceive();
|
||||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
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 Tick(WorldSession session);
|
||||||
void DisposeSession(WorldSession session);
|
void DisposeSession(WorldSession session);
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +217,9 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
||||||
|
|
||||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
||||||
|
|
||||||
|
public void StartCharacterSelectionReceive(WorldSession session) =>
|
||||||
|
session.StartCharacterSelectionReceive();
|
||||||
|
|
||||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||||
session.EnterWorld(activeCharacterIndex);
|
session.EnterWorld(activeCharacterIndex);
|
||||||
|
|
||||||
|
|
@ -219,8 +236,49 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LiveSessionController
|
public sealed class LiveSessionController
|
||||||
: IDisposable,
|
: IDisposable,
|
||||||
IRuntimeLiveSessionFramePhase
|
IRuntimeLiveSessionFramePhase,
|
||||||
|
IRuntimeCharacterSelectionCommands
|
||||||
{
|
{
|
||||||
|
private sealed class CharacterSelectionWireBinding : IDisposable
|
||||||
|
{
|
||||||
|
private WorldSession? _session;
|
||||||
|
private readonly Action<CharacterList.Parsed> _roster;
|
||||||
|
private readonly Action _delete;
|
||||||
|
private readonly Action<CharacterRestore.Parsed> _restore;
|
||||||
|
private readonly Action<CharacterError.Parsed> _error;
|
||||||
|
|
||||||
|
public CharacterSelectionWireBinding(
|
||||||
|
WorldSession session,
|
||||||
|
Action<CharacterList.Parsed> roster,
|
||||||
|
Action delete,
|
||||||
|
Action<CharacterRestore.Parsed> restore,
|
||||||
|
Action<CharacterError.Parsed> error)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_roster = roster;
|
||||||
|
_delete = delete;
|
||||||
|
_restore = restore;
|
||||||
|
_error = error;
|
||||||
|
session.CharacterListReceived += roster;
|
||||||
|
session.CharacterDeleteAcknowledged += delete;
|
||||||
|
session.CharacterRestoreReceived += restore;
|
||||||
|
session.CharacterErrorReceived += error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsDisposed => _session is null;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
WorldSession? session = Interlocked.Exchange(ref _session, null);
|
||||||
|
if (session is null)
|
||||||
|
return;
|
||||||
|
session.CharacterListReceived -= _roster;
|
||||||
|
session.CharacterDeleteAcknowledged -= _delete;
|
||||||
|
session.CharacterRestoreReceived -= _restore;
|
||||||
|
session.CharacterErrorReceived -= _error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class SessionScope(
|
private sealed class SessionScope(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
ILiveSessionLifecycleHost host,
|
ILiveSessionLifecycleHost host,
|
||||||
|
|
@ -232,6 +290,11 @@ public sealed class LiveSessionController
|
||||||
public ILiveSessionLifecycleHost Host { get; } = host;
|
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||||
public RuntimeGenerationToken Generation { get; } = generation;
|
public RuntimeGenerationToken Generation { get; } = generation;
|
||||||
public LiveSessionBinding? Binding { get; set; }
|
public LiveSessionBinding? Binding { get; set; }
|
||||||
|
public CharacterSelectionWireBinding? CharacterSelectionBinding
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
public bool HostAttached { get; set; }
|
public bool HostAttached { get; set; }
|
||||||
public RuntimeTeardownStage CompletedStages { get; private set; }
|
public RuntimeTeardownStage CompletedStages { get; private set; }
|
||||||
|
|
||||||
|
|
@ -242,13 +305,18 @@ public sealed class LiveSessionController
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Binding?.Dispose();
|
Binding?.Dispose();
|
||||||
|
CharacterSelectionBinding?.Dispose();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (Binding is null || Binding.CommandsDeactivated)
|
if (Binding is null || Binding.CommandsDeactivated)
|
||||||
CompletedStages |= RuntimeTeardownStage.CommandsInert;
|
CompletedStages |= RuntimeTeardownStage.CommandsInert;
|
||||||
if (Binding is null || Binding.EventsDetached)
|
if ((Binding is null || Binding.EventsDetached)
|
||||||
|
&& (CharacterSelectionBinding is null
|
||||||
|
|| CharacterSelectionBinding.IsDisposed))
|
||||||
|
{
|
||||||
CompletedStages |= RuntimeTeardownStage.InboundDetached;
|
CompletedStages |= RuntimeTeardownStage.InboundDetached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_teardownStage = 1;
|
_teardownStage = 1;
|
||||||
}
|
}
|
||||||
|
|
@ -315,8 +383,14 @@ public sealed class LiveSessionController
|
||||||
public LiveSessionController(ILiveSessionOperations operations)
|
public LiveSessionController(ILiveSessionOperations operations)
|
||||||
{
|
{
|
||||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||||
|
CharacterSelectionState = new RuntimeCharacterSelectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
|
||||||
|
|
||||||
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
|
CharacterSelectionState.View;
|
||||||
|
|
||||||
public WorldSession? CurrentSession
|
public WorldSession? CurrentSession
|
||||||
{
|
{
|
||||||
get { lock (_gate) return _scope?.Session; }
|
get { lock (_gate) return _scope?.Session; }
|
||||||
|
|
@ -405,6 +479,11 @@ public sealed class LiveSessionController
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
if (_inWorld)
|
if (_inWorld)
|
||||||
return ConnectedResult();
|
return ConnectedResult();
|
||||||
|
if (_scope is not null)
|
||||||
|
{
|
||||||
|
return new LiveSessionStartResult(
|
||||||
|
LiveSessionStartStatus.AwaitingCharacterSelection);
|
||||||
|
}
|
||||||
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,7 +571,7 @@ public sealed class LiveSessionController
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
if (!_inWorld || _scope is null || _operationDepth != 0)
|
if (_scope is null || _operationDepth != 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
RunTopLevel(() =>
|
RunTopLevel(() =>
|
||||||
|
|
@ -517,7 +596,8 @@ public sealed class LiveSessionController
|
||||||
// the protocol pump above and only when the scope/generation
|
// the protocol pump above and only when the scope/generation
|
||||||
// are still current — a reconnect that happened mid-tick
|
// are still current — a reconnect that happened mid-tick
|
||||||
// must not flush against a retired session.
|
// 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);
|
RuntimeGenerationToken resetGeneration = new(_generation);
|
||||||
ulong generation = ++_generation;
|
ulong generation = ++_generation;
|
||||||
|
RuntimeGenerationToken activeGeneration = new(generation);
|
||||||
|
CharacterSelectionState.Reset(activeGeneration);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DrainRetiredScope();
|
DrainRetiredScope();
|
||||||
|
|
@ -608,6 +690,8 @@ public sealed class LiveSessionController
|
||||||
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
|
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
||||||
|
|
||||||
|
CharacterSelectionState.Begin(activeGeneration);
|
||||||
|
|
||||||
SessionScope? scope = null;
|
SessionScope? scope = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -643,6 +727,9 @@ public sealed class LiveSessionController
|
||||||
_operations.Connect(session, options.User, options.Password);
|
_operations.Connect(session, options.User, options.Password);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
|
scope.CharacterSelectionBinding = BindCharacterSelection(
|
||||||
|
scope,
|
||||||
|
generation);
|
||||||
host.ReportConnected();
|
host.ReportConnected();
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
|
|
@ -650,7 +737,9 @@ public sealed class LiveSessionController
|
||||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||||
if (characters is not null)
|
if (characters is not null)
|
||||||
{
|
{
|
||||||
host.ReportRoster(BuildRosterReport(characters));
|
LiveSessionRosterReport roster = BuildRosterReport(characters);
|
||||||
|
CharacterSelectionState.ApplyRoster(roster);
|
||||||
|
host.ReportRoster(roster);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
}
|
}
|
||||||
|
|
@ -670,6 +759,19 @@ public sealed class LiveSessionController
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
|
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
|
if (characters is null
|
||||||
|| !TrySelectCharacter(
|
|| !TrySelectCharacter(
|
||||||
characters,
|
characters,
|
||||||
|
|
@ -686,6 +788,12 @@ public sealed class LiveSessionController
|
||||||
selected.Character.Id,
|
selected.Character.Id,
|
||||||
selected.Character.Name,
|
selected.Character.Name,
|
||||||
characters.AccountName);
|
characters.AccountName);
|
||||||
|
if (!CharacterSelectionState.TryHighlight(selection.CharacterId)
|
||||||
|
|| !CharacterSelectionState.BeginEnter(out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Runtime character selection rejected the validated active character.");
|
||||||
|
}
|
||||||
host.ApplySelectedCharacter(selection);
|
host.ApplySelectedCharacter(selection);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
|
|
@ -701,6 +809,7 @@ public sealed class LiveSessionController
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
_inWorld = true;
|
_inWorld = true;
|
||||||
_activeSelection = selection;
|
_activeSelection = selection;
|
||||||
|
CharacterSelectionState.CompleteEnter(selection.CharacterId);
|
||||||
host.ApplyEnteredWorld(selection);
|
host.ApplyEnteredWorld(selection);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
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()
|
private void StopCore()
|
||||||
{
|
{
|
||||||
// MUST-FIX 1: TS-71's logout-flush half — retail's
|
// MUST-FIX 1: TS-71's logout-flush half — retail's
|
||||||
|
|
@ -749,6 +1125,7 @@ public sealed class LiveSessionController
|
||||||
++_generation;
|
++_generation;
|
||||||
_inWorld = false;
|
_inWorld = false;
|
||||||
_activeSelection = null;
|
_activeSelection = null;
|
||||||
|
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
|
||||||
if (_scope is { } scope)
|
if (_scope is { } scope)
|
||||||
{
|
{
|
||||||
_scope = null;
|
_scope = null;
|
||||||
|
|
@ -889,6 +1266,7 @@ public sealed class LiveSessionController
|
||||||
private void DisposeCore()
|
private void DisposeCore()
|
||||||
{
|
{
|
||||||
StopCore();
|
StopCore();
|
||||||
|
CharacterSelectionState.Dispose();
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
RuntimeSessionStartStatus.Failed,
|
RuntimeSessionStartStatus.Failed,
|
||||||
LiveSessionStartStatus.ProbeComplete =>
|
LiveSessionStartStatus.ProbeComplete =>
|
||||||
RuntimeSessionStartStatus.ProbeComplete,
|
RuntimeSessionStartStatus.ProbeComplete,
|
||||||
|
LiveSessionStartStatus.AwaitingCharacterSelection =>
|
||||||
|
RuntimeSessionStartStatus.AwaitingCharacterSelection,
|
||||||
_ => throw new ArgumentOutOfRangeException(
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
nameof(result),
|
nameof(result),
|
||||||
result.Status,
|
result.Status,
|
||||||
|
|
|
||||||
991
src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs
Normal file
991
src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs
Normal file
|
|
@ -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)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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); <see cref=
|
||||||
|
/// "RuntimeCharacterSelectionEntry.ActiveIndex"/> retains the original wire
|
||||||
|
/// slot used by character delete and EnterWorld.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sole mutable owner of character-selection state. It contains no App or UI
|
||||||
|
/// types; presentation borrows <see cref="View"/> and issues generation-gated
|
||||||
|
/// commands through <see cref="LiveSessionController"/>.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<RuntimeCharacterSelectionDelta> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,30 @@ public sealed class SessionPlayerCompositionTests
|
||||||
"LiveSessionRuntimeFactory.cs")), StringComparison.Ordinal);
|
"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(
|
private sealed class RetryBinding(
|
||||||
string name,
|
string name,
|
||||||
List<string> calls,
|
List<string> calls,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ public sealed class RuntimeOptionsSessionConfigTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
|
public void AbsentCharacterSelectorRemainsNullForGraphicalSelectionFlow()
|
||||||
{
|
{
|
||||||
var config = new SessionConfiguration { Version = 1 };
|
var config = new SessionConfiguration { Version = 1 };
|
||||||
var session = new SessionDescriptor
|
var session = new SessionDescriptor
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,44 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
.ToArray());
|
.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]
|
[Fact]
|
||||||
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
||||||
{
|
{
|
||||||
|
|
@ -698,7 +736,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
private readonly IDisposable _combatModeBinding;
|
private readonly IDisposable _combatModeBinding;
|
||||||
private readonly GameRuntime _gameRuntime;
|
private readonly GameRuntime _gameRuntime;
|
||||||
|
|
||||||
public Harness()
|
public Harness(bool awaitCharacterSelection = false)
|
||||||
{
|
{
|
||||||
CombatAttackOperations = new CombatAttackOperationsSlot();
|
CombatAttackOperations = new CombatAttackOperationsSlot();
|
||||||
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
|
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
|
||||||
|
|
@ -758,7 +796,11 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
new SelectionTransport(() => _session?.IsInWorld == true),
|
new SelectionTransport(() => _session?.IsInWorld == true),
|
||||||
new NoopInteractionMovement());
|
new NoopInteractionMovement());
|
||||||
|
|
||||||
Host = CreateHost(_session, Commands, Identity);
|
Host = CreateHost(
|
||||||
|
_session,
|
||||||
|
Commands,
|
||||||
|
Identity,
|
||||||
|
awaitCharacterSelection);
|
||||||
Runtime = new CurrentGameRuntimeAdapter(
|
Runtime = new CurrentGameRuntimeAdapter(
|
||||||
_gameRuntime,
|
_gameRuntime,
|
||||||
Host,
|
Host,
|
||||||
|
|
@ -873,7 +915,8 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
private static LiveSessionHost CreateHost(
|
private static LiveSessionHost CreateHost(
|
||||||
LiveSessionController controller,
|
LiveSessionController controller,
|
||||||
RecordingCommandRouting commands,
|
RecordingCommandRouting commands,
|
||||||
LocalPlayerIdentityState identity)
|
LocalPlayerIdentityState identity,
|
||||||
|
bool awaitCharacterSelection = false)
|
||||||
{
|
{
|
||||||
Action noop = static () => { };
|
Action noop = static () => { };
|
||||||
var reset = new LiveSessionResetBindings
|
var reset = new LiveSessionResetBindings
|
||||||
|
|
@ -925,7 +968,8 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
9000,
|
9000,
|
||||||
"user",
|
"user",
|
||||||
"password"));
|
"password",
|
||||||
|
AwaitCharacterSelection: awaitCharacterSelection));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RuntimeOptions LiveOptions()
|
private static RuntimeOptions LiveOptions()
|
||||||
|
|
@ -1039,6 +1083,10 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
true,
|
true,
|
||||||
true);
|
true);
|
||||||
|
|
||||||
|
public void StartCharacterSelectionReceive(WorldSession session)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<byte> datagram) { }
|
||||||
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||||
|
public int Receive(
|
||||||
|
Span<byte> destination,
|
||||||
|
TimeSpan timeout,
|
||||||
|
out IPEndPoint? from)
|
||||||
|
{
|
||||||
|
from = null;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||||
|
Memory<byte> destination,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromCanceled<NetReceiveResult>(cancellationToken);
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterManagementSends_UseRetailQueuesAndExactBodies()
|
||||||
|
{
|
||||||
|
using var session = CreateSession();
|
||||||
|
var sent = new List<(byte[] Body, GameMessageGroup Queue)>();
|
||||||
|
session.GameMessageCapture =
|
||||||
|
(body, queue) => sent.Add((body, queue));
|
||||||
|
|
||||||
|
session.SendDeleteCharacter("Canonical", activeIndex: 3);
|
||||||
|
session.SendRestoreCharacter(0x50000001u);
|
||||||
|
|
||||||
|
Assert.Collection(
|
||||||
|
sent,
|
||||||
|
delete =>
|
||||||
|
{
|
||||||
|
Assert.Equal(GameMessageGroup.LoginQueue, delete.Queue);
|
||||||
|
Assert.Equal(
|
||||||
|
CharacterDelete.BuildRequestBody("Canonical", 3u),
|
||||||
|
delete.Body);
|
||||||
|
},
|
||||||
|
restore =>
|
||||||
|
{
|
||||||
|
Assert.Equal(GameMessageGroup.ControlQueue, restore.Queue);
|
||||||
|
Assert.Equal(
|
||||||
|
CharacterRestore.BuildRequestBody(0x50000001u),
|
||||||
|
restore.Body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters()
|
||||||
|
{
|
||||||
|
using var session = CreateSession();
|
||||||
|
var events = new List<string>();
|
||||||
|
session.CharacterListReceived += roster =>
|
||||||
|
events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");
|
||||||
|
session.CharacterDeleteAcknowledged += () => events.Add("delete");
|
||||||
|
session.CharacterRestoreReceived += restore =>
|
||||||
|
events.Add($"restore:{restore.Guid:X8}");
|
||||||
|
session.CharacterErrorReceived += error =>
|
||||||
|
events.Add($"error:{error.RawErrorCode:X}");
|
||||||
|
|
||||||
|
byte[] packet = BuildPacket(
|
||||||
|
BuildRoster(secondsGreyedOut: 0u),
|
||||||
|
BitConverter.GetBytes(CharacterDelete.Opcode),
|
||||||
|
BuildRestoreResponse(),
|
||||||
|
BuildCharacterError(CharacterError.Code.Delete),
|
||||||
|
BuildRoster(secondsGreyedOut: 1u));
|
||||||
|
InvokeProcessDatagram(session, packet);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"roster:0",
|
||||||
|
"delete",
|
||||||
|
"restore:50000001",
|
||||||
|
"error:6",
|
||||||
|
"roster:1",
|
||||||
|
],
|
||||||
|
events);
|
||||||
|
CharacterList.Character current =
|
||||||
|
Assert.Single(session.Characters!.Characters);
|
||||||
|
Assert.Equal(1u, current.SecondsGreyedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<byte>(datagram), null, true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,10 +57,13 @@ public sealed class LiveSessionControllerTests
|
||||||
public bool ThrowOnCreate { get; set; }
|
public bool ThrowOnCreate { get; set; }
|
||||||
public bool ThrowOnCharacters { get; set; }
|
public bool ThrowOnCharacters { get; set; }
|
||||||
public bool ThrowOnEnterWorld { get; set; }
|
public bool ThrowOnEnterWorld { get; set; }
|
||||||
|
public CharacterError.Parsed? EnterWorldRejection { get; set; }
|
||||||
public bool ThrowOnTick { get; set; }
|
public bool ThrowOnTick { get; set; }
|
||||||
public bool FailDisposeOnce { get; set; }
|
public bool FailDisposeOnce { get; set; }
|
||||||
public List<WorldSession> Sessions { get; } = [];
|
public List<WorldSession> Sessions { get; } = [];
|
||||||
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
|
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
|
||||||
|
public List<(string Account, int ActiveIndex)> DeleteRequests { get; } = [];
|
||||||
|
public List<uint> RestoreRequests { get; } = [];
|
||||||
public int EnterWorldCount { get; private set; }
|
public int EnterWorldCount { get; private set; }
|
||||||
public int TickCount { get; private set; }
|
public int TickCount { get; private set; }
|
||||||
|
|
||||||
|
|
@ -98,15 +101,35 @@ public sealed class LiveSessionControllerTests
|
||||||
return Characters;
|
return Characters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void StartCharacterSelectionReceive(WorldSession session) =>
|
||||||
|
calls.Add("start-character-selection-receive");
|
||||||
|
|
||||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||||
{
|
{
|
||||||
calls.Add($"enter:{activeCharacterIndex}");
|
calls.Add($"enter:{activeCharacterIndex}");
|
||||||
EnterWorldCount++;
|
EnterWorldCount++;
|
||||||
OnEnterWorld?.Invoke();
|
OnEnterWorld?.Invoke();
|
||||||
|
if (EnterWorldRejection is { } rejection)
|
||||||
|
throw new CharacterSelectionRejectedException(rejection);
|
||||||
if (ThrowOnEnterWorld)
|
if (ThrowOnEnterWorld)
|
||||||
throw new InvalidOperationException("enter failure");
|
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)
|
public void Tick(WorldSession session)
|
||||||
{
|
{
|
||||||
calls.Add("tick");
|
calls.Add("tick");
|
||||||
|
|
@ -340,6 +363,179 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
|
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_GraphicalNoSelectorStopsBeforeEnterWorldThenTypedEnterContinuesSameScope()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
|
||||||
|
LiveSessionStartResult result = controller.Start(
|
||||||
|
LiveOptions(awaitSelection: true),
|
||||||
|
host);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
LiveSessionStartStatus.AwaitingCharacterSelection,
|
||||||
|
result.Status);
|
||||||
|
Assert.False(controller.IsInWorld);
|
||||||
|
Assert.NotNull(controller.CurrentSession);
|
||||||
|
Assert.Equal(0, operations.EnterWorldCount);
|
||||||
|
Assert.False(host.CommandBuses[0].Active);
|
||||||
|
RuntimeGenerationToken generation = controller.Generation;
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||||||
|
controller.CharacterSelection.Snapshot.Lifecycle);
|
||||||
|
|
||||||
|
// Greyed entries remain highlightable but cannot enter.
|
||||||
|
Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCommandStatus.Rejected,
|
||||||
|
controller.Enter(generation).Status);
|
||||||
|
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
|
||||||
|
Assert.True(controller.Enter(generation).Accepted);
|
||||||
|
|
||||||
|
Assert.True(controller.IsInWorld);
|
||||||
|
Assert.Equal(1, operations.EnterWorldCount);
|
||||||
|
Assert.Contains("enter:1", calls);
|
||||||
|
Assert.True(host.CommandBuses[0].Active);
|
||||||
|
Assert.Same(operations.Sessions[0], controller.CurrentSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_ExplicitSelectorStillEntersWhenGraphicalPauseFlagIsSet()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
|
||||||
|
LiveSessionStartResult result = controller.Start(
|
||||||
|
LiveOptions(
|
||||||
|
selector: new LiveSessionCharacterSelector(
|
||||||
|
CharacterId: 0x50000002u),
|
||||||
|
awaitSelection: true),
|
||||||
|
host);
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||||
|
Assert.True(controller.IsInWorld);
|
||||||
|
Assert.Equal(1, operations.EnterWorldCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnterRejection_SurfacesErrorAndKeepsExactPreWorldScopeRetryable()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls)
|
||||||
|
{
|
||||||
|
EnterWorldRejection = new CharacterError.Parsed(
|
||||||
|
(uint)CharacterError.Code.EnterGameCharacterLocked),
|
||||||
|
};
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
Assert.Equal(
|
||||||
|
LiveSessionStartStatus.AwaitingCharacterSelection,
|
||||||
|
controller.Start(LiveOptions(awaitSelection: true), host).Status);
|
||||||
|
RuntimeGenerationToken generation = controller.Generation;
|
||||||
|
WorldSession session = controller.CurrentSession!;
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCommandStatus.Rejected,
|
||||||
|
controller.Enter(generation).Status);
|
||||||
|
Assert.Same(session, controller.CurrentSession);
|
||||||
|
Assert.Equal(generation, controller.Generation);
|
||||||
|
Assert.False(controller.IsInWorld);
|
||||||
|
RuntimeCharacterSelectionSnapshot rejected =
|
||||||
|
controller.CharacterSelection.Snapshot;
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||||||
|
rejected.Lifecycle);
|
||||||
|
Assert.Equal(
|
||||||
|
CharacterError.Code.EnterGameCharacterLocked,
|
||||||
|
rejected.Error!.Value.Code);
|
||||||
|
|
||||||
|
operations.EnterWorldRejection = null;
|
||||||
|
Assert.True(controller.Enter(generation).Accepted);
|
||||||
|
Assert.True(controller.IsInWorld);
|
||||||
|
Assert.Same(session, controller.CurrentSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CharacterCommands_UseWireSlotAndRestoreNeverWaitsForReply()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var 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<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
controller.Start(LiveOptions(awaitSelection: true), host);
|
||||||
|
RuntimeGenerationToken first = controller.Generation;
|
||||||
|
|
||||||
|
LiveSessionStartResult reconnect = controller.Reconnect(
|
||||||
|
LiveOptions(awaitSelection: true),
|
||||||
|
host);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
LiveSessionStartStatus.AwaitingCharacterSelection,
|
||||||
|
reconnect.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCommandStatus.StaleGeneration,
|
||||||
|
controller.Highlight(first, 0x50000002u).Status);
|
||||||
|
RuntimeGenerationToken second = controller.Generation;
|
||||||
|
Assert.True(controller.Highlight(second, 0x50000002u).Accepted);
|
||||||
|
controller.Stop();
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCommandStatus.StaleGeneration,
|
||||||
|
controller.Enter(second).Status);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCharacterSelectionLifecycle.Inactive,
|
||||||
|
controller.CharacterSelection.Snapshot.Lifecycle);
|
||||||
|
Assert.Equal(0, controller.CharacterSelection.Snapshot.RosterCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
||||||
{
|
{
|
||||||
|
|
@ -1232,7 +1428,8 @@ public sealed class LiveSessionControllerTests
|
||||||
bool live = true,
|
bool live = true,
|
||||||
string? user = "user",
|
string? user = "user",
|
||||||
LiveSessionCharacterSelector? selector = null,
|
LiveSessionCharacterSelector? selector = null,
|
||||||
bool probe = false) =>
|
bool probe = false,
|
||||||
|
bool awaitSelection = false) =>
|
||||||
new(
|
new(
|
||||||
live,
|
live,
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|
@ -1240,7 +1437,8 @@ public sealed class LiveSessionControllerTests
|
||||||
user ?? string.Empty,
|
user ?? string.Empty,
|
||||||
"password",
|
"password",
|
||||||
selector,
|
selector,
|
||||||
probe);
|
probe,
|
||||||
|
awaitSelection);
|
||||||
|
|
||||||
private static CharacterList.Parsed AvailableCharacters() => new(
|
private static CharacterList.Parsed AvailableCharacters() => new(
|
||||||
0u,
|
0u,
|
||||||
|
|
|
||||||
|
|
@ -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<RuntimeCharacterSelectionEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
public void Visit(in RuntimeCharacterSelectionEntry character) =>
|
||||||
|
Entries.Add(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Observer(
|
||||||
|
Action<RuntimeCharacterSelectionDelta> onDelta)
|
||||||
|
: IRuntimeCharacterSelectionObserver
|
||||||
|
{
|
||||||
|
public void OnCharacterSelectionChanged(
|
||||||
|
in RuntimeCharacterSelectionDelta delta) =>
|
||||||
|
onDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<RuntimeCharacterSelectionDelta>();
|
||||||
|
using IDisposable subscription = state.View.Subscribe(
|
||||||
|
new Observer(deltas.Add));
|
||||||
|
|
||||||
|
state.ApplyError(new CharacterError.Parsed(
|
||||||
|
(uint)CharacterError.Code.Delete));
|
||||||
|
RuntimeCharacterSelectionError known =
|
||||||
|
Assert.IsType<RuntimeCharacterSelectionError>(state.Snapshot.Error);
|
||||||
|
Assert.Equal(CharacterError.Code.Delete, known.Code);
|
||||||
|
Assert.Contains("delete", known.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
Assert.True(state.Cancel());
|
||||||
|
state.ApplyError(new CharacterError.Parsed(0xDEADBEEFu));
|
||||||
|
RuntimeCharacterSelectionError unknown =
|
||||||
|
Assert.IsType<RuntimeCharacterSelectionError>(state.Snapshot.Error);
|
||||||
|
Assert.Contains("0xDEADBEEF", unknown.Message, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
long revision = state.Snapshot.Revision;
|
||||||
|
int count = deltas.Count;
|
||||||
|
state.ApplyError(new CharacterError.Parsed(
|
||||||
|
(uint)CharacterError.Code.NumErrors));
|
||||||
|
Assert.Equal(revision, state.Snapshot.Revision);
|
||||||
|
Assert.Equal(count, deltas.Count);
|
||||||
|
Assert.Equal(0xDEADBEEFu, state.Snapshot.Error!.Value.RawCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deltas_AreMonotonicAndReentrantMutationsStayOrdered()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeCharacterSelectionState();
|
||||||
|
state.Begin(new RuntimeGenerationToken(9));
|
||||||
|
var deltas = new List<RuntimeCharacterSelectionDelta>();
|
||||||
|
var observer = new Observer(delta =>
|
||||||
|
{
|
||||||
|
deltas.Add(delta);
|
||||||
|
if (delta.Kind == RuntimeCharacterSelectionDeltaKind.RosterChanged)
|
||||||
|
Assert.True(state.TryHighlight(0x50000002u));
|
||||||
|
});
|
||||||
|
using IDisposable subscription = state.View.Subscribe(observer);
|
||||||
|
|
||||||
|
state.ApplyRoster(Roster(
|
||||||
|
new(0x50000001u, "Ready", 0u),
|
||||||
|
new(0x50000002u, "Grey", 1u)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
RuntimeCharacterSelectionDeltaKind.RosterChanged,
|
||||||
|
RuntimeCharacterSelectionDeltaKind.HighlightChanged,
|
||||||
|
],
|
||||||
|
deltas.Select(delta => delta.Kind));
|
||||||
|
Assert.True(deltas[0].Sequence < deltas[1].Sequence);
|
||||||
|
Assert.All(
|
||||||
|
deltas,
|
||||||
|
delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveSessionRosterReport Roster(
|
||||||
|
params LiveSessionRosterEntry[] entries) =>
|
||||||
|
new("Canonical", 11, entries);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue