feat(runtime): share chat commands and run login sequence

This commit is contained in:
Erik 2026-08-14 20:27:45 +02:00
parent 5535d0adac
commit 41b15efd4d
57 changed files with 1739 additions and 345 deletions

View 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));
}
}