feat(runtime): share chat commands and run login sequence
This commit is contained in:
parent
5535d0adac
commit
41b15efd4d
57 changed files with 1739 additions and 345 deletions
170
src/AcDream.Runtime/Chat/LoginCommandSequence.cs
Normal file
170
src/AcDream.Runtime/Chat/LoginCommandSequence.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
public readonly record struct LoginCommandFailure(
|
||||
int CommandIndex,
|
||||
string Command,
|
||||
string Error);
|
||||
|
||||
/// <summary>
|
||||
/// Executes configured login lines through the same parser and command bus as
|
||||
/// typed chat. A sequence is armed only by an entered-world edge, belongs to
|
||||
/// one exact Runtime generation, and never lets one command or status-report
|
||||
/// failure abort the remaining lines or the session.
|
||||
/// </summary>
|
||||
public sealed class LoginCommandSequence
|
||||
{
|
||||
private readonly string[] _commands;
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IChatCommandFeedback _feedback;
|
||||
private readonly ICommandBus _bus;
|
||||
private readonly Action<LoginCommandFailure> _onFailure;
|
||||
private RuntimeGenerationToken _generation;
|
||||
private RuntimeGenerationToken? _lastStartedGeneration;
|
||||
private long _nextDeadline;
|
||||
private int _nextIndex;
|
||||
private bool _active;
|
||||
|
||||
public LoginCommandSequence(
|
||||
IEnumerable<string?>? commands,
|
||||
TimeSpan delay,
|
||||
IChatCommandFeedback feedback,
|
||||
ICommandBus bus,
|
||||
Action<LoginCommandFailure>? onFailure = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (delay < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(delay));
|
||||
ArgumentNullException.ThrowIfNull(feedback);
|
||||
ArgumentNullException.ThrowIfNull(bus);
|
||||
|
||||
_commands = commands?.Select(static command => command ?? string.Empty)
|
||||
.ToArray() ?? [];
|
||||
_delay = delay;
|
||||
_feedback = feedback;
|
||||
_bus = bus;
|
||||
_onFailure = onFailure ?? (static _ => { });
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public int CommandCount => _commands.Length;
|
||||
public bool IsActive => _active;
|
||||
public int NextCommandIndex => _nextIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Arms this generation once and executes its first due command
|
||||
/// immediately. Repeated entered-world callbacks in the same generation
|
||||
/// are ignored; a reconnect generation starts the list again from zero.
|
||||
/// </summary>
|
||||
public void EnteredWorld(RuntimeGenerationToken generation)
|
||||
{
|
||||
if (_lastStartedGeneration == generation)
|
||||
return;
|
||||
|
||||
_lastStartedGeneration = generation;
|
||||
_generation = generation;
|
||||
_nextIndex = 0;
|
||||
_active = _commands.Length > 0;
|
||||
_nextDeadline = _timeProvider.GetTimestamp();
|
||||
DrainDue(generation, isInWorld: true);
|
||||
}
|
||||
|
||||
public void Tick(
|
||||
RuntimeGenerationToken generation,
|
||||
bool isInWorld)
|
||||
{
|
||||
DrainDue(generation, isInWorld);
|
||||
}
|
||||
|
||||
public void Cancel(RuntimeGenerationToken generation)
|
||||
{
|
||||
if (_active && _generation == generation)
|
||||
_active = false;
|
||||
}
|
||||
|
||||
private void DrainDue(
|
||||
RuntimeGenerationToken generation,
|
||||
bool isInWorld)
|
||||
{
|
||||
if (!_active || !isInWorld || generation != _generation)
|
||||
return;
|
||||
|
||||
long now = _timeProvider.GetTimestamp();
|
||||
while (_active
|
||||
&& generation == _generation
|
||||
&& _nextIndex < _commands.Length
|
||||
&& now >= _nextDeadline)
|
||||
{
|
||||
int commandIndex = _nextIndex;
|
||||
string command = _commands[commandIndex];
|
||||
try
|
||||
{
|
||||
SubmitOutcome outcome = ChatCommandRouter.Submit(
|
||||
command,
|
||||
_feedback,
|
||||
_bus,
|
||||
ChatChannelKind.Say);
|
||||
if (outcome is SubmitOutcome.UnknownCommand
|
||||
or SubmitOutcome.Dropped)
|
||||
{
|
||||
ReportFailure(new LoginCommandFailure(
|
||||
commandIndex,
|
||||
command,
|
||||
$"Chat command routing returned {outcome}."));
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ReportFailure(new LoginCommandFailure(
|
||||
commandIndex,
|
||||
command,
|
||||
error.GetBaseException().Message));
|
||||
}
|
||||
|
||||
// The command handler may have synchronously stopped or replaced
|
||||
// the session. Cancel() then owns the state; never advance an old
|
||||
// generation after returning from user-controlled code.
|
||||
if (!_active || generation != _generation)
|
||||
return;
|
||||
|
||||
_nextIndex++;
|
||||
if (_nextIndex >= _commands.Length)
|
||||
{
|
||||
_active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Inter-command delay starts after the prior handler returns.
|
||||
// A slow synchronous wire/client handler must not consume the
|
||||
// configured delay merely by taking time itself.
|
||||
now = _timeProvider.GetTimestamp();
|
||||
_nextDeadline = Add(_timeProvider, now, _delay);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportFailure(LoginCommandFailure failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
_onFailure(failure);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Status/diagnostic reporting observes the sequence. It can never
|
||||
// poison login command execution or the session transaction.
|
||||
}
|
||||
}
|
||||
|
||||
private static long Add(
|
||||
TimeProvider provider,
|
||||
long timestamp,
|
||||
TimeSpan duration)
|
||||
{
|
||||
double delta = duration.TotalSeconds * provider.TimestampFrequency;
|
||||
if (delta >= long.MaxValue - timestamp)
|
||||
return long.MaxValue;
|
||||
return checked(timestamp + (long)Math.Ceiling(delta));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue