diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 31ec860b..2f0f0f5a 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e4dedd97..e92876b8 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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 diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 0de911ff..f8ecaa5b 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -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 diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index fb4a37d6..954a65fe 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -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 diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 95ade180..f84a8a6e 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -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, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index a2812b93..b4a214bd 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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( diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index 2a0359be..ec225785 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -115,12 +115,12 @@ internal sealed record SessionDescriptor /// allow-list semantics for this field (OP7 D8). public Dictionary? CharacterOptions { get; init; } - /// LA1: plugin ids to load. Absent = load all (LA5 consumes - /// this; parsed and carried here now per the pinned launch contract). + /// LA1/LA5: plugin ids to load. Absent = load all; explicit + /// empty = load none. public List? Plugins { get; init; } - /// LA1: ordered chat-typed strings run after entering world - /// (LA6 consumes this; parsed and carried here now). + /// LA1/LA6: ordered chat-typed strings run through the shared + /// Runtime parser/router after entering world. public List? LoginCommands { get; init; } /// LA1: inter-command delay for , diff --git a/src/AcDream.App/GlobalUsings.cs b/src/AcDream.App/GlobalUsings.cs index 5213b6cc..83a9ab4d 100644 --- a/src/AcDream.App/GlobalUsings.cs +++ b/src/AcDream.App/GlobalUsings.cs @@ -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; diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index dd59d9f6..50f04757 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource { - AcDream.UI.Abstractions.ICommandBus Commands { get; } + AcDream.Runtime.Chat.ICommandBus Commands { get; } } diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 0b0b44d4..2d781197 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -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(clientCommands.Execute); - commands.Register(command => - { - if (!string.IsNullOrEmpty(command.Text)) - SendIfActive(() => bindings.SendTalk(command.Text)); - }); - commands.Register(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( - command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text))); commands.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( @@ -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(); } - /// - /// The seven values that ride Turbine - /// (0xF7DE), mapped to the lighter - /// reads. Every OTHER channel - /// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast) - /// is legacy-only (0x0147) — those pipelines never overlap Turbine. - /// is the one exception, and - /// 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 /a is bound to the - /// LEGACY AllegianceBroadcast bitflag by default and is only - /// rebound to DoTurbineChat_Allegiance once - /// StartupTurbineChatSystem successfully starts Turbine chat - /// (research doc §4.3). So "Turbine never started" (TurbineChat. - /// Enabled == false) still falls back to legacy, while "Turbine is - /// up but this character has no allegiance room" (Enabled == true, - /// AllegianceRoom == 0) correctly keeps retail's local - /// "Turbine chat is not available." refusal at the membership gate. - /// - private static readonly Dictionary 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); - } - - /// - /// Step 2 of the CH3 fix list: retail - /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local - /// membership gate, raised through the same - /// RuntimeCommunicationState.AddText chokepoint CH2 built for - /// every other client-raised refusal. - /// - 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()), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 805d0170..8a9e3886 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -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 _loginCommands; + private readonly TimeSpan _loginCommandDelay; + private readonly TimeProvider _timeProvider; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -116,7 +120,10 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionCommandSurface commands, Action log, SessionStatusWriter? statusWriter = null, - string sessionId = "app") + string sessionId = "app", + IReadOnlyList? 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); } diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index b0c4bd72..91772711 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -87,10 +87,10 @@ public sealed record RuntimeOptions( string? StatusFilePath, /// Campaign LA slice LA1: plugin ids to load. /// = load every discovered plugin (today's - /// behavior). Consumed by LA5; parsed and carried now. + /// behavior). Consumed by the shared graphical plugin session. IReadOnlyList? Plugins, /// Campaign LA slice LA1: ordered chat-typed strings run once - /// entered-world. Consumed by LA6; parsed and carried now. + /// entered-world through the shared Runtime parser/router. IReadOnlyList LoginCommands, /// Campaign LA slice LA1: inter-command delay for /// , milliseconds. diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 244258ea..182f5f8b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// Widget tree from . /// Chat view-model (transcript data + command routing). /// Factory that returns the live command bus at submit time. - /// Called on every chat submit so it resolves + /// Called on every chat submit so it resolves /// even when the live session is established AFTER runs /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). /// Runtime's canonical per-window filter/open state diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index fdae1225..6bf5f0e1 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -99,16 +99,15 @@ internal sealed record HeadlessSessionDescriptor /// /// Campaign LA slice LA1: plugin ids to load from the standard plugins /// directory (docs/plans/2026-08-14-launcher-campaign.md 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. /// public List? Plugins { get; init; } /// - /// 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. /// public List? LoginCommands { get; init; } @@ -123,8 +122,7 @@ internal sealed record HeadlessSessionDescriptor /// /// Campaign LA slice LA1: absolute path for this session's status-event /// JSONL stream (docs/superpowers/specs/2026-08-14-launcher-campaign-design.md - /// §6). Absent means no - /// is constructed for this session. + /// §6). Absent selects the writer's permanent no-op mode. /// public string? StatusFile { get; init; } } diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 27686c1e..389b2a6f 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -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 /// docs/plans/2026-08-14-launcher-campaign.md LA1's pinned - /// contract). All four stay optional; only their SHAPE is checked here - /// — parsing/executing plugins/loginCommands 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. /// private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session) { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 9e025f61..5219f94c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -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? 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? failures) + { + if (route is not { } current) + return; + + try + { + current.Dispose(); + route = null; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } } private sealed class SessionCommandBridge : IRuntimeSessionCommands @@ -301,6 +324,18 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); + var chatCommandSurface = new LiveChatCommandSurface(); + var loginCommands = new LoginCommandSequence( + descriptor.LoginCommands, + TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), + new RuntimeChatCommandFeedback(runtime.CommunicationOwner), + chatCommandSurface, + failure => statusWriter.LoginCommandFailed( + descriptor.Id, + failure.CommandIndex, + failure.Command, + failure.Error), + _timeProvider); pluginSession = HeadlessPluginSession.Create( runtime, diagnostics, @@ -315,7 +350,12 @@ internal sealed class HeadlessSessionHost : IDisposable CreateEventRoute, session => new SessionCommandRoute( gameplay.CreateRoute(session), - commands.CreateRoute(session))), + commands.CreateRoute(session), + chatCommandSurface.Attach( + new LiveChatCommandRoute( + CreateChatCommandBindings( + session, + runtime))))), generation => runtime.ResetGeneration(generation, _resetHost), new LiveSessionSelectionBindings( @@ -368,7 +408,8 @@ internal sealed class HeadlessSessionHost : IDisposable selection => statusWriter.EnteredWorld( descriptor.Id, selection.CharacterId, - selection.CharacterName))); + selection.CharacterName), + loginCommands)); Runtime = runtime; Commands = commands; @@ -506,7 +547,7 @@ internal sealed class HeadlessSessionHost : IDisposable _ = Runtime.Clock.Advance(deltaSeconds); _localPlayerFrame.AdvanceBeforeNetwork( checked((float)deltaSeconds)); - Runtime.Session.Tick(); + _liveSession.Tick(); // C3c: pump pending first-entry sequences after the network drain — // collision-generation progress and freshly accepted Creates both // surface here, mirroring the graphical per-frame retry phase. @@ -821,6 +862,142 @@ internal sealed class HeadlessSessionHost : IDisposable } } + private LiveChatCommandBindings CreateChatCommandBindings( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime) => new( + ExecuteClientCommand: command => + ExecuteHeadlessClientCommand(session, runtime, command), + Communication: runtime.CommunicationOwner, + Chat: runtime.CommunicationOwner.Chat, + TurbineChat: runtime.CommunicationOwner.TurbineChat, + CharacterState: runtime.CharacterOwner, + PlayerGuid: () => runtime.PlayerIdentity.ServerGuid, + SendTalk: session.SendTalk, + SendTell: session.SendTell, + SendChannel: session.SendChannel, + SendTurbineChat: session.SendTurbineChatTo, + Log: message => _diagnostics.Message( + _descriptor.Id, + message, + runtime.Generation.Value)); + + /// + /// 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. + /// + private static void ExecuteHeadlessClientCommand( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime, + ExecuteClientCommandCmd command) + { + switch (command.Command) + { + case ClientCommandId.LifestoneRecall: + session.SendTeleportToLifestone(); + return; + case ClientCommandId.MarketplaceRecall: + session.SendTeleportToMarketplace(); + return; + case ClientCommandId.PkArenaRecall: + session.SendTeleportToPkArena(); + return; + case ClientCommandId.PkLiteArenaRecall: + session.SendTeleportToPkLiteArena(); + return; + case ClientCommandId.EnterPkLite: + session.SendEnterPkLite(); + return; + case ClientCommandId.HouseRecall: + session.SendTeleportToHouse(); + return; + case ClientCommandId.MansionRecall: + session.SendTeleportToMansion(); + return; + case ClientCommandId.QueryAge: + session.SendQueryAge(); + return; + case ClientCommandId.QueryBirth: + session.SendQueryBirth(); + return; + case ClientCommandId.Emote + when !string.IsNullOrWhiteSpace(command.Arguments): + session.SendEmote(command.Arguments.Trim()); + return; + case ClientCommandId.ClearChat: + runtime.CommunicationOwner.Chat.Clear(); + return; + case ClientCommandId.IndexChannels: + session.SendIndexChannels(); + return; + case ClientCommandId.ListChannel: + SendResolvedChannel( + command.Arguments, + session.SendListChannel); + return; + case ClientCommandId.OnChannel: + SendResolvedChannel( + command.Arguments, + session.SendOnChannel); + return; + case ClientCommandId.OffChannel: + SendResolvedChannel( + command.Arguments, + session.SendOffChannel); + return; + case ClientCommandId.AllegianceHometown: + session.SendRecallAllegianceHometown(); + return; + case ClientCommandId.AllegianceInfo: + session.SendAllegianceInfoRequest(command.Arguments.Trim()); + return; + case ClientCommandId.HouseAvailableList + when RetailClientCommandCatalog.TryResolveHouseType( + command.Arguments, + out uint houseType): + session.SendListAvailableHouses(houseType); + return; + case ClientCommandId.JoinChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint joinOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + joinOption, + true, + session.SendSetSingleCharacterOption); + return; + case ClientCommandId.LeaveChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint leaveOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + leaveOption, + false, + session.SendSetSingleCharacterOption); + return; + default: + throw new NotSupportedException( + $"Client command '{command.Command}' is not available " + + "in the headless host."); + } + + static void SendResolvedChannel( + string arguments, + Action send) + { + if (!RetailChannelTagTable.TryResolve( + arguments.Trim(), + out uint channelId)) + { + throw new InvalidOperationException( + $"Chat channel '{arguments.Trim()}' does not exist."); + } + send(channelId); + } + } + private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index 57d344c9..358dc4f9 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -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) { diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index c090b6e3..2efbf5a9 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -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; } diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index f6811a60..4f5000da 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -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, diff --git a/src/AcDream.UI.Abstractions/ChannelResolver.cs b/src/AcDream.Runtime/Chat/ChannelResolver.cs similarity index 98% rename from src/AcDream.UI.Abstractions/ChannelResolver.cs rename to src/AcDream.Runtime/Chat/ChannelResolver.cs index f357efc5..0c204fc4 100644 --- a/src/AcDream.UI.Abstractions/ChannelResolver.cs +++ b/src/AcDream.Runtime/Chat/ChannelResolver.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Maps a to the legacy ChatChannel diff --git a/src/AcDream.UI.Abstractions/ChatChannelKind.cs b/src/AcDream.Runtime/Chat/ChatChannelKind.cs similarity index 98% rename from src/AcDream.UI.Abstractions/ChatChannelKind.cs rename to src/AcDream.Runtime/Chat/ChatChannelKind.cs index a1861c5a..63060253 100644 --- a/src/AcDream.UI.Abstractions/ChatChannelKind.cs +++ b/src/AcDream.Runtime/Chat/ChatChannelKind.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Outbound chat channel selector. Mirrors holtburger's ChatChannelKind diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs similarity index 84% rename from src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs rename to src/AcDream.Runtime/Chat/ChatCommandRouter.cs index 9b3c090b..f370f176 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs @@ -2,7 +2,7 @@ using System; using System.Linq; using AcDream.Core.Chat; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// What a submit did, so the caller can clear its input + give feedback. /// UnknownCommand is produced only for command-shaped but verbless @@ -11,8 +11,8 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// /// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @ -/// 0x004F5100 analogue). Both the ImGui devtools -/// and retained retail chat window route through here. +/// 0x004F5100 analogue). Every graphical and headless chat entrance routes +/// through here. /// /// /// Flow: emote-prefix rewrite, retail client-command catalog, local @@ -20,17 +20,20 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// ChannelSystem::GetChannelID fallback (unregistered GM/faction /// channel tags), explicit server command, then chat parse. Unknown /// slash/at verbs publish in canonical -/// @ 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. +/// @ 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. /// /// 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. /// - 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 's /// class remarks for the full print-sequence trace). /// - 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); } /// @@ -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. /// - 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) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.Runtime/Chat/ChatInputParser.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs rename to src/AcDream.Runtime/Chat/ChatInputParser.cs index ae9a900b..24546e74 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.Runtime/Chat/ChatInputParser.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Phase I.4: pure-function parse of a chat-input line into the diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.Runtime/Chat/ClientCommandId.cs similarity index 95% rename from src/AcDream.UI.Abstractions/ClientCommandId.cs rename to src/AcDream.Runtime/Chat/ClientCommandId.cs index d4223d63..42aee416 100644 --- a/src/AcDream.UI.Abstractions/ClientCommandId.cs +++ b/src/AcDream.Runtime/Chat/ClientCommandId.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Backend-neutral identity for a command that retail executes in the client @@ -85,8 +85,8 @@ public enum ClientCommandId /// /// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with /// any subcommand beyond the 2 ported ones (info, hometown/ho). Never - /// dispatched — is always + /// dispatched — + /// is always /// false for this id, so ChatCommandRouter shows retail's own /// "Please see @help Allegiance..." refusal and never publishes an /// ExecuteClientCommandCmd. diff --git a/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs b/src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs similarity index 89% rename from src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs rename to src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs index b5e95079..01e607d6 100644 --- a/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs +++ b/src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// User intent to execute a retail client command. The application host owns diff --git a/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs b/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs new file mode 100644 index 00000000..0536569a --- /dev/null +++ b/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs @@ -0,0 +1,17 @@ +namespace AcDream.Runtime.Chat; + +/// +/// 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. +/// +public interface IChatCommandFeedback +{ + void ShowInterfaceText(string text); + + void ShowSystemMessage(string text); + + string? LastIncomingTellSender { get; } + + string? LastOutgoingTellTarget { get; } +} diff --git a/src/AcDream.UI.Abstractions/ICommandBus.cs b/src/AcDream.Runtime/Chat/ICommandBus.cs similarity index 96% rename from src/AcDream.UI.Abstractions/ICommandBus.cs rename to src/AcDream.Runtime/Chat/ICommandBus.cs index 71742969..d6ddf82b 100644 --- a/src/AcDream.UI.Abstractions/ICommandBus.cs +++ b/src/AcDream.Runtime/Chat/ICommandBus.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Publishes user-intent commands from panels to the systems that handle diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs new file mode 100644 index 00000000..827f3348 --- /dev/null +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -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; + +/// +/// Exact live-session dependencies for the four chat-core command records. +/// Both graphical and no-window hosts bind this record to the active +/// WorldSession's send methods and the same canonical Runtime state. +/// +public sealed record LiveChatCommandBindings( + Action ExecuteClientCommand, + RuntimeCommunicationState Communication, + ChatLog Chat, + TurbineChatState TurbineChat, + RuntimeCharacterState CharacterState, + Func PlayerGuid, + Action SendTalk, + Action SendTell, + Action SendChannel, + Action SendTurbineChat, + Action? Log = null); + +/// +/// 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. +/// +public sealed class LiveChatCommandRoute + : ILiveSessionCommandRouting, + ICommandBus +{ + private static readonly Dictionary + 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(command => + SendIfActive(() => bindings.ExecuteClientCommand(command))); + commands.Register(command => + { + if (!string.IsNullOrEmpty(command.Text)) + SendIfActive(() => bindings.SendTalk(command.Text)); + }); + commands.Register(command => RouteChat(bindings, command)); + commands.Register(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 command) where T : notnull + { + if (!TryPublish(command)) + { + Console.WriteLine( + $"[LiveChatCommandRoute] unsupported command type " + + $"{typeof(T).FullName}; dropping."); + } + } + + /// + /// Routes one of the four extracted command records and returns + /// . Other records are left to a host's sibling + /// command router and return without logging. + /// + public bool TryPublish(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; + } + } +} + +/// +/// Stable host-owned bus over a replaceable generation route. A retained +/// login-command runner never captures an obsolete transport. +/// +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 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; + } + } + } +} diff --git a/src/AcDream.UI.Abstractions/LiveCommandBus.cs b/src/AcDream.Runtime/Chat/LiveCommandBus.cs similarity index 98% rename from src/AcDream.UI.Abstractions/LiveCommandBus.cs rename to src/AcDream.Runtime/Chat/LiveCommandBus.cs index cb260c8e..4480dfba 100644 --- a/src/AcDream.UI.Abstractions/LiveCommandBus.cs +++ b/src/AcDream.Runtime/Chat/LiveCommandBus.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Real implementation — single-handler-per-type diff --git a/src/AcDream.Runtime/Chat/LoginCommandSequence.cs b/src/AcDream.Runtime/Chat/LoginCommandSequence.cs new file mode 100644 index 00000000..36aeb461 --- /dev/null +++ b/src/AcDream.Runtime/Chat/LoginCommandSequence.cs @@ -0,0 +1,170 @@ +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Chat; + +public readonly record struct LoginCommandFailure( + int CommandIndex, + string Command, + string Error); + +/// +/// 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. +/// +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 _onFailure; + private RuntimeGenerationToken _generation; + private RuntimeGenerationToken? _lastStartedGeneration; + private long _nextDeadline; + private int _nextIndex; + private bool _active; + + public LoginCommandSequence( + IEnumerable? commands, + TimeSpan delay, + IChatCommandFeedback feedback, + ICommandBus bus, + Action? 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; + + /// + /// 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. + /// + 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)); + } +} diff --git a/src/AcDream.UI.Abstractions/NullCommandBus.cs b/src/AcDream.Runtime/Chat/NullCommandBus.cs similarity index 94% rename from src/AcDream.UI.Abstractions/NullCommandBus.cs rename to src/AcDream.Runtime/Chat/NullCommandBus.cs index 2c111ea8..23500406 100644 --- a/src/AcDream.UI.Abstractions/NullCommandBus.cs +++ b/src/AcDream.Runtime/Chat/NullCommandBus.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// No-op . Accepts any published command and diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs b/src/AcDream.Runtime/Chat/RetailChannelTagTable.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs rename to src/AcDream.Runtime/Chat/RetailChannelTagTable.cs index eb84802e..deee76fe 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs +++ b/src/AcDream.Runtime/Chat/RetailChannelTagTable.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Retail's ChannelSystem::GetChannelID @ 0x005CF1F0 — every legacy diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs rename to src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs index 6b24283c..f819526d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Immutable catalog of commands the named retail client executes locally. diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs rename to src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs index 9c27ab27..4e07b679 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs @@ -1,7 +1,7 @@ using System.Collections.Frozen; using AcDream.Core.Chat; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Campaign CH slice CH4 (2026-08-09): /help <verb> text for @@ -213,11 +213,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// /// Issue #363 (2026-08-10): ChatCommandRouter now routes this /// fallback (and every other 0x1A command-refusal call site) through -/// ChatVM.ShowInterfaceText — an optional hook the App-layer host +/// IChatCommandFeedback.ShowInterfaceText — an optional hook the host /// wires to RuntimeCommunicationState.AddText, 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 +/// ChatVM implements this four-member feedback seam without entering +/// command-routing code. Closes ISSUES.md #367 and retires register row +/// AP-186. /// /// 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"; diff --git a/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs b/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs new file mode 100644 index 00000000..f6e82fa7 --- /dev/null +++ b/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs @@ -0,0 +1,32 @@ +using AcDream.Core.Chat; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Chat; + +/// +/// 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. +/// +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); +} diff --git a/src/AcDream.UI.Abstractions/SendChatCmd.cs b/src/AcDream.Runtime/Chat/SendChatCmd.cs similarity index 93% rename from src/AcDream.UI.Abstractions/SendChatCmd.cs rename to src/AcDream.Runtime/Chat/SendChatCmd.cs index 6b5d9501..7e76f421 100644 --- a/src/AcDream.UI.Abstractions/SendChatCmd.cs +++ b/src/AcDream.Runtime/Chat/SendChatCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Command published by chat panels to send a message. The host resolves diff --git a/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs b/src/AcDream.Runtime/Chat/SendRawChannelCmd.cs similarity index 86% rename from src/AcDream.UI.Abstractions/SendRawChannelCmd.cs rename to src/AcDream.Runtime/Chat/SendRawChannelCmd.cs index 1aaea51e..4454fa3b 100644 --- a/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs +++ b/src/AcDream.Runtime/Chat/SendRawChannelCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy ChatChannel @@ -11,7 +11,7 @@ namespace AcDream.UI.Abstractions; /// (GM/faction channels like @admin, @sentinel, /// @celestialhand) plus the argument channel-tag resolution /// @clist/@on/@off use. See -/// for the tag→id table. +/// for the tag→id table. /// /// public sealed record SendRawChannelCmd(uint ChannelId, string Text); diff --git a/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs b/src/AcDream.Runtime/Chat/SendServerCommandCmd.cs similarity index 89% rename from src/AcDream.UI.Abstractions/SendServerCommandCmd.cs rename to src/AcDream.Runtime/Chat/SendServerCommandCmd.cs index b53aa807..cea17173 100644 --- a/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs +++ b/src/AcDream.Runtime/Chat/SendServerCommandCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Command text owned by the connected server rather than the retail client. diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 62cf5441..1782f9d4 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -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( /// 's narrow SetActiveCharacter(string) /// fan-out, this exists so a status writer can emit the /// enteredWorld event's characterId field. - Action CharacterEntered); + Action CharacterEntered, + LoginCommandSequence? LoginCommands = null); /// /// Runtime host for the one canonical . @@ -47,7 +49,9 @@ public sealed record LiveSessionHostBindings( /// composition, but never mirrors session, generation, identity, routing, or /// command state. /// -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 _characterEntered; private readonly Action _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); + /// + /// 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. + /// + 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( diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index 2b3461fa..c5a01d95 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -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) diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj index 2f4ca833..79dc2e2c 100644 --- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -11,5 +11,6 @@ + diff --git a/src/AcDream.UI.Abstractions/GlobalUsings.cs b/src/AcDream.UI.Abstractions/GlobalUsings.cs new file mode 100644 index 00000000..8a503f36 --- /dev/null +++ b/src/AcDream.UI.Abstractions/GlobalUsings.cs @@ -0,0 +1 @@ +global using AcDream.Runtime.Chat; diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 02a71e50..09adc96e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -21,7 +21,7 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// unchanged transcript does not require a queue snapshot each frame. /// /// -public sealed class ChatVM : IDisposable +public sealed class ChatVM : IDisposable, IChatCommandFeedback { /// Default number of tail entries rendered. public const int DefaultDisplayLimit = 20; diff --git a/tests/AcDream.App.Tests/GlobalUsings.cs b/tests/AcDream.App.Tests/GlobalUsings.cs index 5213b6cc..83a9ab4d 100644 --- a/tests/AcDream.App.Tests/GlobalUsings.cs +++ b/tests/AcDream.App.Tests/GlobalUsings.cs @@ -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; diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index c0cf97fc..2812f7bf 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -6,12 +6,54 @@ 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(); + 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 InactiveAndDisposedRouter_CannotReachTransport() { diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index c2f332ef..44ace1c9 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -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 plugins, - string statusPath) => new() + string statusPath, + IReadOnlyList? 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( diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 1834cb35..8dabeb98 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -24,6 +24,165 @@ namespace AcDream.Headless.Tests; public sealed class HeadlessSessionHostTests { + [Fact] + public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder() + { + var captured = new List(); + 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 LoginCommandFailuresAreVersionedOrderedAndSessionIsolated() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-login-commands-{Guid.NewGuid():N}.jsonl"); + try + { + var captured = new List(); + 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(); + 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 +2611,9 @@ public sealed class HeadlessSessionHostTests HeadlessCredentialProviderKind.Environment, string credentialReference = "BOT_PASSWORD", Dictionary? characterOptions = null, - string? statusFile = null) => new() + string? statusFile = null, + IReadOnlyList? loginCommands = null, + int loginCommandDelayMs = 500) => new() { Id = "bot", Endpoint = new HeadlessEndpointDescriptor @@ -2476,6 +2637,8 @@ public sealed class HeadlessSessionHostTests }, CharacterOptions = characterOptions, StatusFile = statusFile, + LoginCommands = loginCommands is null ? null : [.. loginCommands], + LoginCommandDelayMs = loginCommandDelayMs, }; /// Campaign LA slice LA2: a probe-mode descriptor — mode @@ -3115,6 +3278,26 @@ 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 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 +3345,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? GameActionCapture { get; init; } public int EnterWorldCallCount => Volatile.Read(ref _enterWorldCallCount); public int TickCallCount => Volatile.Read(ref _tickCallCount); @@ -3190,6 +3374,7 @@ public sealed class HeadlessSessionHostTests { CreatedSessionCount++; var session = new WorldSession(endpoint); + session.GameActionCapture = GameActionCapture; Sessions.Add(session); return session; } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 57ab95ed..54cb66d2 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -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() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index 47f5e118..2055ae92 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -77,6 +77,32 @@ public sealed class StatusEventParserTests Assert.Equal("boom", failed.Error); } + [Fact] + public void ParsesLoginCommandFailed() + { + var failed = Assert.IsType( + 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( + StatusEventParser.Parse(line)); + + Assert.Equal("loginCommandFailed", malformed.E); + Assert.Equal("s1", malformed.SessionId); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + [Fact] public void ParsesDisconnectedAndExited() { diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs new file mode 100644 index 00000000..b4d391f6 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs new file mode 100644 index 00000000..04d8fa79 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs @@ -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(); + 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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs b/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs new file mode 100644 index 00000000..9ff7a7e8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs @@ -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(); + 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(); + var bus = new LiveCommandBus(); + bus.Register(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(); + var bus = TalkBus(sent); + bus.Register(_ => + 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(); + 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 sent) + { + var bus = new LiveCommandBus(); + bus.Register(command => sent.Add(command.Text)); + bus.Register(command => sent.Add(command.Text)); + bus.Register(_ => { }); + return bus; + } + + private sealed class RecordingFeedback : IChatCommandFeedback + { + public string? LastIncomingTellSender { get; set; } + public string? LastOutgoingTellTarget { get; set; } + public List Interface { get; } = []; + public List 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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 31373a74..cda56c90 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -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(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var bus = new LiveCommandBus(); + bus.Register(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 calls, Func createEvents, - Func createCommands) => + Func 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 calls) : ILiveSessionOperations { public List Sessions { get; } = []; diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index f9208079..95e37ff8 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -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. diff --git a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj index f99c6e22..3d1f7e7e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj +++ b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs index a09b6331..95f7df1a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs @@ -14,7 +14,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// public sealed class ChatPanelFocusTests { - private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus + private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus { public void Publish(T command) where T : notnull { } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 8e3a95be..77aac3f7 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -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]