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
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue