merge: Campaign LA LA6 - login commands review-closed
This commit is contained in:
commit
2bb8ccb6b6
57 changed files with 1958 additions and 345 deletions
|
|
@ -92,7 +92,7 @@ stack. Full history and the corrected contract live in
|
|||
│ LayoutDesc/DAT → UiRoot retained widgets + controllers │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ SHARED CONTRACTS │
|
||||
│ ViewModels, commands, input actions, state/event services │
|
||||
│ ViewModels, input actions, state/event and command seams │
|
||||
│ ► one model and mutation path, one presentation projection │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Game state + events (unchanged) │
|
||||
|
|
@ -100,24 +100,32 @@ stack. Full history and the corrected contract live in
|
|||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract, the
|
||||
ViewModels and the commands — **survives intact**. It was always
|
||||
`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract and the
|
||||
ViewModels — **survives intact**. It was always
|
||||
backend-agnostic, which is exactly what Code Structure Rule 3 was written to
|
||||
protect, and it is what a future developer-panel host would bind to. Only the
|
||||
ImGui *backend* was deleted. `ACDREAM_DEVTOOLS=1` still selects Vulkan's
|
||||
debug-utils extensions and now logs that the developer UI is gone; replacing it
|
||||
is issue **#258**, deliberately unscheduled.
|
||||
|
||||
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, commands, input,
|
||||
and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
||||
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, input, and the
|
||||
`IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
||||
retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
||||
controllers. Neither presentation stack owns independent game-state truth.
|
||||
Chat submission follows the same rule: both presentation stacks enter the
|
||||
shared `ChatCommandRouter`, which emits distinct backend-neutral intents for a
|
||||
retail client command (`ExecuteClientCommandCmd`), an ACE-owned command
|
||||
(`SendServerCommandCmd`), or ordinary chat (`SendChatCmd`). App-layer
|
||||
handlers and controllers translate those intents to `WorldSession`; panels
|
||||
never inspect or construct wire messages.
|
||||
Chat submission follows the same rule: `AcDream.Runtime/Chat` owns the shared
|
||||
parser, retail command/channel/help catalogs, `ChatCommandRouter`, command bus,
|
||||
and its four backend-neutral records (`ExecuteClientCommandCmd`,
|
||||
`SendServerCommandCmd`, `SendChatCmd`, and `SendRawChannelCmd`). Its only
|
||||
presentation callback is the four-member `IChatCommandFeedback`; retained
|
||||
`ChatVM` implements that seam. Both App and Headless bind the same
|
||||
`LiveChatCommandRoute` to the active `WorldSession` send delegates and exact
|
||||
`RuntimeCommunicationState`/`RuntimeCharacterState` children. Panels never
|
||||
inspect or construct wire messages, and Runtime has no UI or App dependency.
|
||||
Configured login commands enter that identical parser/router only after the
|
||||
generation's `enteredWorld` edge, in order, once per generation. The shared
|
||||
generation-aware sequence cancels on replacement, applies the configured
|
||||
inter-command delay, and reports each isolated failure without aborting the
|
||||
session or plugin lifetime.
|
||||
Plugins register retained gameplay markup through the BCL-only
|
||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
|
||||
presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge:
|
||||
|
|
@ -251,6 +259,9 @@ src/
|
|||
RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent
|
||||
adoption + retail Create tail + strict-order
|
||||
FIFO/replay execution over the residence
|
||||
Chat/ -> LA6 parser/router/catalog and four command
|
||||
intents; shared live route + generation-scoped
|
||||
configured-login sequence for both hosts
|
||||
Gameplay/
|
||||
RuntimeCommunicationState.cs -> one chat/social owner + ordered stream
|
||||
RuntimeInventoryState.cs -> exact object-table borrower + inventory
|
||||
|
|
|
|||
|
|
@ -120,6 +120,14 @@ ViewModel or command had to change, because none of them had ever imported
|
|||
writes against `IPanelRenderer`; a renderer implementation translates those
|
||||
calls at runtime. Plugin-facing UI follows the same rule.
|
||||
|
||||
The shared chat parser/router/catalog and its four command intents live in
|
||||
`AcDream.Runtime/Chat`, not in a panel or App. `AcDream.UI.Abstractions`
|
||||
references Runtime so retained `ChatVM` can implement the narrow
|
||||
`IChatCommandFeedback` seam and its existing panel input can call the shared
|
||||
router. That dependency does not permit panels to import App, windowing,
|
||||
rendering, audio, or another presentation backend; Runtime itself remains
|
||||
presentation-independent and its dependency guards enforce that boundary.
|
||||
|
||||
**Status:** there is currently no `IPanelRenderer` implementation in the tree —
|
||||
the ImGui one went with V11 and the replacement is issue **#258**. The contract
|
||||
is kept rather than deleted precisely because this rule proved its worth; a new
|
||||
|
|
|
|||
|
|
@ -168,13 +168,21 @@ line, writer opens `FileShare.Read`, tailer opens
|
|||
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
|
||||
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
|
||||
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
|
||||
`pluginFailed{plugin,error}`, `disconnected{reason}`,
|
||||
`pluginFailed{plugin,error}`,
|
||||
`loginCommandFailed{commandIndex,command,error}`,
|
||||
`disconnected{reason}`,
|
||||
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
|
||||
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
|
||||
sides. Unknown `e` values must parse to a typed Unknown event, never
|
||||
throw; a known `e` with a wrong payload shape should be distinguishable
|
||||
from an unknown `e` (LA3 review finding 12).
|
||||
|
||||
`loginCommandFailed.commandIndex` is the zero-based index in the configured
|
||||
`loginCommands` array. `command` is the exact configured line and `error` is
|
||||
the isolated parser/router/handler failure. The event is observational: the
|
||||
host continues with the next configured line and never converts the command
|
||||
failure into a login, plugin, session, or process failure.
|
||||
|
||||
**Known LA1 status limitation:** the stream has no independent mid-play
|
||||
wire-drop detector. If a transport becomes silent without raising through the
|
||||
host's tick/teardown path, no immediate `disconnected` line can be promised;
|
||||
|
|
@ -203,9 +211,10 @@ Three pieces, one slice, because they share the session-config/status seam:
|
|||
`RuntimeOptions.LivePass` is a bare string — the config path must not
|
||||
widen that exposure).
|
||||
2. **Status stream both hosts:** per-session `status.jsonl` (path given in
|
||||
config; absent → no writer constructed, zero cost). Versioned event
|
||||
config; absent → permanent no-op sink). Versioned event
|
||||
vocabulary (`"v":1`): `started`, `connected`, `characterList`,
|
||||
`enteredWorld`, `pluginLoaded`/`pluginFailed`, `disconnected`, `exited`.
|
||||
`enteredWorld`, `pluginLoaded`/`pluginFailed`,
|
||||
`loginCommandFailed`, `disconnected`, `exited`.
|
||||
Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL
|
||||
sink with four kinds (lifecycle/failure/event/resources) and NO per-session
|
||||
file — the status writer is a second, separate sink, not a rework of the
|
||||
|
|
|
|||
|
|
@ -197,7 +197,8 @@ hosts, one JSON object per line:
|
|||
|
||||
`started`, `connected`, `characterList` (names + ids + slots),
|
||||
`enteredWorld` (id + name), `pluginLoaded` / `pluginFailed` (name +
|
||||
error), `disconnected`, `exited` (code + reason).
|
||||
error), `loginCommandFailed` (zero-based command index + exact configured
|
||||
line + isolated error), `disconnected`, `exited` (code + reason).
|
||||
|
||||
The launcher tails this for live per-session UI state and folds
|
||||
`characterList` into the profile store. This exact event vocabulary is
|
||||
|
|
|
|||
|
|
@ -581,7 +581,7 @@ internal sealed class FrameRootCompositionPhase
|
|||
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
|
||||
session.LiveObjectFrame,
|
||||
live.WorldState,
|
||||
session.LiveSession,
|
||||
session.SessionHost,
|
||||
session.LocalPlayerFrame,
|
||||
session.LiveSpatialReconciler,
|
||||
live.WorldAvailability,
|
||||
|
|
|
|||
|
|
@ -1130,7 +1130,9 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
liveSessionCommands,
|
||||
d.Log,
|
||||
d.StatusWriter,
|
||||
d.Options.SessionId ?? "app");
|
||||
d.Options.SessionId ?? "app",
|
||||
d.Options.LoginCommands,
|
||||
d.Options.LoginCommandDelayMs);
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||
liveSession,
|
||||
new LiveSessionConnectOptions(
|
||||
|
|
|
|||
|
|
@ -115,12 +115,12 @@ internal sealed record SessionDescriptor
|
|||
/// allow-list semantics for this field (OP7 D8).</summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
|
||||
/// this; parsed and carried here now per the pinned launch contract).</summary>
|
||||
/// <summary>LA1/LA5: plugin ids to load. Absent = load all; explicit
|
||||
/// empty = load none.</summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>LA1: ordered chat-typed strings run after entering world
|
||||
/// (LA6 consumes this; parsed and carried here now).</summary>
|
||||
/// <summary>LA1/LA6: ordered chat-typed strings run through the shared
|
||||
/// Runtime parser/router after entering world.</summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using AcDream.Runtime.Physics;
|
||||
global using AcDream.Runtime.Chat;
|
||||
global using ILocalPlayerMotionSource =
|
||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource
|
|||
|
||||
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
|
||||
{
|
||||
AcDream.UI.Abstractions.ICommandBus Commands { get; }
|
||||
AcDream.Runtime.Chat.ICommandBus Commands { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
|
|
@ -149,6 +147,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
private LiveChatCommandRoute? _chatCommands;
|
||||
private ClientCommandController.Bindings? _clientCommands;
|
||||
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
||||
|
||||
|
|
@ -170,19 +169,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
var commands = new LiveCommandBus();
|
||||
var clientCommands = new ClientCommandController(
|
||||
BuildGuardedClientCommands(bindings.ClientCommands));
|
||||
commands.Register<ExecuteClientCommandCmd>(clientCommands.Execute);
|
||||
commands.Register<SendServerCommandCmd>(command =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(command.Text))
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
});
|
||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
||||
_chatCommands = new LiveChatCommandRoute(new LiveChatCommandBindings(
|
||||
clientCommands.Execute,
|
||||
bindings.Communication,
|
||||
bindings.Chat,
|
||||
bindings.TurbineChat,
|
||||
bindings.CharacterState,
|
||||
bindings.PlayerGuid,
|
||||
bindings.SendTalk,
|
||||
bindings.SendTell,
|
||||
bindings.SendChannel,
|
||||
bindings.SendTurbineChat,
|
||||
bindings.Log));
|
||||
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
||||
// ChannelSystem::GetChannelID fallback tags — bypasses
|
||||
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
||||
// legacy ChatChannel (0x0147) broadcast directly.
|
||||
commands.Register<SendRawChannelCmd>(
|
||||
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
|
||||
commands.Register<AddShortcutRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
|
||||
commands.Register<RemoveShortcutRuntimeCmd>(
|
||||
|
|
@ -320,6 +322,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
{
|
||||
if (_state == 2)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
|
||||
_chatCommands?.Activate();
|
||||
_state = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -329,202 +332,31 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
lock (_gate)
|
||||
{
|
||||
if (_state == 1)
|
||||
_commands?.Publish(command);
|
||||
{
|
||||
if (_chatCommands?.TryPublish(command) != true)
|
||||
_commands?.Publish(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LiveCommandBus? commands;
|
||||
LiveChatCommandRoute? chatCommands;
|
||||
lock (_gate)
|
||||
{
|
||||
_state = 2;
|
||||
commands = _commands;
|
||||
_commands = null;
|
||||
chatCommands = _chatCommands;
|
||||
_chatCommands = null;
|
||||
_clientCommands = null;
|
||||
}
|
||||
|
||||
chatCommands?.Dispose();
|
||||
commands?.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
|
||||
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
|
||||
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
|
||||
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
|
||||
/// is legacy-only (0x0147) — those pipelines never overlap Turbine.
|
||||
/// <see cref="ChatChannelKind.Allegiance"/> is the one exception, and
|
||||
/// <see cref="RouteChat"/> special-cases it BEFORE this table is
|
||||
/// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original
|
||||
/// CH3 filing (research doc §5.3) — retail's <c>/a</c> is bound to the
|
||||
/// LEGACY <c>AllegianceBroadcast</c> bitflag by default and is only
|
||||
/// rebound to <c>DoTurbineChat_Allegiance</c> once
|
||||
/// <c>StartupTurbineChatSystem</c> successfully starts Turbine chat
|
||||
/// (research doc §4.3). So "Turbine never started" (<c>TurbineChat.
|
||||
/// Enabled == false</c>) still falls back to legacy, while "Turbine is
|
||||
/// up but this character has no allegiance room" (<c>Enabled == true</c>,
|
||||
/// <c>AllegianceRoom == 0</c>) correctly keeps retail's local
|
||||
/// "Turbine chat is not available." refusal at the membership gate.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> TurbineChannelKinds = new()
|
||||
{
|
||||
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
|
||||
[ChatChannelKind.General] = ChatChannelKindLite.General,
|
||||
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
|
||||
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
|
||||
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
|
||||
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
|
||||
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
|
||||
};
|
||||
|
||||
private void RouteChat(
|
||||
LiveSessionCommandBindings bindings,
|
||||
SendChatCmd command)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command.Text))
|
||||
return;
|
||||
|
||||
switch (command.Channel)
|
||||
{
|
||||
case ChatChannelKind.Say:
|
||||
// ACE echoes HearSpeech to the sender. Retail therefore uses
|
||||
// the authoritative inbound line rather than a local echo.
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
return;
|
||||
|
||||
case ChatChannelKind.Tell:
|
||||
if (string.IsNullOrEmpty(command.TargetName))
|
||||
return;
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendTell(command.TargetName, command.Text)))
|
||||
return;
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Tell,
|
||||
command.Text,
|
||||
// Retail's own "You tell ..." echo is Speech_Direct_Send
|
||||
// (0x04), distinct from an incoming Tell's 0x03 — see
|
||||
// ChatMessageType.OutgoingTell's "You tell ..." comment.
|
||||
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
|
||||
targetOrChannel: command.TargetName);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc
|
||||
// comment above — Turbine chat never having started (no 0x0295
|
||||
// SetTurbineChatChannels received at all) still routes /a through
|
||||
// the legacy AllegianceBroadcast bitflag, exactly like retail's
|
||||
// default binding before StartupTurbineChatSystem runs.
|
||||
if (command.Channel == ChatChannelKind.Allegiance
|
||||
&& !bindings.TurbineChat.Enabled)
|
||||
{
|
||||
RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind))
|
||||
{
|
||||
RouteTurbineChat(bindings, liteKind, command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
RouteLegacyChannel(bindings, command.Channel, command.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2 of the CH3 fix list: retail
|
||||
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
|
||||
/// membership gate, raised through the same
|
||||
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
|
||||
/// every other client-raised refusal.
|
||||
/// </summary>
|
||||
private void RouteTurbineChat(
|
||||
LiveSessionCommandBindings bindings,
|
||||
ChatChannelKindLite kind,
|
||||
string text)
|
||||
{
|
||||
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
||||
kind,
|
||||
bindings.TurbineChat,
|
||||
bindings.CharacterState.Options,
|
||||
bindings.CharacterState.IsOlthoiPlayer);
|
||||
|
||||
// N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is
|
||||
// now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via
|
||||
// TurbineChatMembershipGate.ResolveRefusalText — this used to be an
|
||||
// independent copy of the same switch.
|
||||
if (gate.Status != TurbineChatGateStatus.Allowed)
|
||||
{
|
||||
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
|
||||
(string refusalText, RetailLogTextType refusalType))
|
||||
{
|
||||
bindings.Communication.AddText(refusalText, refusalType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint cookie = bindings.TurbineChat.NextContextId();
|
||||
uint senderGuid = bindings.PlayerGuid();
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound TurbineChat {gate.DisplayName} " +
|
||||
$"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " +
|
||||
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
|
||||
SendIfActive(() => bindings.SendTurbineChat(
|
||||
gate.RoomId,
|
||||
gate.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid,
|
||||
text,
|
||||
cookie));
|
||||
}
|
||||
|
||||
private void RouteLegacyChannel(
|
||||
LiveSessionCommandBindings bindings,
|
||||
ChatChannelKind channel,
|
||||
string text)
|
||||
{
|
||||
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
|
||||
if (legacy is null)
|
||||
{
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: SendChatCmd kind={channel} dropped (no legacy id)");
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
|
||||
$"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendChannel(legacy.Value.ChannelId, text)))
|
||||
return;
|
||||
|
||||
// Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends
|
||||
// Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an
|
||||
// empty sender name, so a local optimistic echo double-prints. S1
|
||||
// (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into
|
||||
// this SAME group: ACE's GameActionChatChannel handler iterates
|
||||
// player.Allegiance.Members and the sender is one of them, so they
|
||||
// get their own line back with their real name too — a different
|
||||
// mechanism (no separate ""-sender resend) but the same
|
||||
// double-print risk, so it must ALSO skip the local echo (research
|
||||
// doc §3.7/§5.4, corrected).
|
||||
bool serverEchoes = new ChatChannelInfo.Legacy(
|
||||
legacy.Value.ChannelId,
|
||||
legacy.Value.DisplayName).IsSelfEchoChannel();
|
||||
if (serverEchoes)
|
||||
return;
|
||||
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Channel,
|
||||
text,
|
||||
targetOrChannel: legacy.Value.DisplayName,
|
||||
// Precise per-bit own-send type (LegacyChannelChatType.Resolve's
|
||||
// ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/
|
||||
// Vassal/Follower become 0x0B, the admin/audit/sentinel
|
||||
// catch-all becomes 0x09 Channel_Send (corrected 2026-08-09,
|
||||
// Opus review of 172c6f9a — was wrongly 0x0E).
|
||||
logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true));
|
||||
}
|
||||
|
||||
private ClientCommandController.Bindings BuildGuardedClientCommands(
|
||||
ClientCommandController.Bindings source) => new(
|
||||
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ using AcDream.Runtime;
|
|||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -106,6 +107,9 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly LiveMovementStatsApplier _movementStats;
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
private readonly string _sessionId;
|
||||
private readonly IReadOnlyList<string> _loginCommands;
|
||||
private readonly TimeSpan _loginCommandDelay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LiveSessionRuntimeFactory(
|
||||
LiveSessionPlayerRuntime player,
|
||||
|
|
@ -116,7 +120,10 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
LiveSessionCommandSurface commands,
|
||||
Action<string> log,
|
||||
SessionStatusWriter? statusWriter = null,
|
||||
string sessionId = "app")
|
||||
string sessionId = "app",
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
int loginCommandDelayMs = 500,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||
|
|
@ -130,6 +137,14 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
// status file configured — every call site below stays unconditional.
|
||||
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
if (loginCommandDelayMs < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(loginCommandDelayMs));
|
||||
}
|
||||
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
|
||||
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||
// typed application seam; App keeps zero direct controller mutations.
|
||||
_movementStats = new LiveMovementStatsApplier(
|
||||
|
|
@ -150,6 +165,17 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
LiveSessionResetPlan reset =
|
||||
LiveSessionResetManifest.Create(
|
||||
CreateResetBindings(resetHost));
|
||||
var loginCommands = new LoginCommandSequence(
|
||||
_loginCommands,
|
||||
_loginCommandDelay,
|
||||
new RuntimeChatCommandFeedback(_domain.Communication),
|
||||
_commands,
|
||||
failure => _statusWriter.LoginCommandFailed(
|
||||
_sessionId,
|
||||
failure.CommandIndex,
|
||||
failure.Command,
|
||||
failure.Error),
|
||||
_timeProvider);
|
||||
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
||||
Routing: new(
|
||||
CreateEventRouter,
|
||||
|
|
@ -194,7 +220,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
CharacterEntered: selection => _statusWriter.EnteredWorld(
|
||||
_sessionId,
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)),
|
||||
selection.CharacterName),
|
||||
LoginCommands: loginCommands),
|
||||
connectOptions);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ public sealed record RuntimeOptions(
|
|||
string? StatusFilePath,
|
||||
/// <summary>Campaign LA slice LA1: plugin ids to load.
|
||||
/// <see langword="null"/> = load every discovered plugin (today's
|
||||
/// behavior). Consumed by LA5; parsed and carried now.</summary>
|
||||
/// behavior). Consumed by the shared graphical plugin session.</summary>
|
||||
IReadOnlyList<string>? Plugins,
|
||||
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
|
||||
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
|
||||
/// entered-world through the shared Runtime parser/router.</summary>
|
||||
IReadOnlyList<string> LoginCommands,
|
||||
/// <summary>Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
||||
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
|
||||
/// <param name="busProvider">Factory that returns the live command bus at submit time.
|
||||
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
|
||||
/// Called on every chat submit so it resolves <see cref="LiveCommandBus"/>
|
||||
/// even when the live session is established AFTER <see cref="Bind"/> runs
|
||||
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
|
||||
/// <param name="windowFilters">Runtime's canonical per-window filter/open state
|
||||
|
|
|
|||
|
|
@ -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,186 @@ 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.ChatToggle:
|
||||
// Retail DoChatToggle: "off" adds the global Speech
|
||||
// squelch; "on" removes it.
|
||||
session.SendModifyGlobalSquelch(
|
||||
command.Arguments.Equals(
|
||||
"off",
|
||||
StringComparison.OrdinalIgnoreCase),
|
||||
2u);
|
||||
return;
|
||||
case ClientCommandId.NoTellToggle:
|
||||
// Retail DoNoTell: "on" adds the global Tell squelch;
|
||||
// "off" removes it.
|
||||
session.SendModifyGlobalSquelch(
|
||||
command.Arguments.Equals(
|
||||
"on",
|
||||
StringComparison.OrdinalIgnoreCase),
|
||||
3u);
|
||||
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.Permit:
|
||||
ExecutePermit(session, command.Arguments);
|
||||
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);
|
||||
}
|
||||
|
||||
static void ExecutePermit(
|
||||
AcDream.Core.Net.WorldSession activeSession,
|
||||
string arguments)
|
||||
{
|
||||
// The catalog has already required add/remove plus a name.
|
||||
// Match ClientCommandController's JoinArgsAsName behavior so
|
||||
// multi-word character names remain one exact wire argument.
|
||||
string[] parts = arguments.Split(
|
||||
(char[]?)null,
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
string name = string.Join(' ', parts, 1, parts.Length - 1);
|
||||
if (parts[0].Equals(
|
||||
"add",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
activeSession.SendAddPlayerPermission(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
activeSession.SendRemovePlayerPermission(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ILiveSessionEventRouting CreateEventRoute(
|
||||
AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -849,6 +849,11 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}";
|
||||
activity.Status = activity.Error;
|
||||
break;
|
||||
case LoginCommandFailedStatusEvent failed:
|
||||
activity.Error =
|
||||
$"Login command {failed.CommandIndex} failed: {failed.Error}";
|
||||
activity.Status = activity.Error;
|
||||
break;
|
||||
case DisconnectedStatusEvent disconnected:
|
||||
if (activity.State != LauncherActivityState.Stopping)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ public sealed record PluginFailedStatusEvent : StatusEvent
|
|||
public required string Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginCommandFailedStatusEvent : StatusEvent
|
||||
{
|
||||
public required int CommandIndex { get; init; }
|
||||
|
||||
public required string Command { get; init; }
|
||||
|
||||
public required string Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DisconnectedStatusEvent : StatusEvent
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ public static class StatusEventParser
|
|||
ParsePluginLoaded(root, v, e, t, sessionId),
|
||||
"pluginFailed" =>
|
||||
ParsePluginFailed(root, v, e, t, sessionId),
|
||||
"loginCommandFailed" =>
|
||||
ParseLoginCommandFailed(root, v, e, t, sessionId),
|
||||
"disconnected" =>
|
||||
ParseDisconnected(root, v, e, t, sessionId),
|
||||
"exited" =>
|
||||
|
|
@ -128,6 +130,7 @@ public static class StatusEventParser
|
|||
"enteredWorld" or
|
||||
"pluginLoaded" or
|
||||
"pluginFailed" or
|
||||
"loginCommandFailed" or
|
||||
"disconnected" or
|
||||
"exited";
|
||||
|
||||
|
|
@ -280,6 +283,32 @@ public static class StatusEventParser
|
|||
Reason = RequireString(root, "reason"),
|
||||
};
|
||||
|
||||
private static StatusEvent ParseLoginCommandFailed(
|
||||
JsonElement root,
|
||||
int v,
|
||||
string e,
|
||||
DateTimeOffset t,
|
||||
string sessionId)
|
||||
{
|
||||
int commandIndex = RequireInt32(root, "commandIndex");
|
||||
if (commandIndex < 0)
|
||||
{
|
||||
throw new FormatException(
|
||||
"status event field 'commandIndex' is negative.");
|
||||
}
|
||||
|
||||
return new LoginCommandFailedStatusEvent
|
||||
{
|
||||
V = v,
|
||||
E = e,
|
||||
T = t,
|
||||
SessionId = sessionId,
|
||||
CommandIndex = commandIndex,
|
||||
Command = RequireString(root, "command"),
|
||||
Error = RequireString(root, "error"),
|
||||
};
|
||||
}
|
||||
|
||||
private static StatusEvent ParseExited(
|
||||
JsonElement root,
|
||||
int v,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="ChatChannelKind"/> to the legacy <c>ChatChannel</c>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound chat channel selector. Mirrors holtburger's <c>ChatChannelKind</c>
|
||||
|
|
@ -2,7 +2,7 @@ using System;
|
|||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>What a submit did, so the caller can clear its input + give feedback.
|
||||
/// <c>UnknownCommand</c> is produced only for command-shaped but verbless
|
||||
|
|
@ -11,8 +11,8 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
|
|||
|
||||
/// <summary>
|
||||
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @
|
||||
/// 0x004F5100</c> analogue). Both the ImGui devtools <see cref="ChatPanel"/>
|
||||
/// and retained retail chat window route through here.
|
||||
/// 0x004F5100</c> analogue). Every graphical and headless chat entrance routes
|
||||
/// through here.
|
||||
///
|
||||
/// <para>
|
||||
/// Flow: emote-prefix rewrite, retail client-command catalog, local
|
||||
|
|
@ -20,17 +20,20 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
|
|||
/// <c>ChannelSystem::GetChannelID</c> fallback (unregistered GM/faction
|
||||
/// channel tags), explicit server command, then chat parse. Unknown
|
||||
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
|
||||
/// <c>@</c> form; the App host sends those through Talk, the only wire path
|
||||
/// ACE parses commands on. Prefix text with no letter verb is refused locally
|
||||
/// so command-shaped input can never leak into speech.
|
||||
/// <c>@</c> form; the active host sends those through Talk, the only wire
|
||||
/// path ACE parses commands on. Prefix text with no letter verb is refused
|
||||
/// locally so command-shaped input can never leak into speech.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatCommandRouter
|
||||
{
|
||||
public static SubmitOutcome Submit(
|
||||
string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel)
|
||||
string? raw,
|
||||
IChatCommandFeedback feedback,
|
||||
ICommandBus bus,
|
||||
ChatChannelKind defaultChannel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vm);
|
||||
ArgumentNullException.ThrowIfNull(feedback);
|
||||
ArgumentNullException.ThrowIfNull(bus);
|
||||
string trimmed = (raw ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0)
|
||||
|
|
@ -80,7 +83,7 @@ public static class ChatCommandRouter
|
|||
// ClientLocal is correct for it.
|
||||
if (clientCommand.InvalidArgumentsText is { } bespokeRefusal)
|
||||
{
|
||||
vm.ShowInterfaceText(bespokeRefusal);
|
||||
feedback.ShowInterfaceText(bespokeRefusal);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -88,9 +91,9 @@ public static class ChatCommandRouter
|
|||
WeenieErrorMessages.Resolve(0x026u, null);
|
||||
string fallbackText = fallback.text ?? "That is not a valid command.";
|
||||
if (fallback.type == RetailLogTextType.ClientLocal)
|
||||
vm.ShowInterfaceText(fallbackText);
|
||||
feedback.ShowInterfaceText(fallbackText);
|
||||
else
|
||||
vm.ShowSystemMessage(fallbackText);
|
||||
feedback.ShowSystemMessage(fallbackText);
|
||||
}
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
|
@ -100,7 +103,7 @@ public static class ChatCommandRouter
|
|||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (TryHandleLocalPresentationCommand(trimmed, vm))
|
||||
if (TryHandleLocalPresentationCommand(trimmed, feedback))
|
||||
return SubmitOutcome.ClientHandled;
|
||||
|
||||
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
|
||||
|
|
@ -112,7 +115,7 @@ public static class ChatCommandRouter
|
|||
if (trimmed[0] is '/' or '@'
|
||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||
{
|
||||
vm.ShowInterfaceText(
|
||||
feedback.ShowInterfaceText(
|
||||
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
|
@ -130,7 +133,7 @@ public static class ChatCommandRouter
|
|||
// would resolve them too, but retail never reaches this fallback
|
||||
// for a REGISTERED verb (it's intercepted by the main hash table
|
||||
// first).
|
||||
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus);
|
||||
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, bus);
|
||||
if (fallbackOutcome is { } outcome)
|
||||
return outcome;
|
||||
|
||||
|
|
@ -147,18 +150,23 @@ public static class ChatCommandRouter
|
|||
// null" shape does for these cases.
|
||||
if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed))
|
||||
{
|
||||
vm.ShowInterfaceText("You must specify the text you wish to say!");
|
||||
feedback.ShowInterfaceText("You must specify the text you wish to say!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender))
|
||||
if (ChatInputParser.IsReplyMissingLastTeller(
|
||||
trimmed,
|
||||
feedback.LastIncomingTellSender))
|
||||
{
|
||||
vm.ShowInterfaceText("Someone must @tell you first!");
|
||||
feedback.ShowInterfaceText("Someone must @tell you first!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
var parsed = ChatInputParser.Parse(
|
||||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
||||
trimmed,
|
||||
defaultChannel,
|
||||
feedback.LastIncomingTellSender,
|
||||
feedback.LastOutgoingTellTarget);
|
||||
if (parsed is { } chat)
|
||||
{
|
||||
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));
|
||||
|
|
@ -173,7 +181,9 @@ public static class ChatCommandRouter
|
|||
/// fallback channel tags (caller continues its own dispatch chain);
|
||||
/// otherwise returns the outcome to return immediately.
|
||||
/// </summary>
|
||||
private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus)
|
||||
private static SubmitOutcome? TryDispatchChannelFallback(
|
||||
string trimmed,
|
||||
ICommandBus bus)
|
||||
{
|
||||
if (trimmed[0] is not ('/' or '@'))
|
||||
return null;
|
||||
|
|
@ -240,11 +250,13 @@ public static class ChatCommandRouter
|
|||
return true;
|
||||
}
|
||||
|
||||
private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm)
|
||||
private static bool TryHandleLocalPresentationCommand(
|
||||
string trimmed,
|
||||
IChatCommandFeedback feedback)
|
||||
{
|
||||
if (EqAny(trimmed, "/help", "/?", "@help", "@?"))
|
||||
{
|
||||
EmitBareHelp(vm);
|
||||
EmitBareHelp(feedback);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +267,7 @@ public static class ChatCommandRouter
|
|||
if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? "))
|
||||
{
|
||||
string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim();
|
||||
EmitVerbHelp(verb, vm);
|
||||
EmitVerbHelp(verb, feedback);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -268,10 +280,10 @@ public static class ChatCommandRouter
|
|||
/// user-gate round 3, finding (b) — see <see cref="RetailCommandHelpTable"/>'s
|
||||
/// class remarks for the full print-sequence trace).
|
||||
/// </summary>
|
||||
private static void EmitBareHelp(ChatVM vm)
|
||||
private static void EmitBareHelp(IChatCommandFeedback feedback)
|
||||
{
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -302,11 +314,13 @@ public static class ChatCommandRouter
|
|||
/// — chat-alias/channel verbs and the group-topic nodes, a disjoint key
|
||||
/// space from the catalog so this reordering changes nothing for them.
|
||||
/// </remarks>
|
||||
private static void EmitVerbHelp(string verb, ChatVM vm)
|
||||
private static void EmitVerbHelp(
|
||||
string verb,
|
||||
IChatCommandFeedback feedback)
|
||||
{
|
||||
if (verb.Length == 0)
|
||||
{
|
||||
EmitBareHelp(vm);
|
||||
EmitBareHelp(feedback);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -319,35 +333,35 @@ public static class ChatCommandRouter
|
|||
// SAME fallback an unregistered verb gets — DoHelp's help-
|
||||
// pointer-null guard skips its callback branch entirely. See
|
||||
// RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks.
|
||||
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailCommandHelpTable.TryGetCatalogVerbDetailText(normalized, out string retailDetailText))
|
||||
{
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText))
|
||||
{
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText))
|
||||
{
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
|
||||
// now routed through ChatVM.ShowInterfaceText instead of the chat
|
||||
// scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand.
|
||||
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
// now routed through IChatCommandFeedback.ShowInterfaceText instead
|
||||
// of the chat scroll — see RetailCommandHelpTable.UnknownCommand.
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.4: pure-function parse of a chat-input line into the
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-neutral identity for a command that retail executes in the client
|
||||
|
|
@ -85,8 +85,8 @@ public enum ClientCommandId
|
|||
/// <summary>
|
||||
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
|
||||
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
|
||||
/// dispatched — <see cref="AcDream.UI.Abstractions.Panels.Chat.
|
||||
/// RetailClientCommandCatalog.Match.HasValidArguments"/> is always
|
||||
/// dispatched — <see cref="RetailClientCommandCatalog.Match.HasValidArguments"/>
|
||||
/// is always
|
||||
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
|
||||
/// "Please see @help Allegiance..." refusal and never publishes an
|
||||
/// <c>ExecuteClientCommandCmd</c>.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// User intent to execute a retail client command. The application host owns
|
||||
17
src/AcDream.Runtime/Chat/IChatCommandFeedback.cs
Normal file
17
src/AcDream.Runtime/Chat/IChatCommandFeedback.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// The exact feedback surface used by the retail chat command parser/router.
|
||||
/// Presentation hosts may implement it, while headless execution binds these
|
||||
/// members directly to the canonical Runtime communication state.
|
||||
/// </summary>
|
||||
public interface IChatCommandFeedback
|
||||
{
|
||||
void ShowInterfaceText(string text);
|
||||
|
||||
void ShowSystemMessage(string text);
|
||||
|
||||
string? LastIncomingTellSender { get; }
|
||||
|
||||
string? LastOutgoingTellTarget { get; }
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes user-intent commands from panels to the systems that handle
|
||||
345
src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs
Normal file
345
src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Exact live-session dependencies for the four chat-core command records.
|
||||
/// Both graphical and no-window hosts bind this record to the active
|
||||
/// <c>WorldSession</c>'s send methods and the same canonical Runtime state.
|
||||
/// </summary>
|
||||
public sealed record LiveChatCommandBindings(
|
||||
Action<ExecuteClientCommandCmd> ExecuteClientCommand,
|
||||
RuntimeCommunicationState Communication,
|
||||
ChatLog Chat,
|
||||
TurbineChatState TurbineChat,
|
||||
RuntimeCharacterState CharacterState,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<string> SendTalk,
|
||||
Action<string, string> SendTell,
|
||||
Action<uint, string> SendChannel,
|
||||
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
|
||||
Action<string>? Log = null);
|
||||
|
||||
/// <summary>
|
||||
/// One generation's active binding for the four chat-core records. The route
|
||||
/// becomes inert before the transport is disposed and clears every delegate
|
||||
/// during teardown.
|
||||
/// </summary>
|
||||
public sealed class LiveChatCommandRoute
|
||||
: ILiveSessionCommandRouting,
|
||||
ICommandBus
|
||||
{
|
||||
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite>
|
||||
TurbineChannelKinds = new()
|
||||
{
|
||||
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
|
||||
[ChatChannelKind.General] = ChatChannelKindLite.General,
|
||||
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
|
||||
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
|
||||
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
|
||||
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
|
||||
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
||||
|
||||
public LiveChatCommandRoute(LiveChatCommandBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
ArgumentNullException.ThrowIfNull(bindings.ExecuteClientCommand);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Communication);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Chat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.TurbineChat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.CharacterState);
|
||||
ArgumentNullException.ThrowIfNull(bindings.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTalk);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTell);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendChannel);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
|
||||
|
||||
var commands = new LiveCommandBus();
|
||||
commands.Register<ExecuteClientCommandCmd>(command =>
|
||||
SendIfActive(() => bindings.ExecuteClientCommand(command)));
|
||||
commands.Register<SendServerCommandCmd>(command =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(command.Text))
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
});
|
||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
||||
commands.Register<SendRawChannelCmd>(command =>
|
||||
SendIfActive(() =>
|
||||
bindings.SendChannel(command.ChannelId, command.Text)));
|
||||
_commands = commands;
|
||||
}
|
||||
|
||||
public bool IsActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _state == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 2)
|
||||
throw new ObjectDisposedException(nameof(LiveChatCommandRoute));
|
||||
_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
if (!TryPublish(command))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[LiveChatCommandRoute] unsupported command type "
|
||||
+ $"{typeof(T).FullName}; dropping.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes one of the four extracted command records and returns
|
||||
/// <see langword="true"/>. Other records are left to a host's sibling
|
||||
/// command router and return <see langword="false"/> without logging.
|
||||
/// </summary>
|
||||
public bool TryPublish<T>(T command) where T : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
Type type = typeof(T);
|
||||
if (type != typeof(ExecuteClientCommandCmd)
|
||||
&& type != typeof(SendServerCommandCmd)
|
||||
&& type != typeof(SendChatCmd)
|
||||
&& type != typeof(SendRawChannelCmd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 1)
|
||||
_commands?.Publish(command);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LiveCommandBus? commands;
|
||||
lock (_gate)
|
||||
{
|
||||
_state = 2;
|
||||
commands = _commands;
|
||||
_commands = null;
|
||||
}
|
||||
commands?.Clear();
|
||||
}
|
||||
|
||||
private void RouteChat(
|
||||
LiveChatCommandBindings bindings,
|
||||
SendChatCmd command)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command.Text))
|
||||
return;
|
||||
|
||||
switch (command.Channel)
|
||||
{
|
||||
case ChatChannelKind.Say:
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
return;
|
||||
|
||||
case ChatChannelKind.Tell:
|
||||
if (string.IsNullOrEmpty(command.TargetName))
|
||||
return;
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendTell(command.TargetName, command.Text)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Tell,
|
||||
command.Text,
|
||||
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
|
||||
targetOrChannel: command.TargetName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.Channel == ChatChannelKind.Allegiance
|
||||
&& !bindings.TurbineChat.Enabled)
|
||||
{
|
||||
RouteLegacyChannel(
|
||||
bindings,
|
||||
ChatChannelKind.AllegianceBroadcast,
|
||||
command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TurbineChannelKinds.TryGetValue(
|
||||
command.Channel,
|
||||
out ChatChannelKindLite liteKind))
|
||||
{
|
||||
RouteTurbineChat(bindings, liteKind, command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
RouteLegacyChannel(bindings, command.Channel, command.Text);
|
||||
}
|
||||
|
||||
private void RouteTurbineChat(
|
||||
LiveChatCommandBindings bindings,
|
||||
ChatChannelKindLite kind,
|
||||
string text)
|
||||
{
|
||||
TurbineChatState turbineChat = bindings.TurbineChat;
|
||||
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
||||
kind,
|
||||
turbineChat,
|
||||
bindings.CharacterState.Options,
|
||||
bindings.CharacterState.IsOlthoiPlayer);
|
||||
|
||||
if (gate.Status != TurbineChatGateStatus.Allowed)
|
||||
{
|
||||
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
|
||||
(string refusalText, RetailLogTextType refusalType))
|
||||
{
|
||||
bindings.Communication.AddText(refusalText, refusalType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint cookie = turbineChat.NextContextId();
|
||||
uint senderGuid = bindings.PlayerGuid();
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound TurbineChat {gate.DisplayName} "
|
||||
+ $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} "
|
||||
+ $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
|
||||
SendIfActive(() => bindings.SendTurbineChat(
|
||||
gate.RoomId,
|
||||
gate.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid,
|
||||
text,
|
||||
cookie));
|
||||
}
|
||||
|
||||
private void RouteLegacyChannel(
|
||||
LiveChatCommandBindings bindings,
|
||||
ChatChannelKind channel,
|
||||
string text)
|
||||
{
|
||||
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
|
||||
if (legacy is null)
|
||||
{
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: SendChatCmd kind={channel} dropped (no legacy id)");
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} "
|
||||
+ $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendChannel(legacy.Value.ChannelId, text)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool serverEchoes = new ChatChannelInfo.Legacy(
|
||||
legacy.Value.ChannelId,
|
||||
legacy.Value.DisplayName).IsSelfEchoChannel();
|
||||
if (serverEchoes)
|
||||
return;
|
||||
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Channel,
|
||||
text,
|
||||
targetOrChannel: legacy.Value.DisplayName,
|
||||
logTextType: LegacyChannelChatType.Resolve(
|
||||
legacy.Value.ChannelId,
|
||||
ownSend: true));
|
||||
}
|
||||
|
||||
private bool SendIfActive(Action send)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != 1)
|
||||
return false;
|
||||
send();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable host-owned bus over a replaceable generation route. A retained
|
||||
/// login-command runner never captures an obsolete transport.
|
||||
/// </summary>
|
||||
public sealed class LiveChatCommandSurface : ICommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveChatCommandRoute? _active;
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_active is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A live chat command route is already attached.");
|
||||
}
|
||||
_active = route;
|
||||
return new RouteLease(this, route);
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
LiveChatCommandRoute? route;
|
||||
lock (_gate)
|
||||
route = _active;
|
||||
route?.Publish(command);
|
||||
}
|
||||
|
||||
private void Release(LiveChatCommandRoute expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_active, expected))
|
||||
_active = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RouteLease(
|
||||
LiveChatCommandSurface owner,
|
||||
LiveChatCommandRoute route)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveChatCommandSurface? _owner = owner;
|
||||
|
||||
public void Activate() => route.Activate();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_owner is null)
|
||||
return;
|
||||
_owner.Release(route);
|
||||
_owner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="ICommandBus"/> implementation — single-handler-per-type
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// No-op <see cref="ICommandBus"/>. Accepts any published command and
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>ChannelSystem::GetChannelID @ 0x005CF1F0</c> — every legacy
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable catalog of commands the named retail client executes locally.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Collections.Frozen;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): <c>/help <verb></c> text for
|
||||
|
|
@ -213,11 +213,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// <para>
|
||||
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
|
||||
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
|
||||
/// <c>ChatVM.ShowInterfaceText</c> — an optional hook the App-layer host
|
||||
/// <c>IChatCommandFeedback.ShowInterfaceText</c> — an optional hook the host
|
||||
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
|
||||
/// chokepoint every other producer of interface text uses. UI.Abstractions
|
||||
/// still never references Runtime directly (Code Structure Rules); the hook
|
||||
/// is the seam. Closes ISSUES.md #367 and retires register row AP-186.
|
||||
/// chokepoint every other producer of interface text uses. The retained
|
||||
/// <c>ChatVM</c> implements this four-member feedback seam without entering
|
||||
/// command-routing code. Closes ISSUES.md #367 and retires register row
|
||||
/// AP-186.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailCommandHelpTable
|
||||
|
|
@ -267,7 +268,7 @@ public static class RetailCommandHelpTable
|
|||
// DoHelp's fallback when the verb hash lookup fails, or resolves to an
|
||||
// entry with no registered help callback. Retail types this 0x1A
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note --
|
||||
// ChatCommandRouter now routes it through ChatVM.ShowInterfaceText
|
||||
// ChatCommandRouter routes it through IChatCommandFeedback.ShowInterfaceText
|
||||
// (issue #363), closing #367.
|
||||
public const string UnknownCommand = "Unknown command";
|
||||
|
||||
32
src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs
Normal file
32
src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free feedback for chat commands executed by a host rather
|
||||
/// than a panel. It borrows the canonical communication owner; it creates no
|
||||
/// transcript or reply-target mirror.
|
||||
/// </summary>
|
||||
public sealed class RuntimeChatCommandFeedback : IChatCommandFeedback
|
||||
{
|
||||
private readonly RuntimeCommunicationState _communication;
|
||||
|
||||
public RuntimeChatCommandFeedback(RuntimeCommunicationState communication)
|
||||
{
|
||||
_communication = communication
|
||||
?? throw new ArgumentNullException(nameof(communication));
|
||||
}
|
||||
|
||||
public string? LastIncomingTellSender =>
|
||||
_communication.CommandTargets.LastIncomingTellSender;
|
||||
|
||||
public string? LastOutgoingTellTarget =>
|
||||
_communication.CommandTargets.LastOutgoingTellTarget;
|
||||
|
||||
public void ShowInterfaceText(string text) =>
|
||||
_communication.AddText(text, RetailLogTextType.ClientLocal);
|
||||
|
||||
public void ShowSystemMessage(string text) =>
|
||||
_communication.Chat.OnSystemMessage(text, chatType: 0x00u);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Command published by chat panels to send a message. The host resolves
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel
|
||||
|
|
@ -11,7 +11,7 @@ namespace AcDream.UI.Abstractions;
|
|||
/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>,
|
||||
/// <c>@celestialhand</c>) plus the argument channel-tag resolution
|
||||
/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See
|
||||
/// <see cref="Panels.Chat.RetailChannelTagTable"/> for the tag→id table.
|
||||
/// <see cref="RetailChannelTagTable"/> for the tag→id table.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SendRawChannelCmd(uint ChannelId, string Text);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Command text owned by the connected server rather than the retail client.
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Runtime.ExceptionServices;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -39,7 +40,8 @@ public sealed record LiveSessionHostBindings(
|
|||
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
||||
/// fan-out, this exists so a status writer can emit the
|
||||
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered);
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered,
|
||||
LoginCommandSequence? LoginCommands = null);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
||||
|
|
@ -47,7 +49,9 @@ public sealed record LiveSessionHostBindings(
|
|||
/// composition, but never mirrors session, generation, identity, routing, or
|
||||
/// command state.
|
||||
/// </summary>
|
||||
public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||
public sealed class LiveSessionHost
|
||||
: IRuntimeSessionCommands,
|
||||
IRuntimeLiveSessionFramePhase
|
||||
{
|
||||
private sealed class PendingRouteRollback(
|
||||
ILiveSessionCommandRouting? commands,
|
||||
|
|
@ -98,6 +102,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
||||
private readonly Action<RuntimeGenerationToken> _reset;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private readonly LoginCommandSequence? _loginCommands;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
||||
public LiveSessionHost(
|
||||
|
|
@ -114,6 +119,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
_characterEntered = bindings.CharacterEntered
|
||||
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
||||
_loginCommands = bindings.LoginCommands;
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
|
|
@ -176,6 +182,17 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
RuntimeGenerationToken expectedGeneration) =>
|
||||
_controller.Stop(expectedGeneration);
|
||||
|
||||
/// <summary>
|
||||
/// Pumps the canonical network session first, then any due login command.
|
||||
/// Both graphical and headless frame loops use this host boundary so the
|
||||
/// sequencing contract cannot drift between them.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
_controller.Tick();
|
||||
_loginCommands?.Tick(_controller.Generation, _controller.IsInWorld);
|
||||
}
|
||||
|
||||
private LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
|
|
@ -211,6 +228,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
// state below. Treat physical route convergence as the same hard
|
||||
// barrier used by normal LiveSessionBinding teardown.
|
||||
DrainPendingRouteRollback();
|
||||
_loginCommands?.Cancel(retiringGeneration);
|
||||
_reset(retiringGeneration);
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +252,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
_characterEntered(selection);
|
||||
_loginCommands?.EnteredWorld(_controller.Generation);
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
|
|
|
|||
|
|
@ -228,6 +228,22 @@ public sealed class SessionStatusWriter
|
|||
error,
|
||||
});
|
||||
|
||||
public void LoginCommandFailed(
|
||||
string sessionId,
|
||||
int commandIndex,
|
||||
string command,
|
||||
string error) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "loginCommandFailed",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
commandIndex,
|
||||
command,
|
||||
error,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
1
src/AcDream.UI.Abstractions/GlobalUsings.cs
Normal file
1
src/AcDream.UI.Abstractions/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
global using AcDream.Runtime.Chat;
|
||||
|
|
@ -21,7 +21,7 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// unchanged transcript does not require a queue snapshot each frame.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatVM : IDisposable
|
||||
public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
||||
{
|
||||
/// <summary>Default number of tail entries rendered.</summary>
|
||||
public const int DefaultDisplayLimit = 20;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using AcDream.Runtime.Physics;
|
||||
global using AcDream.Runtime.Chat;
|
||||
global using ILocalPlayerMotionSource =
|
||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,101 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionCommandRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoginCommandSequenceUsesTheIdenticalGraphicalCommandSurface()
|
||||
{
|
||||
using var communication = new RuntimeCommunicationState();
|
||||
var calls = new List<string>();
|
||||
ClientCommandController.Bindings client = NewClientBindings() with
|
||||
{
|
||||
QueryAge = () => calls.Add("client:age"),
|
||||
};
|
||||
var router = NewRouter(
|
||||
chat: communication.Chat,
|
||||
turbine: communication.TurbineChat,
|
||||
communication: communication,
|
||||
clientBindings: client,
|
||||
sendTalk: text => calls.Add($"talk:{text}"),
|
||||
sendTell: (target, text) =>
|
||||
calls.Add($"tell:{target}:{text}"),
|
||||
sendChannel: (channel, text) =>
|
||||
calls.Add($"channel:{channel:X8}:{text}"));
|
||||
var surface = new LiveSessionCommandSurface();
|
||||
using ILiveSessionCommandRouting lease = surface.Attach(router);
|
||||
lease.Activate();
|
||||
var sequence = new LoginCommandSequence(
|
||||
["hello", "/tell Bob, secret", "@admin raw", "/age"],
|
||||
TimeSpan.Zero,
|
||||
new RuntimeChatCommandFeedback(communication),
|
||||
surface);
|
||||
|
||||
sequence.EnteredWorld(new RuntimeGenerationToken(9));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"talk:hello",
|
||||
"tell:Bob:secret",
|
||||
"channel:00000002:raw",
|
||||
"client:age",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalRouteKeepsWireOnlyClientCommandParityWithHeadless()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
ClientCommandController.Bindings client = NewClientBindings() with
|
||||
{
|
||||
AddPlayerPermission = name =>
|
||||
calls.Add($"permit:add:{name}"),
|
||||
RemovePlayerPermission = name =>
|
||||
calls.Add($"permit:remove:{name}"),
|
||||
ModifyGlobalSquelch = (add, messageType) =>
|
||||
calls.Add($"squelch:{add}:{messageType}"),
|
||||
};
|
||||
var router = NewRouter(clientBindings: client);
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.Permit,
|
||||
"add Aunt Agatha"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.Permit,
|
||||
"remove Lord Gnarly Beard"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.ChatToggle,
|
||||
"on"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.ChatToggle,
|
||||
"off"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.NoTellToggle,
|
||||
"on"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.NoTellToggle,
|
||||
"off"));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"permit:add:Aunt Agatha",
|
||||
"permit:remove:Lord Gnarly Beard",
|
||||
"squelch:False:2",
|
||||
"squelch:True:2",
|
||||
"squelch:True:3",
|
||||
"squelch:False:3",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InactiveAndDisposedRouter_CannotReachTransport()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,11 @@ public sealed class HeadlessPluginSessionTests
|
|||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
||||
Descriptor(
|
||||
[FixtureId.ToUpperInvariant(), BrokenId],
|
||||
statusPath,
|
||||
loginCommands: ["/"],
|
||||
loginCommandDelayMs: 0),
|
||||
credential,
|
||||
diagnostics,
|
||||
new FixtureSessionOperations(),
|
||||
|
|
@ -57,7 +61,7 @@ public sealed class HeadlessPluginSessionTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"started", "pluginLoaded", "pluginFailed", "connected",
|
||||
"characterList", "enteredWorld",
|
||||
"characterList", "enteredWorld", "loginCommandFailed",
|
||||
],
|
||||
EventNames(statuses));
|
||||
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||
|
|
@ -66,6 +70,8 @@ public sealed class HeadlessPluginSessionTests
|
|||
"entry dll not found",
|
||||
statuses[2].GetProperty("error").GetString()!,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(0, statuses[6].GetProperty("commandIndex").GetInt32());
|
||||
Assert.Equal("/", statuses[6].GetProperty("command").GetString());
|
||||
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
|
|
@ -231,7 +237,9 @@ public sealed class HeadlessPluginSessionTests
|
|||
|
||||
private static HeadlessSessionDescriptor Descriptor(
|
||||
List<string> plugins,
|
||||
string statusPath) => new()
|
||||
string statusPath,
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
int loginCommandDelayMs = 500) => new()
|
||||
{
|
||||
Id = "headless-session",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
|
|
@ -255,6 +263,8 @@ public sealed class HeadlessPluginSessionTests
|
|||
},
|
||||
Plugins = plugins,
|
||||
StatusFile = statusPath,
|
||||
LoginCommands = loginCommands is null ? null : [.. loginCommands],
|
||||
LoginCommandDelayMs = loginCommandDelayMs,
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,286 @@ namespace AcDream.Headless.Tests;
|
|||
|
||||
public sealed class HeadlessSessionHostTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder()
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
GameActionCapture = body => captured.Add(body),
|
||||
};
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(
|
||||
loginCommands:
|
||||
[
|
||||
"hello",
|
||||
"/tell Bob, secret",
|
||||
"/f group",
|
||||
"@admin raw",
|
||||
"/vt start",
|
||||
],
|
||||
loginCommandDelayMs: 0),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult result = host.Start();
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
ChatRequests.TalkOpcode,
|
||||
ChatRequests.TellOpcode,
|
||||
ChatRequests.ChatChannelOpcode,
|
||||
ChatRequests.ChatChannelOpcode,
|
||||
ChatRequests.TalkOpcode,
|
||||
],
|
||||
captured.Select(ActionOpcode));
|
||||
Assert.Equal(
|
||||
0x00000800u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(captured[2].AsSpan(12)));
|
||||
Assert.Equal(
|
||||
0x00000002u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginCommandsRouteWireOnlyClientCommandsWithExactPolarityAndOrder()
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
GameActionCapture = body => captured.Add(body),
|
||||
};
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(
|
||||
loginCommands:
|
||||
[
|
||||
"/permit add Aunt Agatha",
|
||||
"@permit remove Lord Gnarly Beard",
|
||||
"/chat on",
|
||||
"/chat off",
|
||||
"/notell on",
|
||||
"/notell off",
|
||||
],
|
||||
loginCommandDelayMs: 0),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
ClientCommandRequests.AddPlayerPermissionOpcode,
|
||||
ClientCommandRequests.RemovePlayerPermissionOpcode,
|
||||
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||
],
|
||||
captured.Select(ActionOpcode));
|
||||
Assert.Equal("Aunt Agatha", StringActionArgument(captured[0]));
|
||||
Assert.Equal("Lord Gnarly Beard", StringActionArgument(captured[1]));
|
||||
Assert.Equal(
|
||||
[
|
||||
(Add: 0u, MessageType: 2u),
|
||||
(Add: 1u, MessageType: 2u),
|
||||
(Add: 1u, MessageType: 3u),
|
||||
(Add: 0u, MessageType: 3u),
|
||||
],
|
||||
captured.Skip(2).Select(static body => (
|
||||
Add: BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
body.AsSpan(12, sizeof(uint))),
|
||||
MessageType: BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
body.AsSpan(16, sizeof(uint))))));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/permit add")]
|
||||
[InlineData("/chat maybe")]
|
||||
[InlineData("/notell maybe")]
|
||||
public void InvalidWireOnlyArgumentKeepsTypedFeedbackWithoutStatusFailure(
|
||||
string invalidCommand)
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-wire-client-errors-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
GameActionCapture = body => captured.Add(body),
|
||||
};
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(
|
||||
statusFile: statusPath,
|
||||
loginCommands:
|
||||
[
|
||||
invalidCommand,
|
||||
"after",
|
||||
],
|
||||
loginCommandDelayMs: 0),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
Assert.Single(captured);
|
||||
Assert.Equal("after", TalkText(captured[0]));
|
||||
|
||||
// Invalid registered-command arguments are handled exactly as
|
||||
// typed: retail's ClientLocal refusal reaches canonical Runtime
|
||||
// feedback and is not misclassified as a transport failure.
|
||||
host.Runtime.CommunicationOwner.SpewBox.Tick(0d);
|
||||
Assert.Equal(
|
||||
"That is not a valid command.",
|
||||
Assert.Single(
|
||||
host.Runtime.CommunicationOwner.SpewBox.Snapshot()).Text);
|
||||
|
||||
JsonElement[] events = File.ReadAllLines(statusPath)
|
||||
.Select(static line =>
|
||||
JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
Assert.DoesNotContain(
|
||||
events,
|
||||
static item => item.GetProperty("e").GetString()
|
||||
== "loginCommandFailed");
|
||||
Assert.True(host.Runtime.Session.IsInWorld);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-login-commands-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
GameActionCapture = body => captured.Add(body),
|
||||
};
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(
|
||||
statusFile: statusPath,
|
||||
loginCommands: ["/", "/version", "after"],
|
||||
loginCommandDelayMs: 0),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult result = host.Start();
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status);
|
||||
Assert.True(host.Runtime.Session.IsInWorld);
|
||||
Assert.Single(captured);
|
||||
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(captured[0]));
|
||||
|
||||
JsonElement[] events = File.ReadAllLines(statusPath)
|
||||
.Select(static line =>
|
||||
JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"loginCommandFailed", "loginCommandFailed",
|
||||
],
|
||||
events.Select(static item =>
|
||||
item.GetProperty("e").GetString()));
|
||||
JsonElement[] failures = events
|
||||
.Where(static item =>
|
||||
item.GetProperty("e").GetString()
|
||||
== "loginCommandFailed")
|
||||
.ToArray();
|
||||
Assert.Equal(1, failures[0].GetProperty("v").GetInt32());
|
||||
Assert.Equal(0, failures[0].GetProperty("commandIndex").GetInt32());
|
||||
Assert.Equal("/", failures[0].GetProperty("command").GetString());
|
||||
Assert.Equal(
|
||||
"Chat command routing returned UnknownCommand.",
|
||||
failures[0].GetProperty("error").GetString());
|
||||
Assert.Equal(1, failures[1].GetProperty("commandIndex").GetInt32());
|
||||
Assert.Equal(
|
||||
"/version",
|
||||
failures[1].GetProperty("command").GetString());
|
||||
Assert.Contains(
|
||||
"not available in the headless host",
|
||||
failures[1].GetProperty("error").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginCommandDelayIsGenerationScopedAcrossReconnect()
|
||||
{
|
||||
var time = new ManualTimeProvider();
|
||||
var captured = new List<byte[]>();
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
GameActionCapture = body => captured.Add(body),
|
||||
};
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(
|
||||
loginCommands: ["first", "second"],
|
||||
loginCommandDelayMs: 500),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations,
|
||||
timeProvider: time);
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status);
|
||||
Assert.Equal(["first"], captured.Select(TalkText));
|
||||
|
||||
host.Tick(0.1d);
|
||||
Assert.Equal(["first"], captured.Select(TalkText));
|
||||
|
||||
// Replacement cancels the retiring generation's pending "second"
|
||||
// and starts the configured list once for the new entered-world edge.
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Reconnect().Status);
|
||||
Assert.Equal(["first", "first"], captured.Select(TalkText));
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(499));
|
||||
host.Tick(0.1d);
|
||||
Assert.Equal(["first", "first"], captured.Select(TalkText));
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(1));
|
||||
host.Tick(0.1d);
|
||||
Assert.Equal(["first", "first", "second"], captured.Select(TalkText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation()
|
||||
{
|
||||
|
|
@ -2452,7 +2732,9 @@ public sealed class HeadlessSessionHostTests
|
|||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "BOT_PASSWORD",
|
||||
Dictionary<string, bool>? characterOptions = null,
|
||||
string? statusFile = null) => new()
|
||||
string? statusFile = null,
|
||||
IReadOnlyList<string>? loginCommands = null,
|
||||
int loginCommandDelayMs = 500) => new()
|
||||
{
|
||||
Id = "bot",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
|
|
@ -2476,6 +2758,8 @@ public sealed class HeadlessSessionHostTests
|
|||
},
|
||||
CharacterOptions = characterOptions,
|
||||
StatusFile = statusFile,
|
||||
LoginCommands = loginCommands is null ? null : [.. loginCommands],
|
||||
LoginCommandDelayMs = loginCommandDelayMs,
|
||||
};
|
||||
|
||||
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
|
||||
|
|
@ -3115,6 +3399,33 @@ public sealed class HeadlessSessionHostTests
|
|||
BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
body.AsSpan(8, sizeof(uint)));
|
||||
|
||||
private static string TalkText(byte[] body)
|
||||
{
|
||||
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
|
||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
body.AsSpan(12, sizeof(ushort)));
|
||||
return System.Text.Encoding.ASCII.GetString(body, 14, length);
|
||||
}
|
||||
|
||||
private static string StringActionArgument(byte[] body)
|
||||
{
|
||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
body.AsSpan(12, sizeof(ushort)));
|
||||
return System.Text.Encoding.ASCII.GetString(body, 14, length);
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private long _timestamp;
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public override long GetTimestamp() => _timestamp;
|
||||
|
||||
internal void Advance(TimeSpan duration) =>
|
||||
_timestamp = checked(_timestamp + duration.Ticks);
|
||||
}
|
||||
|
||||
// SF-4 fixture: minimal PlayerDescription (0x0013) body carrying only
|
||||
// the CharacterOptions1/2 trailer fields — copied from
|
||||
// HeadlessCharacterOptionsSeederWiringTests.WrapPlayerDescriptionEnvelope
|
||||
|
|
@ -3162,6 +3473,7 @@ public sealed class HeadlessSessionHostTests
|
|||
public int DisposedSessionCount { get; private set; }
|
||||
public string? LastUser { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
public Action<byte[]>? GameActionCapture { get; init; }
|
||||
public int EnterWorldCallCount =>
|
||||
Volatile.Read(ref _enterWorldCallCount);
|
||||
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
||||
|
|
@ -3190,6 +3502,7 @@ public sealed class HeadlessSessionHostTests
|
|||
{
|
||||
CreatedSessionCount++;
|
||||
var session = new WorldSession(endpoint);
|
||||
session.GameActionCapture = GameActionCapture;
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,44 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginCommandFailureIsVisibleButDoesNotMakeTheSessionTerminal()
|
||||
{
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
statusSourceFactory: statusSources);
|
||||
_ = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||
source.Enqueue(Connected("s1"));
|
||||
source.Enqueue(EnteredWorld("s1", "+Acdream"));
|
||||
source.Enqueue(new LoginCommandFailedStatusEvent
|
||||
{
|
||||
V = 1,
|
||||
E = "loginCommandFailed",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = "s1",
|
||||
CommandIndex = 2,
|
||||
Command = "/version",
|
||||
Error = "not available in the headless host",
|
||||
});
|
||||
|
||||
orchestrator.PollStatus();
|
||||
|
||||
LauncherSessionSnapshot session = Assert.Single(
|
||||
orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.InWorld, session.State);
|
||||
Assert.Equal(
|
||||
"Login command 2 failed: not available in the headless host",
|
||||
session.Error);
|
||||
Assert.Equal(session.Error, session.Status);
|
||||
Assert.Null(session.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -77,6 +77,32 @@ public sealed class StatusEventParserTests
|
|||
Assert.Equal("boom", failed.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesLoginCommandFailed()
|
||||
{
|
||||
var failed = Assert.IsType<LoginCommandFailedStatusEvent>(
|
||||
StatusEventParser.Parse(
|
||||
"""{"v":1,"e":"loginCommandFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","commandIndex":2,"command":"/version","error":"unsupported headless command"}"""));
|
||||
|
||||
Assert.Equal(2, failed.CommandIndex);
|
||||
Assert.Equal("/version", failed.Command);
|
||||
Assert.Equal("unsupported headless command", failed.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":-1,\"command\":\"/version\",\"error\":\"unsupported\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"error\":\"unsupported\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"command\":\"/version\"}")]
|
||||
public void MalformedLoginCommandFailureUsesTheKnownEventFailurePath(string line)
|
||||
{
|
||||
var malformed = Assert.IsType<MalformedStatusEvent>(
|
||||
StatusEventParser.Parse(line));
|
||||
|
||||
Assert.Equal("loginCommandFailed", malformed.E);
|
||||
Assert.Equal("s1", malformed.SessionId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesDisconnectedAndExited()
|
||||
{
|
||||
|
|
|
|||
60
tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs
Normal file
60
tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Chat;
|
||||
|
||||
public sealed class ChatExtractionTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompleteChatCoreLivesInRuntimeAssembly()
|
||||
{
|
||||
Assembly runtime = typeof(GameRuntime).Assembly;
|
||||
Type[] extracted =
|
||||
[
|
||||
typeof(ChatInputParser),
|
||||
typeof(ChatCommandRouter),
|
||||
typeof(RetailClientCommandCatalog),
|
||||
typeof(RetailCommandHelpTable),
|
||||
typeof(RetailChannelTagTable),
|
||||
typeof(ChannelResolver),
|
||||
typeof(ICommandBus),
|
||||
typeof(LiveCommandBus),
|
||||
typeof(NullCommandBus),
|
||||
typeof(ChatChannelKind),
|
||||
typeof(ClientCommandId),
|
||||
typeof(SendChatCmd),
|
||||
typeof(SendServerCommandCmd),
|
||||
typeof(SendRawChannelCmd),
|
||||
typeof(ExecuteClientCommandCmd),
|
||||
];
|
||||
|
||||
Assert.All(extracted, type => Assert.Same(runtime, type.Assembly));
|
||||
Assert.DoesNotContain(
|
||||
runtime.GetReferencedAssemblies(),
|
||||
reference => reference.Name is "AcDream.App"
|
||||
or "AcDream.UI.Abstractions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouterDependsOnlyOnTheFourMemberFeedbackContract()
|
||||
{
|
||||
string[] members = typeof(IChatCommandFeedback)
|
||||
.GetMembers(BindingFlags.Instance | BindingFlags.Public)
|
||||
.Where(static member => member.MemberType is
|
||||
MemberTypes.Method or MemberTypes.Property)
|
||||
.Where(static member => member is not MethodInfo method
|
||||
|| !method.IsSpecialName)
|
||||
.Select(static member => member.Name)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"LastIncomingTellSender",
|
||||
"LastOutgoingTellTarget",
|
||||
"ShowInterfaceText",
|
||||
"ShowSystemMessage",
|
||||
],
|
||||
members);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Chat;
|
||||
|
||||
public sealed class LiveChatCommandRouteTests
|
||||
{
|
||||
[Fact]
|
||||
public void FourRegistrationsPreserveOrderedWireRoutesAndCanonicalEcho()
|
||||
{
|
||||
using var communication = new RuntimeCommunicationState();
|
||||
using var character = new RuntimeCharacterState();
|
||||
var sent = new List<string>();
|
||||
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
|
||||
command => sent.Add($"client:{command.Command}"),
|
||||
communication,
|
||||
communication.Chat,
|
||||
communication.TurbineChat,
|
||||
character,
|
||||
() => 0x50000001u,
|
||||
text => sent.Add($"talk:{text}"),
|
||||
(target, text) => sent.Add($"tell:{target}:{text}"),
|
||||
(channel, text) => sent.Add($"channel:{channel:X8}:{text}"),
|
||||
(_, _, _, _, text, _) => sent.Add($"turbine:{text}")));
|
||||
|
||||
route.Activate();
|
||||
route.Publish(new SendServerCommandCmd("@server"));
|
||||
route.Publish(new SendChatCmd(ChatChannelKind.Say, null, "say"));
|
||||
route.Publish(new SendChatCmd(ChatChannelKind.Tell, "Bob", "secret"));
|
||||
route.Publish(new SendChatCmd(
|
||||
ChatChannelKind.Fellowship,
|
||||
null,
|
||||
"group"));
|
||||
route.Publish(new SendRawChannelCmd(0x00000002u, "admin"));
|
||||
route.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.QueryAge,
|
||||
string.Empty));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"talk:@server",
|
||||
"talk:say",
|
||||
"tell:Bob:secret",
|
||||
"channel:00000800:group",
|
||||
"channel:00000002:admin",
|
||||
"client:QueryAge",
|
||||
],
|
||||
sent);
|
||||
ChatEntry echo = Assert.Single(communication.Chat.Snapshot());
|
||||
Assert.Equal(ChatKind.Tell, echo.Kind);
|
||||
Assert.Equal("Bob", echo.Sender);
|
||||
Assert.Equal("secret", echo.Text);
|
||||
|
||||
route.Dispose();
|
||||
route.Publish(new SendServerCommandCmd("@stale"));
|
||||
Assert.Equal(6, sent.Count);
|
||||
}
|
||||
}
|
||||
184
tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs
Normal file
184
tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Chat;
|
||||
|
||||
public sealed class LoginCommandSequenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnteredWorldExecutesImmediatelyThenHonorsMonotonicDelay()
|
||||
{
|
||||
var time = new ManualTimeProvider();
|
||||
var sent = new List<string>();
|
||||
var bus = TalkBus(sent);
|
||||
var sequence = new LoginCommandSequence(
|
||||
["one", "two", "three"],
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
new RecordingFeedback(),
|
||||
bus,
|
||||
timeProvider: time);
|
||||
var generation = new RuntimeGenerationToken(7);
|
||||
|
||||
sequence.EnteredWorld(generation);
|
||||
Assert.Equal(["one"], sent);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(499));
|
||||
sequence.Tick(generation, isInWorld: true);
|
||||
Assert.Equal(["one"], sent);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(1));
|
||||
sequence.Tick(generation, isInWorld: true);
|
||||
Assert.Equal(["one", "two"], sent);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(500));
|
||||
sequence.Tick(generation, isInWorld: true);
|
||||
Assert.Equal(["one", "two", "three"], sent);
|
||||
Assert.False(sequence.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlerRuntimeDoesNotConsumeTheInterCommandDelay()
|
||||
{
|
||||
var time = new ManualTimeProvider();
|
||||
var sent = new List<string>();
|
||||
var bus = new LiveCommandBus();
|
||||
bus.Register<SendChatCmd>(command =>
|
||||
{
|
||||
sent.Add(command.Text);
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
});
|
||||
var sequence = new LoginCommandSequence(
|
||||
["one", "two"],
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
new RecordingFeedback(),
|
||||
bus,
|
||||
timeProvider: time);
|
||||
var generation = new RuntimeGenerationToken(7);
|
||||
|
||||
sequence.EnteredWorld(generation);
|
||||
Assert.Equal(["one"], sent);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(499));
|
||||
sequence.Tick(generation, isInWorld: true);
|
||||
Assert.Equal(["one"], sent);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(1));
|
||||
sequence.Tick(generation, isInWorld: true);
|
||||
Assert.Equal(["one", "two"], sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAndHandlerFailuresAndStatusFailureAllContinue()
|
||||
{
|
||||
var sent = new List<string>();
|
||||
var bus = TalkBus(sent);
|
||||
bus.Register<ExecuteClientCommandCmd>(_ =>
|
||||
throw new InvalidOperationException("client boom"));
|
||||
int reports = 0;
|
||||
var sequence = new LoginCommandSequence(
|
||||
["/", "/version", "after"],
|
||||
TimeSpan.Zero,
|
||||
new RecordingFeedback(),
|
||||
bus,
|
||||
_ =>
|
||||
{
|
||||
reports++;
|
||||
throw new IOException("status unavailable");
|
||||
});
|
||||
|
||||
sequence.EnteredWorld(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.Equal(2, reports);
|
||||
Assert.Equal(["after"], sent);
|
||||
Assert.False(sequence.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAndGenerationReplacementPreventLeaksAndReconnectRestarts()
|
||||
{
|
||||
var time = new ManualTimeProvider();
|
||||
var sent = new List<string>();
|
||||
var sequence = new LoginCommandSequence(
|
||||
["one", "two"],
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
new RecordingFeedback(),
|
||||
TalkBus(sent),
|
||||
timeProvider: time);
|
||||
var first = new RuntimeGenerationToken(1);
|
||||
var second = new RuntimeGenerationToken(2);
|
||||
|
||||
sequence.EnteredWorld(first);
|
||||
sequence.Cancel(first);
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
sequence.Tick(first, isInWorld: true);
|
||||
sequence.EnteredWorld(first); // exact-once per generation
|
||||
Assert.Equal(["one"], sent);
|
||||
|
||||
sequence.EnteredWorld(second);
|
||||
sequence.Tick(first, isInWorld: true); // stale frame is inert
|
||||
time.Advance(TimeSpan.FromMilliseconds(500));
|
||||
sequence.Tick(second, isInWorld: true);
|
||||
|
||||
Assert.Equal(["one", "one", "two"], sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullAndEmptyCollectionsAreNoOpsAndNullEntriesTypeAsEmpty()
|
||||
{
|
||||
var feedback = new RecordingFeedback();
|
||||
var bus = TalkBus([]);
|
||||
var absent = new LoginCommandSequence(
|
||||
null,
|
||||
TimeSpan.Zero,
|
||||
feedback,
|
||||
bus);
|
||||
var empty = new LoginCommandSequence(
|
||||
[],
|
||||
TimeSpan.Zero,
|
||||
feedback,
|
||||
bus);
|
||||
var nullEntry = new LoginCommandSequence(
|
||||
[null],
|
||||
TimeSpan.Zero,
|
||||
feedback,
|
||||
bus);
|
||||
|
||||
absent.EnteredWorld(new RuntimeGenerationToken(1));
|
||||
empty.EnteredWorld(new RuntimeGenerationToken(1));
|
||||
nullEntry.EnteredWorld(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.False(absent.IsActive);
|
||||
Assert.False(empty.IsActive);
|
||||
Assert.False(nullEntry.IsActive);
|
||||
}
|
||||
|
||||
private static LiveCommandBus TalkBus(List<string> sent)
|
||||
{
|
||||
var bus = new LiveCommandBus();
|
||||
bus.Register<SendChatCmd>(command => sent.Add(command.Text));
|
||||
bus.Register<SendServerCommandCmd>(command => sent.Add(command.Text));
|
||||
bus.Register<SendRawChannelCmd>(_ => { });
|
||||
return bus;
|
||||
}
|
||||
|
||||
private sealed class RecordingFeedback : IChatCommandFeedback
|
||||
{
|
||||
public string? LastIncomingTellSender { get; set; }
|
||||
public string? LastOutgoingTellTarget { get; set; }
|
||||
public List<string> Interface { get; } = [];
|
||||
public List<string> System { get; } = [];
|
||||
public void ShowInterfaceText(string text) => Interface.Add(text);
|
||||
public void ShowSystemMessage(string text) => System.Add(text);
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private long _timestamp;
|
||||
|
||||
public override long TimestampFrequency => 1_000;
|
||||
public override long GetTimestamp() => _timestamp;
|
||||
|
||||
public void Advance(TimeSpan duration) =>
|
||||
_timestamp += checked((long)duration.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Net;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Session;
|
||||
|
|
@ -74,6 +75,37 @@ public sealed class LiveSessionHostTests
|
|||
Assert.False(host.IsInWorld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginSequenceStartsAfterTheEnteredWorldObservation()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var bus = new LiveCommandBus();
|
||||
bus.Register<SendChatCmd>(command => calls.Add($"login:{command.Text}"));
|
||||
var loginCommands = new LoginCommandSequence(
|
||||
["ready"],
|
||||
TimeSpan.Zero,
|
||||
new TestFeedback(),
|
||||
bus);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => new TestEventRouting(calls),
|
||||
_ => new TestCommandRouting(calls),
|
||||
loginCommands);
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Connected,
|
||||
host.Start(LiveOptions()).Status);
|
||||
|
||||
int entered = calls.IndexOf("character-entered:1342177282");
|
||||
int login = calls.IndexOf("login:ready");
|
||||
Assert.True(entered >= 0);
|
||||
Assert.Equal(entered + 1, login);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
|
||||
{
|
||||
|
|
@ -219,7 +251,8 @@ public sealed class LiveSessionHostTests
|
|||
LiveSessionController controller,
|
||||
List<string> calls,
|
||||
Func<WorldSession, ILiveSessionEventRouting> createEvents,
|
||||
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
||||
Func<WorldSession, ILiveSessionCommandRouting> createCommands,
|
||||
LoginCommandSequence? loginCommands = null) =>
|
||||
new(controller, new LiveSessionHostBindings(
|
||||
Routing: new(createEvents, createCommands),
|
||||
Reset: _ => calls.Add("reset"),
|
||||
|
|
@ -240,7 +273,8 @@ public sealed class LiveSessionHostTests
|
|||
Connected: () => calls.Add("connected"),
|
||||
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||
CharacterEntered: selection =>
|
||||
calls.Add($"character-entered:{selection.CharacterId}")));
|
||||
calls.Add($"character-entered:{selection.CharacterId}"),
|
||||
LoginCommands: loginCommands));
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions(
|
||||
bool live = true,
|
||||
|
|
@ -291,6 +325,14 @@ public sealed class LiveSessionHostTests
|
|||
public void Dispose() => calls.Add("dispose-commands");
|
||||
}
|
||||
|
||||
private sealed class TestFeedback : IChatCommandFeedback
|
||||
{
|
||||
public string? LastIncomingTellSender => null;
|
||||
public string? LastOutgoingTellTarget => null;
|
||||
public void ShowInterfaceText(string text) { }
|
||||
public void ShowSystemMessage(string text) { }
|
||||
}
|
||||
|
||||
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ public sealed class SessionStatusWriterTests
|
|||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "enable failed");
|
||||
writer.LoginCommandFailed("s1", 2, "/version", "unsupported headless command");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(8, lines.Length);
|
||||
Assert.Equal(9, lines.Length);
|
||||
|
||||
JsonElement started = Parse(lines[0]);
|
||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||
|
|
@ -73,11 +74,25 @@ public sealed class SessionStatusWriterTests
|
|||
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
|
||||
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[6]);
|
||||
JsonElement loginCommandFailed = Parse(lines[6]);
|
||||
Assert.Equal(1, loginCommandFailed.GetProperty("v").GetInt32());
|
||||
Assert.Equal("loginCommandFailed", loginCommandFailed.GetProperty("e").GetString());
|
||||
Assert.Equal("s1", loginCommandFailed.GetProperty("sessionId").GetString());
|
||||
Assert.Equal(2, loginCommandFailed.GetProperty("commandIndex").GetInt32());
|
||||
Assert.Equal("/version", loginCommandFailed.GetProperty("command").GetString());
|
||||
Assert.Equal(
|
||||
"unsupported headless command",
|
||||
loginCommandFailed.GetProperty("error").GetString());
|
||||
Assert.Equal(
|
||||
["v", "e", "t", "sessionId", "commandIndex", "command", "error"],
|
||||
loginCommandFailed.EnumerateObject()
|
||||
.Select(static property => property.Name));
|
||||
|
||||
JsonElement disconnected = Parse(lines[7]);
|
||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||
|
||||
JsonElement exited = Parse(lines[7]);
|
||||
JsonElement exited = Parse(lines[8]);
|
||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||
|
|
@ -93,6 +108,7 @@ public sealed class SessionStatusWriterTests
|
|||
writer.Connected("s1");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "failed");
|
||||
writer.LoginCommandFailed("s1", 0, "", "unknown command");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
|
|
@ -199,11 +215,12 @@ public sealed class SessionStatusWriterTests
|
|||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("bot", "acdream.good");
|
||||
writer.PluginFailed("bot", "acdream.bad", "enable failed");
|
||||
writer.LoginCommandFailed("bot", 1, "/version", "unsupported");
|
||||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(8, lines.Length);
|
||||
Assert.Equal(9, lines.Length);
|
||||
|
||||
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||
|
|
@ -215,8 +232,11 @@ public sealed class SessionStatusWriterTests
|
|||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
|
||||
AssertExactProperties(
|
||||
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
|
||||
AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
|
||||
AssertExactProperties(
|
||||
lines[6],
|
||||
"v", "e", "t", "sessionId", "commandIndex", "command", "error");
|
||||
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[8], "v", "e", "t", "sessionId", "code", "reason");
|
||||
|
||||
// The nested characters[] entries are exact too — the exact shape a
|
||||
// password could otherwise be smuggled through.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AcDream.Runtime.Chat" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|||
/// </summary>
|
||||
public sealed class ChatPanelFocusTests
|
||||
{
|
||||
private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus
|
||||
private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ public sealed class ChatPanelInputTests
|
|||
var entries = log.Snapshot();
|
||||
Assert.Equal(2, entries.Length);
|
||||
Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind));
|
||||
Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
||||
Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
||||
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
||||
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue