feat(runtime): own character selection flow
This commit is contained in:
parent
6c4cd2bbc6
commit
0e82cbf700
20 changed files with 2396 additions and 33 deletions
|
|
@ -1139,7 +1139,9 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Options.LivePort,
|
||||
d.Options.LiveUser ?? string.Empty,
|
||||
d.Options.LivePass ?? string.Empty,
|
||||
d.Options.LiveCharacterSelector));
|
||||
d.Options.LiveCharacterSelector,
|
||||
AwaitCharacterSelection:
|
||||
d.Options.LiveCharacterSelector is null));
|
||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||
|
||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
public IRuntimeCharacterView Character => _runtime.Character;
|
||||
public IRuntimeSocialView Social => _runtime.Social;
|
||||
public IRuntimeChatView Chat => _runtime.Chat;
|
||||
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||
_runtime.CharacterSelection;
|
||||
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
||||
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
||||
public IRuntimeActionView Actions => _runtime.Actions;
|
||||
|
|
@ -97,6 +99,10 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
public IRuntimePortalView Portal => _runtime.Portal;
|
||||
|
||||
public IRuntimeSessionCommands Session => _commands;
|
||||
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
|
||||
_runtime.Session;
|
||||
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
|
||||
_runtime.Session;
|
||||
public IRuntimeSelectionCommands Selection => _commands;
|
||||
public IRuntimeCombatCommands Combat => _commands;
|
||||
public IRuntimeMagicCommands Magic => _commands;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport;
|
|||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// EnterWorld was rejected by the server while the transport remains a valid
|
||||
/// character-select session. Callers may surface <see cref="Error"/> and let
|
||||
/// the user choose another character instead of tearing down the connection.
|
||||
/// </summary>
|
||||
public sealed class CharacterSelectionRejectedException(
|
||||
CharacterError.Parsed error)
|
||||
: InvalidOperationException(
|
||||
$"The server rejected character entry with error 0x{error.RawErrorCode:X8}.")
|
||||
{
|
||||
public CharacterError.Parsed Error { get; } = error;
|
||||
}
|
||||
|
||||
internal interface IWorldSessionTransport : IDisposable
|
||||
{
|
||||
void Send(ReadOnlySpan<byte> datagram);
|
||||
|
|
@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>Raised every time the state machine transitions.</summary>
|
||||
public event Action<State>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-world character-management replies. All are decoded on the same
|
||||
/// caller thread and in the same fragment order as ordinary world events.
|
||||
/// ACE routes these replies on UIQueue; the queue is consumed by the
|
||||
/// transport before this typed boundary.
|
||||
/// </summary>
|
||||
public event Action<CharacterList.Parsed>? CharacterListReceived;
|
||||
public event Action? CharacterDeleteAcknowledged;
|
||||
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
|
||||
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
|
||||
/// handler is registered here (by GameWindow / UI layer / chat
|
||||
|
|
@ -667,6 +691,7 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
|
||||
public CharacterList.Parsed? Characters { get; private set; }
|
||||
private CharacterError.Parsed? _lastCharacterSelectionError;
|
||||
|
||||
private readonly IWorldSessionTransport _net;
|
||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||
|
|
@ -1019,6 +1044,22 @@ public sealed class WorldSession : IDisposable
|
|||
SweepTransport();
|
||||
}
|
||||
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the sole asynchronous receive loop while the session remains at
|
||||
/// character selection. Graphical hosts call this only when they actually
|
||||
/// pause before <see cref="EnterWorld"/>; immediate and headless entry keep
|
||||
/// the original blocking handshake pump until ServerReady is accepted.
|
||||
/// </summary>
|
||||
public void StartCharacterSelectionReceive()
|
||||
{
|
||||
if (CurrentState != State.InCharacterSelect)
|
||||
throw new InvalidOperationException(
|
||||
"character-selection receive requires InCharacterSelect state");
|
||||
|
||||
EnsureNetReceiveLoopStarted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1045,14 +1086,56 @@ public sealed class WorldSession : IDisposable
|
|||
// the blocking pump (campaign landmine #8): the EnterWorld
|
||||
// CreateObject flood — and any NAK it provokes — precedes the
|
||||
// first Tick().
|
||||
bool serverReady = false;
|
||||
while (DateTime.UtcNow < deadline && !serverReady)
|
||||
_lastCharacterSelectionError = null;
|
||||
bool serverReady;
|
||||
if (_netReceiveTask is null)
|
||||
{
|
||||
var drained = PumpOnce(out var opcodes);
|
||||
SweepTransport();
|
||||
if (!drained) continue;
|
||||
foreach (var op in opcodes)
|
||||
if (op == 0xF7DFu) { serverReady = true; break; }
|
||||
// Immediate/headless entry deliberately preserves the blocking
|
||||
// transport pump. Besides matching the established handshake
|
||||
// contract, Receive supplies the clock edge used by the reliable
|
||||
// transport's resend/NAK sweep on otherwise quiet connections.
|
||||
serverReady = false;
|
||||
while (DateTime.UtcNow < deadline
|
||||
&& !serverReady
|
||||
&& _lastCharacterSelectionError is null)
|
||||
{
|
||||
bool drained = PumpOnce(out List<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"); }
|
||||
|
||||
|
|
@ -1067,14 +1150,15 @@ public sealed class WorldSession : IDisposable
|
|||
// Hidden/pink-bubble login state.
|
||||
Transition(State.InWorld);
|
||||
|
||||
// Phase A.3: start the background receive thread now that the
|
||||
// handshake is complete and the session is fully established.
|
||||
// During Connect() and EnterWorld(), PumpOnce() read directly
|
||||
// from the socket (blocking). From here on, Tick() drains the
|
||||
// channel instead.
|
||||
_netReceiveTask = NetReceiveLoopAsync();
|
||||
// A paused selector already owns the socket through the background
|
||||
// receiver. Immediate/headless entry starts that same sole receiver
|
||||
// only after its blocking ServerReady handshake has completed.
|
||||
EnsureNetReceiveLoopStarted();
|
||||
}
|
||||
|
||||
private void EnsureNetReceiveLoopStarted() =>
|
||||
_netReceiveTask ??= NetReceiveLoopAsync();
|
||||
|
||||
internal readonly record struct EnterWorldSelection(
|
||||
CharacterList.Character Character,
|
||||
byte[] EnterWorldBody);
|
||||
|
|
@ -1139,8 +1223,9 @@ public sealed class WorldSession : IDisposable
|
|||
ReturnInboundDatagram(datagram);
|
||||
}
|
||||
processed++;
|
||||
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
|
||||
// (the async receive owner starts at Transition(State.InWorld)).
|
||||
// Bound ONLY in-world: immediate/headless handshakes use blocking
|
||||
// PumpOnce, while a deliberately paused selector uses Tick without
|
||||
// an in-world flood budget so management replies drain promptly.
|
||||
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
|
||||
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
|
||||
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
|
||||
|
|
@ -1687,10 +1772,53 @@ public sealed class WorldSession : IDisposable
|
|||
if (!dispatchWorldEvents)
|
||||
continue;
|
||||
|
||||
if (op == CharacterList.Opcode && Characters is null)
|
||||
if (op == CharacterList.Opcode)
|
||||
{
|
||||
try { Characters = CharacterList.Parse(body); }
|
||||
catch { /* malformed — ignore and keep draining */ }
|
||||
CharacterList.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharacterList.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed management messages do not poison the
|
||||
// remaining ordered UIQueue fragments.
|
||||
continue;
|
||||
}
|
||||
Characters = parsed;
|
||||
CharacterListReceived?.Invoke(parsed);
|
||||
}
|
||||
else if (op == CharacterDelete.Opcode
|
||||
&& CharacterDelete.IsAcknowledgement(body))
|
||||
{
|
||||
CharacterDeleteAcknowledged?.Invoke();
|
||||
}
|
||||
else if (op == CharacterRestore.ResponseOpcode)
|
||||
{
|
||||
CharacterRestore.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharacterRestore.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CharacterRestoreReceived?.Invoke(parsed);
|
||||
}
|
||||
else if (op == CharacterError.Opcode)
|
||||
{
|
||||
CharacterError.Parsed parsed;
|
||||
try
|
||||
{
|
||||
parsed = CharacterError.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
_lastCharacterSelectionError = parsed;
|
||||
CharacterErrorReceived?.Invoke(parsed);
|
||||
}
|
||||
else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
|
||||
{
|
||||
|
|
@ -2040,6 +2168,29 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameMessage(gameActionBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail CharacterDelete through the login/logon queue. The caller
|
||||
/// supplies the selected entry's active CharacterSet slot, not its guid.
|
||||
/// </summary>
|
||||
public void SendDeleteCharacter(string accountName, int activeIndex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(accountName);
|
||||
if (activeIndex < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(activeIndex));
|
||||
SendGameMessage(
|
||||
CharacterDelete.BuildRequestBody(
|
||||
accountName,
|
||||
checked((uint)activeIndex)),
|
||||
GameMessageGroup.LoginQueue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail CharacterRestore through the control queue. This is
|
||||
/// deliberately non-blocking because ACE silently drops unknown guids.
|
||||
/// </summary>
|
||||
public void SendRestoreCharacter(uint characterId) =>
|
||||
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
|
||||
/// invokes this instead of writing to the wire. Lets unit tests verify
|
||||
|
|
@ -2049,6 +2200,9 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
internal Action<byte[]>? GameActionCapture { get; set; }
|
||||
|
||||
/// <summary>LA7b unit-test seam for queue-sensitive pre-world sends.</summary>
|
||||
internal Action<byte[], GameMessageGroup>? GameMessageCapture { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase B.2: get and increment the game-action sequence counter.
|
||||
/// Call once per outbound movement message; pass the returned value
|
||||
|
|
@ -2895,6 +3049,11 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
|
||||
{
|
||||
if (GameMessageCapture is { } capture)
|
||||
{
|
||||
capture(gameMessageBody, queue);
|
||||
return;
|
||||
}
|
||||
// #260 probe: log the send BEFORE the sequence counters are consumed
|
||||
// so the line carries the values this datagram will actually use. The
|
||||
// exception filter below logs a wire-write fault WITHOUT catching it
|
||||
|
|
|
|||
|
|
@ -527,6 +527,8 @@ public sealed class GameRuntime
|
|||
public IRuntimeCharacterView Character => CharacterOwner.View;
|
||||
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
||||
public IRuntimeChatView Chat => CommunicationOwner.View;
|
||||
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||
Session.CharacterSelection;
|
||||
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
||||
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus
|
|||
/// exit-code mapping, not a failure.
|
||||
/// </summary>
|
||||
ProbeComplete,
|
||||
AwaitingCharacterSelection,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeSessionStartResult(
|
||||
|
|
@ -381,6 +382,10 @@ public interface IGameRuntimeCommands
|
|||
{
|
||||
IRuntimeSessionCommands Session { get; }
|
||||
|
||||
Session.IRuntimeCharacterSelectionCommands CharacterSelection =>
|
||||
throw new NotSupportedException(
|
||||
"This command adapter does not project character selection.");
|
||||
|
||||
IRuntimeSelectionCommands Selection { get; }
|
||||
|
||||
IRuntimeCombatCommands Combat { get; }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.World;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime;
|
||||
|
||||
|
|
@ -275,6 +276,10 @@ public interface IGameRuntimeView
|
|||
|
||||
IRuntimeChatView Chat { get; }
|
||||
|
||||
IRuntimeCharacterSelectionView CharacterSelection =>
|
||||
throw new NotSupportedException(
|
||||
"This runtime view does not project character selection.");
|
||||
|
||||
IRuntimeFellowshipView Fellowship { get; }
|
||||
|
||||
IRuntimeAllegianceView Allegiance { get; }
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
}
|
||||
|
||||
public IRuntimeSessionCommands Session => this;
|
||||
public IRuntimeCharacterSelectionCommands CharacterSelection =>
|
||||
_runtime.Session;
|
||||
public IRuntimeSelectionCommands Selection => this;
|
||||
public IRuntimeCombatCommands Combat => this;
|
||||
public IRuntimeMagicCommands Magic => this;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions(
|
|||
/// itself does not enforce that pairing — the headless config loader
|
||||
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
|
||||
/// </summary>
|
||||
bool Probe = false);
|
||||
bool Probe = false,
|
||||
/// <summary>
|
||||
/// Keep the connected transport in Runtime's pre-world selection state
|
||||
/// instead of applying the legacy first-available fallback. Graphical
|
||||
/// composition enables this only when no explicit selector was supplied;
|
||||
/// direct/headless callers retain the existing default behavior.
|
||||
/// </summary>
|
||||
bool AwaitCharacterSelection = false);
|
||||
|
||||
public interface IRuntimeLiveSessionFramePhase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
|
|||
Deferred,
|
||||
Failed,
|
||||
/// <summary>
|
||||
/// The account transport is connected and Runtime owns a live roster,
|
||||
/// but EnterWorld has deliberately not run yet.
|
||||
/// </summary>
|
||||
AwaitingCharacterSelection,
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
|
||||
/// session connected, received (and reported) the character roster, and
|
||||
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
|
||||
|
|
@ -167,7 +172,16 @@ public interface ILiveSessionOperations
|
|||
WorldSession CreateSession(IPEndPoint endpoint);
|
||||
void Connect(WorldSession session, string user, string password);
|
||||
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||
void StartCharacterSelectionReceive(WorldSession session) =>
|
||||
session.StartCharacterSelectionReceive();
|
||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||
void DeleteCharacter(
|
||||
WorldSession session,
|
||||
string accountName,
|
||||
int activeCharacterIndex) =>
|
||||
session.SendDeleteCharacter(accountName, activeCharacterIndex);
|
||||
void RestoreCharacter(WorldSession session, uint characterId) =>
|
||||
session.SendRestoreCharacter(characterId);
|
||||
void Tick(WorldSession session);
|
||||
void DisposeSession(WorldSession session);
|
||||
}
|
||||
|
|
@ -203,6 +217,9 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
|||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
||||
|
||||
public void StartCharacterSelectionReceive(WorldSession session) =>
|
||||
session.StartCharacterSelectionReceive();
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
session.EnterWorld(activeCharacterIndex);
|
||||
|
||||
|
|
@ -219,8 +236,49 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
|||
/// </summary>
|
||||
public sealed class LiveSessionController
|
||||
: IDisposable,
|
||||
IRuntimeLiveSessionFramePhase
|
||||
IRuntimeLiveSessionFramePhase,
|
||||
IRuntimeCharacterSelectionCommands
|
||||
{
|
||||
private sealed class CharacterSelectionWireBinding : IDisposable
|
||||
{
|
||||
private WorldSession? _session;
|
||||
private readonly Action<CharacterList.Parsed> _roster;
|
||||
private readonly Action _delete;
|
||||
private readonly Action<CharacterRestore.Parsed> _restore;
|
||||
private readonly Action<CharacterError.Parsed> _error;
|
||||
|
||||
public CharacterSelectionWireBinding(
|
||||
WorldSession session,
|
||||
Action<CharacterList.Parsed> roster,
|
||||
Action delete,
|
||||
Action<CharacterRestore.Parsed> restore,
|
||||
Action<CharacterError.Parsed> error)
|
||||
{
|
||||
_session = session;
|
||||
_roster = roster;
|
||||
_delete = delete;
|
||||
_restore = restore;
|
||||
_error = error;
|
||||
session.CharacterListReceived += roster;
|
||||
session.CharacterDeleteAcknowledged += delete;
|
||||
session.CharacterRestoreReceived += restore;
|
||||
session.CharacterErrorReceived += error;
|
||||
}
|
||||
|
||||
public bool IsDisposed => _session is null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
WorldSession? session = Interlocked.Exchange(ref _session, null);
|
||||
if (session is null)
|
||||
return;
|
||||
session.CharacterListReceived -= _roster;
|
||||
session.CharacterDeleteAcknowledged -= _delete;
|
||||
session.CharacterRestoreReceived -= _restore;
|
||||
session.CharacterErrorReceived -= _error;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionScope(
|
||||
WorldSession session,
|
||||
ILiveSessionLifecycleHost host,
|
||||
|
|
@ -232,6 +290,11 @@ public sealed class LiveSessionController
|
|||
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||
public RuntimeGenerationToken Generation { get; } = generation;
|
||||
public LiveSessionBinding? Binding { get; set; }
|
||||
public CharacterSelectionWireBinding? CharacterSelectionBinding
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public bool HostAttached { get; set; }
|
||||
public RuntimeTeardownStage CompletedStages { get; private set; }
|
||||
|
||||
|
|
@ -242,13 +305,18 @@ public sealed class LiveSessionController
|
|||
try
|
||||
{
|
||||
Binding?.Dispose();
|
||||
CharacterSelectionBinding?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Binding is null || Binding.CommandsDeactivated)
|
||||
CompletedStages |= RuntimeTeardownStage.CommandsInert;
|
||||
if (Binding is null || Binding.EventsDetached)
|
||||
if ((Binding is null || Binding.EventsDetached)
|
||||
&& (CharacterSelectionBinding is null
|
||||
|| CharacterSelectionBinding.IsDisposed))
|
||||
{
|
||||
CompletedStages |= RuntimeTeardownStage.InboundDetached;
|
||||
}
|
||||
}
|
||||
_teardownStage = 1;
|
||||
}
|
||||
|
|
@ -315,8 +383,14 @@ public sealed class LiveSessionController
|
|||
public LiveSessionController(ILiveSessionOperations operations)
|
||||
{
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
CharacterSelectionState = new RuntimeCharacterSelectionState();
|
||||
}
|
||||
|
||||
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
|
||||
|
||||
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||
CharacterSelectionState.View;
|
||||
|
||||
public WorldSession? CurrentSession
|
||||
{
|
||||
get { lock (_gate) return _scope?.Session; }
|
||||
|
|
@ -405,6 +479,11 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (_inWorld)
|
||||
return ConnectedResult();
|
||||
if (_scope is not null)
|
||||
{
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.AwaitingCharacterSelection);
|
||||
}
|
||||
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
||||
}
|
||||
}
|
||||
|
|
@ -492,7 +571,7 @@ public sealed class LiveSessionController
|
|||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (!_inWorld || _scope is null || _operationDepth != 0)
|
||||
if (_scope is null || _operationDepth != 0)
|
||||
return;
|
||||
|
||||
RunTopLevel(() =>
|
||||
|
|
@ -517,7 +596,8 @@ public sealed class LiveSessionController
|
|||
// the protocol pump above and only when the scope/generation
|
||||
// are still current — a reconnect that happened mid-tick
|
||||
// must not flush against a retired session.
|
||||
InvokeAutoSaveTick(scope.Session);
|
||||
if (_inWorld)
|
||||
InvokeAutoSaveTick(scope.Session);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -583,6 +663,8 @@ public sealed class LiveSessionController
|
|||
{
|
||||
RuntimeGenerationToken resetGeneration = new(_generation);
|
||||
ulong generation = ++_generation;
|
||||
RuntimeGenerationToken activeGeneration = new(generation);
|
||||
CharacterSelectionState.Reset(activeGeneration);
|
||||
try
|
||||
{
|
||||
DrainRetiredScope();
|
||||
|
|
@ -608,6 +690,8 @@ public sealed class LiveSessionController
|
|||
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
||||
|
||||
CharacterSelectionState.Begin(activeGeneration);
|
||||
|
||||
SessionScope? scope = null;
|
||||
try
|
||||
{
|
||||
|
|
@ -643,6 +727,9 @@ public sealed class LiveSessionController
|
|||
_operations.Connect(session, options.User, options.Password);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
scope.CharacterSelectionBinding = BindCharacterSelection(
|
||||
scope,
|
||||
generation);
|
||||
host.ReportConnected();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
|
@ -650,7 +737,9 @@ public sealed class LiveSessionController
|
|||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is not null)
|
||||
{
|
||||
host.ReportRoster(BuildRosterReport(characters));
|
||||
LiveSessionRosterReport roster = BuildRosterReport(characters);
|
||||
CharacterSelectionState.ApplyRoster(roster);
|
||||
host.ReportRoster(roster);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
|
|
@ -670,6 +759,19 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
|
||||
}
|
||||
|
||||
if (options.AwaitCharacterSelection
|
||||
&& options.Character is null
|
||||
&& characters is not null)
|
||||
{
|
||||
_operations.StartCharacterSelectionReceive(session);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
Console.WriteLine(
|
||||
"live: awaiting character selection before EnterWorld");
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.AwaitingCharacterSelection);
|
||||
}
|
||||
|
||||
if (characters is null
|
||||
|| !TrySelectCharacter(
|
||||
characters,
|
||||
|
|
@ -686,6 +788,12 @@ public sealed class LiveSessionController
|
|||
selected.Character.Id,
|
||||
selected.Character.Name,
|
||||
characters.AccountName);
|
||||
if (!CharacterSelectionState.TryHighlight(selection.CharacterId)
|
||||
|| !CharacterSelectionState.BeginEnter(out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Runtime character selection rejected the validated active character.");
|
||||
}
|
||||
host.ApplySelectedCharacter(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
|
@ -701,6 +809,7 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
CharacterSelectionState.CompleteEnter(selection.CharacterId);
|
||||
host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
|
@ -734,6 +843,273 @@ public sealed class LiveSessionController
|
|||
}
|
||||
}
|
||||
|
||||
private CharacterSelectionWireBinding BindCharacterSelection(
|
||||
SessionScope scope,
|
||||
ulong generation) =>
|
||||
new(
|
||||
scope.Session,
|
||||
roster =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsCurrent(scope, generation))
|
||||
return;
|
||||
LiveSessionRosterReport report = BuildRosterReport(roster);
|
||||
CharacterSelectionState.ApplyRoster(report);
|
||||
scope.Host.ReportRoster(report);
|
||||
}
|
||||
},
|
||||
() =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsCurrent(scope, generation))
|
||||
CharacterSelectionState.ApplyDeleteAcknowledged();
|
||||
}
|
||||
},
|
||||
restore =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsCurrent(scope, generation))
|
||||
CharacterSelectionState.ApplyRestore(restore);
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsCurrent(scope, generation))
|
||||
CharacterSelectionState.ApplyError(error);
|
||||
}
|
||||
});
|
||||
|
||||
public RuntimeCommandResult Highlight(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint characterId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
RuntimeCommandStatus status =
|
||||
CharacterSelectionState.TryHighlight(characterId)
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Rejected;
|
||||
return CharacterSelectionResult(status, characterId);
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Enter(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
if (_operationDepth != 0)
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
|
||||
return RunTopLevel(EnterSelectedCore);
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult RequestDelete(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
RuntimeCommandStatus status =
|
||||
CharacterSelectionState.TryRequestDelete(out uint characterId)
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Rejected;
|
||||
return CharacterSelectionResult(status, characterId);
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult ConfirmDelete(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
if (!CharacterSelectionState.TryTakeDeleteConfirmation(
|
||||
out RuntimeCharacterSelectionEntry character,
|
||||
out string accountName))
|
||||
{
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_operations.DeleteCharacter(
|
||||
_scope!.Session,
|
||||
accountName,
|
||||
character.ActiveIndex);
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Accepted,
|
||||
character.CharacterId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CharacterSelectionState.ApplyError(
|
||||
new CharacterError.Parsed(
|
||||
(uint)CharacterError.Code.Delete));
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Rejected,
|
||||
character.CharacterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Restore(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
if (!CharacterSelectionState.TryBeginRestore(
|
||||
out RuntimeCharacterSelectionEntry character))
|
||||
{
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_operations.RestoreCharacter(
|
||||
_scope!.Session,
|
||||
character.CharacterId);
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Accepted,
|
||||
character.CharacterId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CharacterSelectionState.ApplyError(
|
||||
new CharacterError.Parsed(
|
||||
(uint)CharacterError.Code.Undefined));
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Rejected,
|
||||
character.CharacterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Cancel(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
|
||||
expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterSelectionResult(gate);
|
||||
return CharacterSelectionResult(
|
||||
CharacterSelectionState.Cancel()
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeCommandResult EnterSelectedCore()
|
||||
{
|
||||
SessionScope scope = _scope!;
|
||||
ulong generation = _generation;
|
||||
if (!CharacterSelectionState.BeginEnter(
|
||||
out RuntimeCharacterSelectionEntry character))
|
||||
{
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
|
||||
RuntimeCharacterSelectionSnapshot snapshot =
|
||||
CharacterSelectionState.Snapshot;
|
||||
var selection = new LiveSessionCharacterSelection(
|
||||
character.ActiveIndex,
|
||||
character.CharacterId,
|
||||
character.Name,
|
||||
snapshot.AccountName);
|
||||
try
|
||||
{
|
||||
scope.Host.ApplySelectedCharacter(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
_operations.EnterWorld(scope.Session, character.ActiveIndex);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
scope.Binding!.ActivateCommands();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
CharacterSelectionState.CompleteEnter(character.CharacterId);
|
||||
scope.Host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
Console.WriteLine("live: in world — CreateObject stream active");
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Accepted,
|
||||
character.CharacterId);
|
||||
}
|
||||
catch (CharacterSelectionRejectedException rejected)
|
||||
{
|
||||
if (CharacterSelectionState.Snapshot.Error?.RawCode
|
||||
!= rejected.Error.RawErrorCode)
|
||||
{
|
||||
CharacterSelectionState.ApplyError(rejected.Error);
|
||||
}
|
||||
CharacterSelectionState.ReturnToSelection();
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Rejected,
|
||||
character.CharacterId);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_ = StopAfterFailure(error);
|
||||
return CharacterSelectionResult(
|
||||
RuntimeCommandStatus.Rejected,
|
||||
character.CharacterId);
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeCommandStatus ValidateCharacterSelectionCommand(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
RuntimeGenerationToken current = new(_generation);
|
||||
if (expectedGeneration != current)
|
||||
return RuntimeCommandStatus.StaleGeneration;
|
||||
if (_disposed || _disposeRequested || _scope is null || _inWorld)
|
||||
return RuntimeCommandStatus.Inactive;
|
||||
return CharacterSelectionState.Snapshot.Lifecycle
|
||||
== RuntimeCharacterSelectionLifecycle.AwaitingSelection
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Inactive;
|
||||
}
|
||||
|
||||
private RuntimeCommandResult CharacterSelectionResult(
|
||||
RuntimeCommandStatus status,
|
||||
uint characterId = 0u) =>
|
||||
new(
|
||||
status,
|
||||
new RuntimeGenerationToken(_generation),
|
||||
characterId);
|
||||
|
||||
private void StopCore()
|
||||
{
|
||||
// MUST-FIX 1: TS-71's logout-flush half — retail's
|
||||
|
|
@ -749,6 +1125,7 @@ public sealed class LiveSessionController
|
|||
++_generation;
|
||||
_inWorld = false;
|
||||
_activeSelection = null;
|
||||
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
|
||||
if (_scope is { } scope)
|
||||
{
|
||||
_scope = null;
|
||||
|
|
@ -889,6 +1266,7 @@ public sealed class LiveSessionController
|
|||
private void DisposeCore()
|
||||
{
|
||||
StopCore();
|
||||
CharacterSelectionState.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,6 +318,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
RuntimeSessionStartStatus.Failed,
|
||||
LiveSessionStartStatus.ProbeComplete =>
|
||||
RuntimeSessionStartStatus.ProbeComplete,
|
||||
LiveSessionStartStatus.AwaitingCharacterSelection =>
|
||||
RuntimeSessionStartStatus.AwaitingCharacterSelection,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(result),
|
||||
result.Status,
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue