feat(ui+net): #16 LiveCommandBus + WorldSession.Send{Talk,Tell,Channel} + SendChatCmd wiring

Replaces NullCommandBus.Instance in PanelContext with a real
LiveCommandBus when a live session is active. Panels publish
SendChatCmd; the host routes it to the right wire opcode + emits
a ChatLog.OnSelfSent local echo (optimistic; retail-equivalent
for Talk).

Pieces:
- ChatChannelKind enum (UI.Abstractions) - mirrors holtburger's
  ChatChannelKind (references/holtburger/.../client/types.rs:35-49).
- SendChatCmd record (UI.Abstractions) - (Channel, TargetName?, Text).
- LiveCommandBus (UI.Abstractions) - single-handler-per-type;
  Register<T> throws on double-register; Publish<T> logs missing
  handler but does not throw.
- ChannelResolver (UI.Abstractions) - port of holtburger's
  resolve_legacy_channel (client/commands.rs:50-62) mapping
  ChatChannelKind to legacy ChatChannel ids verbatim from
  holtburger-protocol/.../chat/types.rs:8-24 (Fellow=0x0800,
  AllegianceBroadcast=0x02000000, Vassals=0x1000, Patron=0x2000,
  Monarch=0x4000, CoVassals=0x01000000).
- WorldSession.SendTalk / SendTell / SendChannel - 3-line wrappers
  around existing ChatRequests.Build* + SendGameAction. Internal
  GameActionCapture seam + InternalsVisibleTo for tests.
- GameWindow registers SendChatCmd handler: Say -> SendTalk +
  ChatLog echo, Tell -> SendTell + echo, channel kinds ->
  ChannelResolver.Resolve -> SendChannel + echo.

12 new tests across SendChatCmd + LiveCommandBus + ChannelResolver
+ WorldSessionChat. NullCommandBus.Instance retained for back-compat
when no live session.

Solution total: 893 green (51 + 229 + 613).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 19:27:22 +02:00
parent ff5ed9ec0b
commit 8e6e5a0b61
10 changed files with 457 additions and 1 deletions

View file

@ -10,4 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
</ItemGroup>
</Project>

View file

@ -784,9 +784,27 @@ public sealed class WorldSession : IDisposable
/// </summary>
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);
}
/// <summary>
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
/// invokes this instead of writing to the wire. Lets unit tests verify
/// that <see cref="SendTalk"/>/<see cref="SendTell"/>/<see cref="SendChannel"/>
/// produce the bytes they should without standing up a full handshake +
/// ISAAC keystream. Production sites never set this.
/// </summary>
internal Action<byte[]>? GameActionCapture { get; set; }
/// <summary>
/// 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
/// </summary>
public uint NextGameActionSequence() => ++_gameActionSequence;
/// <summary>
/// Phase I.3: send a local /say message (heard within ~20m).
/// Wraps <see cref="ChatRequests.BuildTalk"/>.
/// </summary>
public void SendTalk(string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTalk(seq, text);
SendGameAction(body);
}
/// <summary>
/// Phase I.3: send a /tell (whisper) by target character name.
/// Wraps <see cref="ChatRequests.BuildTell"/>.
/// </summary>
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);
}
/// <summary>
/// Phase I.3: send to a chat channel (allegiance, fellowship, etc.) by
/// the legacy <c>ChatChannel</c> bitflag id.
/// Wraps <see cref="ChatRequests.BuildChatChannel"/>.
/// </summary>
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(