feat(runtime): own character selection flow
This commit is contained in:
parent
6c4cd2bbc6
commit
0e82cbf700
20 changed files with 2396 additions and 33 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue