Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:
BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).
BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).
Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.
dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
12 KiB
C#
353 lines
12 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase I.4: when the user submits text via the chat input field, the
|
|
/// panel must publish the appropriate typed intent to the command bus.
|
|
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
|
|
/// pre-loading a "submitted" string and a recording bus capturing the
|
|
/// resulting command.
|
|
/// </summary>
|
|
public sealed class ChatPanelInputTests
|
|
{
|
|
private sealed class RecordingBus : ICommandBus
|
|
{
|
|
public List<object> Published { get; } = new();
|
|
public void Publish<T>(T command) where T : notnull => Published.Add(command);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish()
|
|
{
|
|
// Phase J follow-up: client-side commands (/help, /?, /h) are
|
|
// intercepted before the parser. They render a local cheat-sheet
|
|
// via ChatLog.OnSystemMessage and do NOT round-trip the server
|
|
// — that's what prevented the "Unknown command: help" duplicate
|
|
// ACE was firing back.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/help",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(ChatKind.System, entry.Kind);
|
|
// Help text mentions / and @ equivalence and points at @acehelp
|
|
// for the server's full command list.
|
|
Assert.Contains("/tell", entry.Text);
|
|
Assert.Contains("@acehelp", entry.Text);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/?")]
|
|
[InlineData("/h")]
|
|
[InlineData("/HELP")]
|
|
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
Assert.Single(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_FramerateCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log) { FpsProvider = () => 60f };
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/framerate",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command);
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_LocCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log)
|
|
{
|
|
PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f),
|
|
};
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "@loc",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ShowLocation, command.Command);
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/foo", "@foo")]
|
|
[InlineData("/genio public", "@genio public")]
|
|
public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText)
|
|
{
|
|
// Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
|
|
// as plain speech. Retail treats / and @ as equivalent command
|
|
// prefixes, so unknown verbs now go to the SERVER as @commands
|
|
// (ACE's GameActionTalk intercepts @ on the Say action and
|
|
// answers "Unknown command: x" itself) instead of a local guess.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(expectedText, sendCmd.Text);
|
|
Assert.Empty(log.Snapshot()); // no local "Unknown command" guess
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/lifestone")]
|
|
[InlineData("/lif")]
|
|
[InlineData("/ls")]
|
|
[InlineData("@LS")]
|
|
public void Submit_LifestoneAlias_PublishesTypedClientCommand(string raw)
|
|
{
|
|
var vm = new ChatVM(new ChatLog());
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/")]
|
|
[InlineData("//shrug")]
|
|
public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw)
|
|
{
|
|
// Command-shaped but no letter verb: refused locally — this is
|
|
// the remaining Tier-4 guard (never broadcast /-text as speech,
|
|
// and don't put junk @-rewrites on the wire either).
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(ChatKind.System, entry.Kind);
|
|
Assert.Contains("Unknown command", entry.Text);
|
|
Assert.Contains("/help", entry.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_AtAcehelp_PublishesExplicitServerCommand()
|
|
{
|
|
// Unknown @-verb falls through to the default channel with the
|
|
// literal "@acehelp" text intact so ACE's CommandManager
|
|
// intercepts it server-side. The explicit server-command record keeps
|
|
// it distinct from ordinary Say text.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "@acehelp",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal("@acehelp", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_ClearCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed line", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/clear",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ClearChat, command.Command);
|
|
Assert.Single(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_PlainText_PublishesSayCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "hello world",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var cmd = Assert.Single(bus.Published);
|
|
var sendCmd = Assert.IsType<SendChatCmd>(cmd);
|
|
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
|
|
Assert.Null(sendCmd.TargetName);
|
|
Assert.Equal("hello world", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_TellSlashCommand_PublishesTellCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/t Bestie ping",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
|
|
Assert.Equal("Bestie", sendCmd.TargetName);
|
|
Assert.Equal("ping", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_ReplySlashCommand_UsesLastIncomingTellSender()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u);
|
|
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/r back at you",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
|
|
Assert.Equal("Bestie", sendCmd.TargetName);
|
|
Assert.Equal("back at you", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_EmptyOrWhitespace_PublishesNothing()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = " ",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoSubmit_PublishesNothing()
|
|
{
|
|
// Most frames: user is typing or idle; submitted == null.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = null,
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_AlwaysCallsInputTextSubmit_ToShowTheField()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = null,
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Contains(renderer.Calls, c => c.Method == "InputTextSubmit");
|
|
}
|
|
}
|