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