diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f825350..d49c456 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -300,6 +300,12 @@ public sealed class GameWindow : IDisposable private static readonly bool DevToolsEnabled = Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1"; + // Phase I.3 — real ICommandBus for live sessions. Constructed when + // the live session spins up (so SendChatCmd handlers can close over + // _liveSession + Chat). Null when offline; PanelContext then falls + // back to NullCommandBus.Instance. + private AcDream.UI.Abstractions.LiveCommandBus? _commandBus; + // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = new AcDream.Core.World.WorldTimeService( @@ -1221,6 +1227,41 @@ public sealed class GameWindow : IDisposable senderGuid: speech.SenderGuid, isRanged: speech.IsRanged); + // Phase I.3: real ICommandBus. Panels publish SendChatCmd here + // and we route it to the right wire opcode (Talk / Tell / ChatChannel) + // plus a local echo into ChatLog so the player sees their own + // message immediately. Closes over _liveSession + Chat so this + // wiring only exists for the lifetime of the live session. + var liveSession = _liveSession; + var chat = Chat; + _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); + _commandBus.Register(cmd => + { + if (string.IsNullOrEmpty(cmd.Text)) return; + switch (cmd.Channel) + { + case AcDream.UI.Abstractions.ChatChannelKind.Say: + liveSession.SendTalk(cmd.Text); + chat.OnSelfSent(AcDream.Core.Chat.ChatKind.LocalSpeech, cmd.Text); + break; + case AcDream.UI.Abstractions.ChatChannelKind.Tell: + if (string.IsNullOrEmpty(cmd.TargetName)) return; + liveSession.SendTell(cmd.TargetName, cmd.Text); + chat.OnSelfSent( + AcDream.Core.Chat.ChatKind.Tell, cmd.Text, + targetOrChannel: cmd.TargetName); + break; + default: + var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel); + if (resolved is null) return; + liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text); + chat.OnSelfSent( + AcDream.Core.Chat.ChatKind.Channel, cmd.Text, + targetOrChannel: resolved.Value.DisplayName); + break; + } + }); + // Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent // into LocalPlayer so VitalsPanel can draw Stam / Mana bars. _liveSession.VitalUpdated += v => @@ -4116,9 +4157,15 @@ public sealed class GameWindow : IDisposable // today) would need manual protection. if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null) { + // Phase I.3 — prefer the live command bus when a live session + // is up so panel-emitted SendChatCmd actually flows server-ward. + // Fall back to NullCommandBus for offline / pre-connect renders. + AcDream.UI.Abstractions.ICommandBus bus = + _commandBus ?? (AcDream.UI.Abstractions.ICommandBus) + AcDream.UI.Abstractions.NullCommandBus.Instance; var ctx = new AcDream.UI.Abstractions.PanelContext( (float)deltaSeconds, - AcDream.UI.Abstractions.NullCommandBus.Instance); + bus); _panelHost.RenderAll(ctx); _imguiBootstrap.Render(); } diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj index 4f8a02f..49c20a3 100644 --- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -10,4 +10,7 @@ + + + diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5b2ff00..307b1aa 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -784,9 +784,27 @@ public sealed class WorldSession : IDisposable /// public void SendGameAction(byte[] gameActionBody) { + // Phase I.3 test seam: when set, intercept the body before the + // wire-write path runs (which would otherwise NPE on an unseeded + // ISAAC keystream during unit tests). Production callers leave + // this null and the body proceeds to the framed/encrypted UDP send. + if (GameActionCapture is not null) + { + GameActionCapture(gameActionBody); + return; + } SendGameMessage(gameActionBody); } + /// + /// Phase I.3: test-only hook. When non-null, + /// invokes this instead of writing to the wire. Lets unit tests verify + /// that // + /// produce the bytes they should without standing up a full handshake + + /// ISAAC keystream. Production sites never set this. + /// + internal Action? GameActionCapture { get; set; } + /// /// Phase B.2: get and increment the game-action sequence counter. /// Call once per outbound movement message; pass the returned value @@ -795,6 +813,44 @@ public sealed class WorldSession : IDisposable /// public uint NextGameActionSequence() => ++_gameActionSequence; + /// + /// Phase I.3: send a local /say message (heard within ~20m). + /// Wraps . + /// + public void SendTalk(string text) + { + ArgumentNullException.ThrowIfNull(text); + uint seq = NextGameActionSequence(); + byte[] body = ChatRequests.BuildTalk(seq, text); + SendGameAction(body); + } + + /// + /// Phase I.3: send a /tell (whisper) by target character name. + /// Wraps . + /// + public void SendTell(string targetName, string text) + { + ArgumentNullException.ThrowIfNull(targetName); + ArgumentNullException.ThrowIfNull(text); + uint seq = NextGameActionSequence(); + byte[] body = ChatRequests.BuildTell(seq, targetName, text); + SendGameAction(body); + } + + /// + /// Phase I.3: send to a chat channel (allegiance, fellowship, etc.) by + /// the legacy ChatChannel bitflag id. + /// Wraps . + /// + public void SendChannel(uint channelId, string text) + { + ArgumentNullException.ThrowIfNull(text); + uint seq = NextGameActionSequence(); + byte[] body = ChatRequests.BuildChatChannel(seq, channelId, text); + SendGameAction(body); + } + private void SendGameMessage(byte[] gameMessageBody) { var fragment = GameMessageFragment.BuildSingleFragment( diff --git a/src/AcDream.UI.Abstractions/ChannelResolver.cs b/src/AcDream.UI.Abstractions/ChannelResolver.cs new file mode 100644 index 0000000..f734a9a --- /dev/null +++ b/src/AcDream.UI.Abstractions/ChannelResolver.cs @@ -0,0 +1,47 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Maps a to the legacy ChatChannel +/// bitflag id used by the 0x0147 ChatChannel GameAction. Ported from +/// holtburger's resolve_legacy_channel +/// (references/holtburger/crates/holtburger-core/src/client/commands.rs +/// lines 50-62) cross-referenced against the ChatChannel enum +/// (references/holtburger/crates/holtburger-protocol/src/messages/chat/types.rs +/// lines 8-24) for the actual numeric ids. +/// +/// +/// Returns null for non-legacy kinds (Say, Tell, Unknown, and the +/// Turbine-routed General/Trade/etc.) — those callers handle dispatch +/// themselves. rides Talk (0x0015) and +/// rides Tell (0x005D); the Turbine +/// channels need TurbineChat wiring not yet in place (Phase I.6). +/// +/// +public static class ChannelResolver +{ + /// Result of a successful legacy-channel resolution. + public readonly record struct Resolved(uint ChannelId, string DisplayName); + + /// + /// Resolve to a legacy ChatChannel id + a + /// human-readable display name for chat-log echo. Returns null + /// for non-legacy kinds; the caller routes those separately. + /// + public static Resolved? Resolve(ChatChannelKind kind) => kind switch + { + // ChatChannel values from holtburger-protocol/src/messages/chat/types.rs: + // Fellow = 0x00000800 + // Vassals = 0x00001000 + // Patron = 0x00002000 + // Monarch = 0x00004000 + // CoVassals = 0x01000000 + // AllegianceBroadcast = 0x02000000 + ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"), + ChatChannelKind.Allegiance => new Resolved(0x02000000u, "Allegiance"), + ChatChannelKind.Vassals => new Resolved(0x00001000u, "Vassals"), + ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"), + ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"), + ChatChannelKind.CoVassals => new Resolved(0x01000000u, "CoVassals"), + _ => null, + }; +} diff --git a/src/AcDream.UI.Abstractions/ChatChannelKind.cs b/src/AcDream.UI.Abstractions/ChatChannelKind.cs new file mode 100644 index 0000000..f31db4c --- /dev/null +++ b/src/AcDream.UI.Abstractions/ChatChannelKind.cs @@ -0,0 +1,40 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Outbound chat channel selector. Mirrors holtburger's ChatChannelKind +/// (references/holtburger/crates/holtburger-core/src/client/types.rs +/// lines 35-49) plus a synthetic + for +/// the two non-channel cases — local speech and whispers — so a single +/// can carry every outbound flavour the chat +/// panel emits. +/// +/// +/// Channels split into: +/// +/// Legacy (Fellowship..CoVassals): map to a fixed ChatChannel +/// bitflag id via and ride 0x0147 ChatChannel. +/// Turbine (General..Olthoi): require a TurbineChat channel id +/// resolved at runtime — not yet wired (Phase I.6 owns TurbineChat). +/// Say / Tell: route to the dedicated 0x0015 / 0x005D +/// opcodes — no channel id needed. +/// +/// +/// +public enum ChatChannelKind +{ + Say, + Tell, + Fellowship, + Allegiance, + Vassals, + Patron, + Monarch, + CoVassals, + General, + Trade, + Lfg, + Roleplay, + Society, + Olthoi, + Unknown, +} diff --git a/src/AcDream.UI.Abstractions/LiveCommandBus.cs b/src/AcDream.UI.Abstractions/LiveCommandBus.cs new file mode 100644 index 0000000..3f67d4a --- /dev/null +++ b/src/AcDream.UI.Abstractions/LiveCommandBus.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.UI.Abstractions; + +/// +/// Real implementation — single-handler-per-type +/// dispatch keyed by typeof(T). Replaces +/// in live sessions; persists for tests and +/// non-live UI scenarios where no command flow is wanted. +/// +/// +/// Threading. Both and +/// run on the render thread today (panels render on the render thread, and +/// host wiring happens at startup). The internal handler dictionary is +/// not synchronized — register all handlers during host setup before the +/// panel host starts rendering. +/// +/// +/// +/// Phase I.3 of the chat/UI consolidation plan +/// (~/.claude/plans/ticklish-conjuring-cake.md): primary client of +/// the bus is the handler wired by GameWindow +/// against WorldSession.SendTalk/SendTell/SendChannel + the local +/// ChatLog echo. +/// +/// +public sealed class LiveCommandBus : ICommandBus +{ + private readonly Dictionary _handlers = new(); + + /// + /// Register a single handler for commands of type . + /// Throws if a handler is already + /// registered for this type — single-handler-per-type is intentional so + /// command routing is unambiguous. + /// + public void Register(Action handler) where T : notnull + { + ArgumentNullException.ThrowIfNull(handler); + if (_handlers.ContainsKey(typeof(T))) + throw new InvalidOperationException( + $"A handler for command type {typeof(T).FullName} is already registered."); + _handlers[typeof(T)] = handler; + } + + /// + public void Publish(T command) where T : notnull + { + ArgumentNullException.ThrowIfNull(command); + if (_handlers.TryGetValue(typeof(T), out var handler)) + { + ((Action)handler).Invoke(command); + } + else + { + // Soft-warn: command published with no registered handler. + // Don't throw — the host may publish optional commands a non- + // live build doesn't wire (e.g. inventory pre-Phase I.7). + Console.WriteLine( + $"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping."); + } + } +} diff --git a/src/AcDream.UI.Abstractions/SendChatCmd.cs b/src/AcDream.UI.Abstractions/SendChatCmd.cs new file mode 100644 index 0000000..6b5d950 --- /dev/null +++ b/src/AcDream.UI.Abstractions/SendChatCmd.cs @@ -0,0 +1,14 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Command published by chat panels to send a message. The host resolves +/// + + +/// into the right wire opcode (Talk, Tell, or ChatChannel) and echoes +/// locally via ChatLog.OnSelfSent. +/// +/// +/// is meaningful only for +/// ; ignored otherwise. +/// +/// +public sealed record SendChatCmd(ChatChannelKind Channel, string? TargetName, string Text); diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs new file mode 100644 index 0000000..67faf6c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// Phase I.3 — verifies that , +/// , and +/// produce the same wire bytes that builders do, +/// using a sequence number drawn from . +/// +/// +/// Uses the internal GameActionCapture test seam to intercept the +/// game-action body before it hits the (unseeded) ISAAC-encrypted wire path. +/// +/// +public sealed class WorldSessionChatTests +{ + private static WorldSession NewSession() + { + // Bind to a throwaway loopback endpoint; we never actually + // exchange packets — the capture hook intercepts the body. + var ep = new IPEndPoint(IPAddress.Loopback, 65000); + return new WorldSession(ep); + } + + [Fact] + public void SendTalk_EmitsBytesIdenticalToChatRequestsBuildTalk() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTalk("hello"); + + // After SendTalk, the sequence counter has been incremented to 1. + // ChatRequests.BuildTalk(seq=1, "hello") should match exactly. + byte[] expected = ChatRequests.BuildTalk(1, "hello"); + Assert.NotNull(captured); + Assert.Equal(expected, captured); + } + + [Fact] + public void SendTell_EmitsBytesIdenticalToChatRequestsBuildTell() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTell("Alice", "hey"); + + byte[] expected = ChatRequests.BuildTell(1, "Alice", "hey"); + Assert.NotNull(captured); + Assert.Equal(expected, captured); + } + + [Fact] + public void SendChannel_IncrementsSequence_AndMatchesBuildChatChannel() + { + using var session = NewSession(); + var captured = new System.Collections.Generic.List(); + session.GameActionCapture = body => captured.Add(body); + + session.SendChannel(channelId: 0x00000800u, "raid plan"); + session.SendChannel(channelId: 0x02000000u, "allegiance ping"); + + Assert.Equal(2, captured.Count); + Assert.Equal(ChatRequests.BuildChatChannel(1, 0x00000800u, "raid plan"), captured[0]); + Assert.Equal(ChatRequests.BuildChatChannel(2, 0x02000000u, "allegiance ping"), captured[1]); + } + + [Fact] + public void SendTalk_NullText_Throws() + { + using var session = NewSession(); + Assert.Throws(() => session.SendTalk(null!)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs new file mode 100644 index 0000000..d8710e9 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs @@ -0,0 +1,73 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class LiveCommandBusTests +{ + private sealed record FakeCmd(int Value); + private sealed record OtherCmd(string Tag); + + [Fact] + public void Publish_InvokesRegisteredHandler_ForMatchingType() + { + var bus = new LiveCommandBus(); + FakeCmd? captured = null; + bus.Register(c => captured = c); + + bus.Publish(new FakeCmd(42)); + + Assert.NotNull(captured); + Assert.Equal(42, captured!.Value); + } + + [Fact] + public void Publish_DoesNotThrow_WhenNoHandlerRegistered() + { + var bus = new LiveCommandBus(); + + // No handler for OtherCmd — must not throw. + bus.Publish(new OtherCmd("x")); + } + + [Fact] + public void Register_Twice_ForSameType_Throws() + { + var bus = new LiveCommandBus(); + bus.Register(_ => { }); + + Assert.Throws(() => bus.Register(_ => { })); + } +} + +public sealed class ChannelResolverTests +{ + [Fact] + public void Resolve_LegacyChannels_MatchesHoltburgerIds() + { + // Holtburger references/holtburger/crates/holtburger-protocol/src/messages/chat/types.rs:8-24 + // and holtburger-core/src/client/commands.rs:50-62. Six legacy channels + // map to known ChatChannel ids; the rest fall through to TurbineChat. + Assert.Equal((0x00000800u, "Fellowship"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Fellowship))); + Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Allegiance))); + Assert.Equal((0x00001000u, "Vassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Vassals))); + Assert.Equal((0x00002000u, "Patron"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Patron))); + Assert.Equal((0x00004000u, "Monarch"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Monarch))); + Assert.Equal((0x01000000u, "CoVassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.CoVassals))); + } + + [Fact] + public void Resolve_NonLegacyChannels_ReturnsNull() + { + // Say + Tell are handled separately by the SendChatCmd dispatcher. + // General/Trade/etc. require a TurbineChat channel id we don't yet wire. + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Say)); + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Tell)); + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.General)); + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Trade)); + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Unknown)); + } + + private static (uint ChannelId, string DisplayName) AsTuple(ChannelResolver.Resolved? r) + { + Assert.NotNull(r); + return (r!.Value.ChannelId, r.Value.DisplayName); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/SendChatCmdTests.cs b/tests/AcDream.UI.Abstractions.Tests/SendChatCmdTests.cs new file mode 100644 index 0000000..e40de82 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/SendChatCmdTests.cs @@ -0,0 +1,33 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class SendChatCmdTests +{ + [Fact] + public void Construct_DefaultsTargetNameToNull_ForNonTellChannels() + { + var cmd = new SendChatCmd(ChatChannelKind.Say, TargetName: null, Text: "hello"); + + Assert.Equal(ChatChannelKind.Say, cmd.Channel); + Assert.Null(cmd.TargetName); + Assert.Equal("hello", cmd.Text); + } + + [Fact] + public void Equality_HoldsForRecordsWithSameValues() + { + var a = new SendChatCmd(ChatChannelKind.Tell, "Alice", "hi"); + var b = new SendChatCmd(ChatChannelKind.Tell, "Alice", "hi"); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Equality_DiffersWhenChannelDiffers() + { + var a = new SendChatCmd(ChatChannelKind.Fellowship, null, "raid time"); + var b = new SendChatCmd(ChatChannelKind.Allegiance, null, "raid time"); + + Assert.NotEqual(a, b); + } +}