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

@ -99,16 +99,15 @@ internal sealed record HeadlessSessionDescriptor
/// <summary>
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
/// Absent means load every discovered plugin (today's dev behavior);
/// LA5 wires this into an actual allow-list filter. Parsed and carried
/// here now so the session-config shape is stable before LA5 lands.
/// Absent means load every discovered plugin (the developer flow);
/// explicit empty means load none. LA5 host composition consumes this
/// as the actual allow-list filter.
/// </summary>
public List<string>? Plugins { get; init; }
/// <summary>
/// Campaign LA slice LA1: ordered chat-typed strings run once the
/// session enters world. LA6 wires actual execution; parsed and carried
/// here now.
/// Campaign LA slice LA1/LA6: ordered chat-typed strings run through the
/// shared Runtime parser/router once the session enters world.
/// </summary>
public List<string>? LoginCommands { get; init; }
@ -123,8 +122,7 @@ internal sealed record HeadlessSessionDescriptor
/// <summary>
/// Campaign LA slice LA1: absolute path for this session's status-event
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
/// is constructed for this session.
/// §6). Absent selects the writer's permanent no-op mode.
/// </summary>
public string? StatusFile { get; init; }
}

View file

@ -323,8 +323,9 @@ internal static class HeadlessConfigurationLoader
/// Campaign LA slice LA1: validates the four new optional per-session
/// fields shared with the App session-config reader (see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
/// contract). All four stay optional; only their SHAPE is checked here
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
/// contract). All four stay optional; this loader owns their strict
/// shape checks while LA5/LA6 host composition consumes the resulting
/// plugin allow-list and ordered login-command sequence.
/// </summary>
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
{

View file

@ -6,6 +6,7 @@ using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
@ -14,29 +15,54 @@ namespace AcDream.Headless.Hosting;
internal sealed class HeadlessSessionHost : IDisposable
{
private sealed class SessionCommandRoute(
ILiveSessionCommandRouting gameplay,
ILiveSessionCommandRouting commands)
: ILiveSessionCommandRouting
private sealed class SessionCommandRoute : ILiveSessionCommandRouting
{
private bool _gameplayActive;
private bool _commandsActive;
private ILiveSessionCommandRouting? _gameplay;
private ILiveSessionCommandRouting? _commands;
private ILiveSessionCommandRouting? _chat;
private bool _activated;
internal SessionCommandRoute(
ILiveSessionCommandRouting gameplay,
ILiveSessionCommandRouting commands,
ILiveSessionCommandRouting chat)
{
_gameplay = gameplay
?? throw new ArgumentNullException(nameof(gameplay));
_commands = commands
?? throw new ArgumentNullException(nameof(commands));
_chat = chat
?? throw new ArgumentNullException(nameof(chat));
}
public void Activate()
{
if (_gameplayActive || _commandsActive)
if (_activated)
return;
gameplay.Activate();
_gameplayActive = true;
if (_gameplay is null || _commands is null || _chat is null)
throw new ObjectDisposedException(nameof(SessionCommandRoute));
_gameplay.Activate();
try
{
commands.Activate();
_commandsActive = true;
_commands.Activate();
_chat.Activate();
_activated = true;
}
catch
catch (Exception activationError)
{
gameplay.Dispose();
_gameplayActive = false;
try
{
Dispose();
}
catch (Exception disposalError)
{
throw new AggregateException(
"Headless command-route activation and rollback failed.",
activationError,
disposalError);
}
throw;
}
}
@ -44,30 +70,9 @@ internal sealed class HeadlessSessionHost : IDisposable
public void Dispose()
{
List<Exception>? failures = null;
if (_commandsActive)
{
try
{
commands.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
_commandsActive = false;
}
if (_gameplayActive)
{
try
{
gameplay.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
_gameplayActive = false;
}
TryDispose(ref _chat, ref failures);
TryDispose(ref _commands, ref failures);
TryDispose(ref _gameplay, ref failures);
if (failures is not null)
{
throw new AggregateException(
@ -75,6 +80,24 @@ internal sealed class HeadlessSessionHost : IDisposable
failures);
}
}
private static void TryDispose(
ref ILiveSessionCommandRouting? route,
ref List<Exception>? failures)
{
if (route is not { } current)
return;
try
{
current.Dispose();
route = null;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
private sealed class SessionCommandBridge : IRuntimeSessionCommands
@ -301,6 +324,18 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
var chatCommandSurface = new LiveChatCommandSurface();
var loginCommands = new LoginCommandSequence(
descriptor.LoginCommands,
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
new RuntimeChatCommandFeedback(runtime.CommunicationOwner),
chatCommandSurface,
failure => statusWriter.LoginCommandFailed(
descriptor.Id,
failure.CommandIndex,
failure.Command,
failure.Error),
_timeProvider);
pluginSession = HeadlessPluginSession.Create(
runtime,
diagnostics,
@ -315,7 +350,12 @@ internal sealed class HeadlessSessionHost : IDisposable
CreateEventRoute,
session => new SessionCommandRoute(
gameplay.CreateRoute(session),
commands.CreateRoute(session))),
commands.CreateRoute(session),
chatCommandSurface.Attach(
new LiveChatCommandRoute(
CreateChatCommandBindings(
session,
runtime))))),
generation =>
runtime.ResetGeneration(generation, _resetHost),
new LiveSessionSelectionBindings(
@ -368,7 +408,8 @@ internal sealed class HeadlessSessionHost : IDisposable
selection => statusWriter.EnteredWorld(
descriptor.Id,
selection.CharacterId,
selection.CharacterName)));
selection.CharacterName),
loginCommands));
Runtime = runtime;
Commands = commands;
@ -506,7 +547,7 @@ internal sealed class HeadlessSessionHost : IDisposable
_ = Runtime.Clock.Advance(deltaSeconds);
_localPlayerFrame.AdvanceBeforeNetwork(
checked((float)deltaSeconds));
Runtime.Session.Tick();
_liveSession.Tick();
// C3c: pump pending first-entry sequences after the network drain —
// collision-generation progress and freshly accepted Creates both
// surface here, mirroring the graphical per-frame retry phase.
@ -821,6 +862,142 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
private LiveChatCommandBindings CreateChatCommandBindings(
AcDream.Core.Net.WorldSession session,
GameRuntime runtime) => new(
ExecuteClientCommand: command =>
ExecuteHeadlessClientCommand(session, runtime, command),
Communication: runtime.CommunicationOwner,
Chat: runtime.CommunicationOwner.Chat,
TurbineChat: runtime.CommunicationOwner.TurbineChat,
CharacterState: runtime.CharacterOwner,
PlayerGuid: () => runtime.PlayerIdentity.ServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: session.SendTurbineChatTo,
Log: message => _diagnostics.Message(
_descriptor.Id,
message,
runtime.Generation.Value));
/// <summary>
/// Presentation-free subset of retail client commands. Commands whose
/// semantics require a graphical confirmation/window or a host-specific
/// presentation service fail explicitly; the login sequence reports that
/// one line and continues. Wire-only and canonical-state commands take
/// the exact same WorldSession/Runtime paths as the graphical bindings.
/// </summary>
private static void ExecuteHeadlessClientCommand(
AcDream.Core.Net.WorldSession session,
GameRuntime runtime,
ExecuteClientCommandCmd command)
{
switch (command.Command)
{
case ClientCommandId.LifestoneRecall:
session.SendTeleportToLifestone();
return;
case ClientCommandId.MarketplaceRecall:
session.SendTeleportToMarketplace();
return;
case ClientCommandId.PkArenaRecall:
session.SendTeleportToPkArena();
return;
case ClientCommandId.PkLiteArenaRecall:
session.SendTeleportToPkLiteArena();
return;
case ClientCommandId.EnterPkLite:
session.SendEnterPkLite();
return;
case ClientCommandId.HouseRecall:
session.SendTeleportToHouse();
return;
case ClientCommandId.MansionRecall:
session.SendTeleportToMansion();
return;
case ClientCommandId.QueryAge:
session.SendQueryAge();
return;
case ClientCommandId.QueryBirth:
session.SendQueryBirth();
return;
case ClientCommandId.Emote
when !string.IsNullOrWhiteSpace(command.Arguments):
session.SendEmote(command.Arguments.Trim());
return;
case ClientCommandId.ClearChat:
runtime.CommunicationOwner.Chat.Clear();
return;
case ClientCommandId.IndexChannels:
session.SendIndexChannels();
return;
case ClientCommandId.ListChannel:
SendResolvedChannel(
command.Arguments,
session.SendListChannel);
return;
case ClientCommandId.OnChannel:
SendResolvedChannel(
command.Arguments,
session.SendOnChannel);
return;
case ClientCommandId.OffChannel:
SendResolvedChannel(
command.Arguments,
session.SendOffChannel);
return;
case ClientCommandId.AllegianceHometown:
session.SendRecallAllegianceHometown();
return;
case ClientCommandId.AllegianceInfo:
session.SendAllegianceInfoRequest(command.Arguments.Trim());
return;
case ClientCommandId.HouseAvailableList
when RetailClientCommandCatalog.TryResolveHouseType(
command.Arguments,
out uint houseType):
session.SendListAvailableHouses(houseType);
return;
case ClientCommandId.JoinChannel
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
command.Arguments,
out uint joinOption):
_ = runtime.CharacterOwner.Options.TrySetOption(
joinOption,
true,
session.SendSetSingleCharacterOption);
return;
case ClientCommandId.LeaveChannel
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
command.Arguments,
out uint leaveOption):
_ = runtime.CharacterOwner.Options.TrySetOption(
leaveOption,
false,
session.SendSetSingleCharacterOption);
return;
default:
throw new NotSupportedException(
$"Client command '{command.Command}' is not available "
+ "in the headless host.");
}
static void SendResolvedChannel(
string arguments,
Action<uint> send)
{
if (!RetailChannelTagTable.TryResolve(
arguments.Trim(),
out uint channelId))
{
throw new InvalidOperationException(
$"Chat channel '{arguments.Trim()}' does not exist.");
}
send(channelId);
}
}
private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{