chore(chat): CT-D1/D2 — delete the dead ChatPanel, reconcile the stale digest
Campaign CT Group D, closing the campaign. D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the audit's word, then removed it with its three panel-only test files. Those tests passed, which is exactly the problem: they made the real input surface look better covered than it is. ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM coverage and only one exercised ChatPanel, so just that method went. Deleting the file would have quietly dropped real coverage along with the dead kind. Three doc comments referencing the deleted type were rewritten rather than left as dangling crefs. D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are recorded CLOSED there, contrary to the audit's summary. What was stale was the chat DIGEST's "Open" section, which still named four closed issues and claimed Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open are #359, #360, #361 and #366. The digest also gained a Campaign CT section (the tag mechanism, the MEASURED tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session: - Do not model authored state media with one image per state — the unseen indicator's Normal state carries SIX frames and that IS retail's blink. - Do not read an element's role from a Binary Ninja field NAME — the names in ChatInterface's binder are shifted badly enough to assign a UIElement* into a float field. - Do not assume our side has a gap because retail has a mechanism. That cost this campaign twice in one session: the transcript was claimed unbounded when ChatLog has always capped at 500 entries, and C1's auto-scroll was planned as a port when UiScrollable already did it. CT-C4 is deferred and marked so: pure test coverage over behaviour the audit confirmed already works, changing nothing a user can see. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cbab79d70c
commit
7aae5ba939
9 changed files with 30 additions and 844 deletions
|
|
@ -1,6 +1,14 @@
|
|||
# Campaign CT — complete chat parity (system + GUI)
|
||||
|
||||
**Status:** PROPOSED (2026-08-21). Not started.
|
||||
**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two
|
||||
items deliberately not shipped: CT-B3 (word filtering — dropped by user
|
||||
direction, register row CT-2) and CT-B4 (session chat log — research-blocked).
|
||||
|
||||
**Carried forward:** multi-frame state media. Retail's unseen-text indicator
|
||||
blinks because its `Normal` state authors SIX image frames; our importer keeps
|
||||
one file per state, so nothing authored can animate. The indicator is
|
||||
state-driven and correct today and gains the blink for free once that
|
||||
capability lands. It is the first known customer, not the only one.
|
||||
|
||||
**Goal, set by the user 2026-08-21: complete retail parity for the chat
|
||||
system AND the chat GUI.** Not "fix the green name" — that was the symptom
|
||||
|
|
@ -135,16 +143,21 @@ Nothing is user-visible until A4.
|
|||
grey), gated on `PlayerModule::DisplayTimeStamps()`.
|
||||
- **CT-C4** Input-bar editing parity: clipboard and selection paths
|
||||
(Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the
|
||||
floating-window Close button have zero coverage.
|
||||
floating-window Close button have zero coverage. **DEFERRED** — pure test
|
||||
coverage over behaviour the audit confirmed already works, so it changes
|
||||
nothing a user can see. Worth doing; not worth blocking the campaign on.
|
||||
|
||||
### Group D — hygiene
|
||||
|
||||
- **CT-D1** Delete the dead ImGui-era `ChatPanel` (never constructed since
|
||||
Campaign V deleted `AcDream.UI.ImGui`), and its three test files, which
|
||||
currently make the real input surface look better covered than it is.
|
||||
- **CT-D2** Reconcile the chat digest and `docs/ISSUES.md`: #358, #362, #363,
|
||||
#367, #372, #379, #380, #382 are DONE in code but still listed open. #359,
|
||||
#360, #361, #366 remain genuinely open.
|
||||
- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never
|
||||
constructed in `src/`, then removed with its three panel-only test files.
|
||||
`ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM`
|
||||
coverage; only the one `ChatPanel` render test went.
|
||||
- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.**
|
||||
`docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are
|
||||
recorded CLOSED there. Only the chat digest's "Open" section was stale, and
|
||||
it is corrected: genuinely open are #359, #360, #361, #366. The digest also
|
||||
gained a Campaign CT section and three new DO-NOT-RETRY rows.
|
||||
|
||||
## Research still owed before the affected slices
|
||||
|
||||
|
|
|
|||
|
|
@ -507,8 +507,9 @@ public readonly record struct ChatEntry(
|
|||
|
||||
/// <summary>
|
||||
/// Phase I.7: severity bucket for <see cref="ChatKind.Combat"/>
|
||||
/// entries. Null for every other kind. Drives the
|
||||
/// <see cref="ChatPanel"/>'s <c>TextColored</c> color choice.
|
||||
/// entries. Null for every other kind. Carried through to the transcript
|
||||
/// on <c>FormattedLine</c>; the per-message colour itself now comes from
|
||||
/// the retail colour table keyed by <c>LogTextType</c>.
|
||||
/// </summary>
|
||||
public Combat.CombatLineKind? CombatKind { get; init; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.8, )",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw=="
|
||||
}
|
||||
},
|
||||
"net10.0": {},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// The chat panel. Shows the tail of <see cref="ChatLog"/> + an input
|
||||
/// field at the bottom that submits on Enter.
|
||||
///
|
||||
/// <para>
|
||||
/// Phase I.4 added the input field and slash-command parsing. Supported
|
||||
/// prefixes (alias-matched against the verb token, not by string-prefix
|
||||
/// — so <c>/general</c> is NOT <c>/g</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>/say <msg></c> or no prefix → Say (default)</item>
|
||||
/// <item><c>/t</c> / <c>/tell <name> <msg></c> → whisper</item>
|
||||
/// <item><c>/r</c> / <c>/reply <msg></c> → reply to most recent
|
||||
/// INCOMING Tell (uses <see cref="ChatVM.LastIncomingTellSender"/>;
|
||||
/// drops the message if no Tell has arrived yet)</item>
|
||||
/// <item><c>/g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
|
||||
/// /olthoi <msg></c> → corresponding channel</item>
|
||||
/// <item>unknown <c>/xyz hello</c> → Say with the literal text intact
|
||||
/// (matches holtburger fall-through)</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Empty / whitespace-only / target-but-no-message inputs are silently
|
||||
/// dropped — the input field clears and no command goes out.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatPanel : IPanel
|
||||
{
|
||||
private const int InputBufferMaxLen = 512;
|
||||
|
||||
private readonly ChatVM _vm;
|
||||
private string _input = string.Empty;
|
||||
|
||||
// Phase J Tier 3: tracks the chat-tail size between frames so we
|
||||
// can auto-scroll the scrollable child to the bottom on new
|
||||
// entries without yanking the user's manual scroll.
|
||||
private int _lastRenderedCount;
|
||||
|
||||
// Phase K.2: one-shot focus request for the chat input. Set by
|
||||
// FocusInput() (driven by Tab → ToggleChatEntry); the next Render
|
||||
// call emits SetKeyboardFocusHere immediately before the input
|
||||
// field and clears the flag. Without the one-shot semantics, the
|
||||
// panel would steal focus on every frame and the user could never
|
||||
// click into another widget.
|
||||
private bool _focusRequested;
|
||||
|
||||
// L.0 follow-up: "Copy mode" — when true, render the chat tail as
|
||||
// a read-only multi-line text widget the user can click+drag to
|
||||
// select + Ctrl+C to copy. Trades per-line color for selectability;
|
||||
// user toggles when they want to grab specific text out of the
|
||||
// log (item names, coordinates, NPC dialogue, etc).
|
||||
private bool _copyMode;
|
||||
|
||||
public ChatPanel(ChatVM vm)
|
||||
{
|
||||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "acdream.chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsVisible { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Phase K.2: request keyboard focus for the chat input on the
|
||||
/// NEXT <see cref="Render"/>. One-shot — fires once and resets,
|
||||
/// so callers (e.g. <c>GameWindow</c>'s Tab handler subscribing to
|
||||
/// <c>ToggleChatEntry</c>) can drive it on a single key press
|
||||
/// without trapping the user permanently in the input field.
|
||||
/// </summary>
|
||||
public void FocusInput() => _focusRequested = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Render(PanelContext ctx, IPanelRenderer renderer)
|
||||
{
|
||||
if (!renderer.Begin(Title))
|
||||
{
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
||||
// L.0 follow-up: wrap the entire chat panel body in a single
|
||||
// outer BeginChild so empty-space clicks anywhere in the body
|
||||
// (Checkbox row, between Separator and input, etc.) are
|
||||
// absorbed by BeginChild's drag-trap (an InvisibleButton the
|
||||
// ImGui renderer adds inside every BeginChild). Without this
|
||||
// wrapper the chat panel was draggable from any empty body
|
||||
// pixel — only the inner ##chattail area was protected.
|
||||
if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f)))
|
||||
{
|
||||
renderer.EndChild();
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
||||
// L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the
|
||||
// chat tail rendering swaps to TextMultilineReadOnly so the
|
||||
// user can mark + Ctrl+C any text. Off (default) preserves the
|
||||
// colored per-line render with combat highlights. The checkbox
|
||||
// sits ABOVE the chat tail (not in the footer) so it's always
|
||||
// visible regardless of scroll position.
|
||||
bool copyMode = _copyMode;
|
||||
if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode))
|
||||
_copyMode = copyMode;
|
||||
renderer.Separator();
|
||||
|
||||
// Phase J Tier 3: keep the input field at the bottom of the
|
||||
// window across resizes by reserving footer space and putting
|
||||
// the chat tail in a scrollable child that fills the rest.
|
||||
// The reserved footer holds: one Separator + one InputText.
|
||||
// FrameHeightWithSpacing covers the input; we add a small fudge
|
||||
// (~6px) for the separator above it.
|
||||
float footerHeight = renderer.FrameHeightWithSpacing() + 6f;
|
||||
|
||||
// Phase I.7: pull the typed-line view so combat entries can
|
||||
// route through TextColored. Non-combat entries still take
|
||||
// the plain Text path (visually identical to the I.4 panel).
|
||||
var lines = _vm.RecentLinesDetailed();
|
||||
|
||||
if (_copyMode)
|
||||
{
|
||||
// Copy mode: one big read-only multiline text widget
|
||||
// holding every visible line, joined with newlines. Loses
|
||||
// per-line color but lets the user click+drag to select
|
||||
// arbitrary spans of text + Ctrl+C to copy. Sized to fill
|
||||
// the available space minus the footer.
|
||||
string joined = lines.Count == 0
|
||||
? "(no messages yet)"
|
||||
: string.Join("\n", lines.Select(l => l.Text));
|
||||
renderer.TextMultilineReadOnly(
|
||||
"##chattailcopy", joined,
|
||||
new System.Numerics.Vector2(0f, -footerHeight));
|
||||
}
|
||||
else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
renderer.Text("(no messages yet)");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (line.Kind == ChatKind.Combat)
|
||||
{
|
||||
// Campaign CH slice CH1: color combat lines from the
|
||||
// retail LogTextType table (Combat_Self/Combat_Enemy/
|
||||
// Default per CombatChatTranslator's ACE-cited
|
||||
// mapping) instead of the CombatLineKind info/
|
||||
// warning/error severity bucket. Every LogTextType
|
||||
// CombatChatTranslator emits is in-range (<0x22), so
|
||||
// the fallback below is defensive only.
|
||||
Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved)
|
||||
? resolved
|
||||
: ColorForCombat(line.CombatKind ?? CombatLineKind.Info);
|
||||
renderer.TextColored(color, line.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
renderer.Text(line.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom only when a new line was appended
|
||||
// since the last render. Manual user scroll-up isn't fought
|
||||
// against; new messages will jump the view back down once
|
||||
// they arrive.
|
||||
if (lines.Count > _lastRenderedCount)
|
||||
{
|
||||
renderer.SetScrollHereY(1.0f);
|
||||
}
|
||||
_lastRenderedCount = lines.Count;
|
||||
}
|
||||
if (!_copyMode) renderer.EndChild();
|
||||
|
||||
// Phase I.4: input field. Backend implementation clears _input
|
||||
// on submit per the IPanelRenderer contract.
|
||||
renderer.Separator();
|
||||
// Phase K.2: honor a pending FocusInput() request — emit
|
||||
// SetKeyboardFocusHere immediately before the input widget so
|
||||
// ImGui (or the future custom backend) applies it to that
|
||||
// field. One-shot: clear the flag after firing.
|
||||
if (_focusRequested)
|
||||
{
|
||||
renderer.SetKeyboardFocusHere();
|
||||
_focusRequested = false;
|
||||
}
|
||||
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
|
||||
&& submitted is not null)
|
||||
{
|
||||
ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
|
||||
_input = string.Empty;
|
||||
}
|
||||
|
||||
renderer.EndChild(); // outer ##chatbody
|
||||
renderer.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: per-severity color for combat-feedback chat lines.
|
||||
/// Maps onto holtburger's <c>color_for_tags</c> at chat.rs:330-333
|
||||
/// (info → yellowish, warning → red incoming, error → deep red).
|
||||
/// </summary>
|
||||
public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch
|
||||
{
|
||||
CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f),
|
||||
CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f),
|
||||
CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f),
|
||||
_ => new Vector4(1f, 1f, 1f, 1f),
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -393,10 +393,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|||
: entry.ChannelName;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: snapshot of the chat tail with kind metadata so
|
||||
/// <see cref="ChatPanel"/> can pick the right rendering primitive
|
||||
/// per entry (plain <c>Text</c> for most kinds; <c>TextColored</c>
|
||||
/// for combat lines, with the rgba chosen from
|
||||
/// Phase I.7: snapshot of the chat tail with kind metadata so the
|
||||
/// transcript can pick the right rendering per entry (most kinds take one
|
||||
/// colour; combat lines take the rgba chosen from
|
||||
/// <see cref="ChatEntry.CombatKind"/>). Campaign CH slice CH1 also
|
||||
/// carries <see cref="ChatEntry.LogTextType"/> through — the retail
|
||||
/// color key, keyed independently of <see cref="ChatKind"/>.
|
||||
|
|
@ -458,10 +457,8 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: formatted chat line with kind metadata. The
|
||||
/// <see cref="ChatPanel"/> switches on <see cref="Kind"/> +
|
||||
/// <see cref="CombatKind"/> to pick a rendering primitive
|
||||
/// (<c>Text</c> vs <c>TextColored(rgba)</c>).
|
||||
/// Phase I.7: formatted chat line with kind metadata. The transcript switches
|
||||
/// on <see cref="Kind"/> + <see cref="CombatKind"/> to pick a colour.
|
||||
/// </summary>
|
||||
/// <param name="LogTextType">
|
||||
/// Campaign CH slice CH1: the retail wire <c>LogTextType</c> that keys
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase K.2 — Tab fires <see cref="AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry"/>,
|
||||
/// which calls <see cref="ChatPanel.FocusInput"/>. The chat panel honors
|
||||
/// the request on the very next <see cref="ChatPanel.Render"/> by emitting
|
||||
/// a <c>SetKeyboardFocusHere</c> immediately before the input field. After
|
||||
/// it fires once, subsequent renders without another <c>FocusInput</c>
|
||||
/// call must not re-fire (one-shot semantics) — otherwise the chat field
|
||||
/// would steal focus on every frame and the user could never click out.
|
||||
/// </summary>
|
||||
public sealed class ChatPanelFocusTests
|
||||
{
|
||||
private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput()
|
||||
{
|
||||
var panel = new ChatPanel(new ChatVM(new ChatLog()));
|
||||
var renderer = new FakePanelRenderer();
|
||||
|
||||
panel.FocusInput();
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
|
||||
|
||||
// Find the SetKeyboardFocusHere call; it must come before the
|
||||
// InputTextSubmit call so ImGui applies the focus to that widget.
|
||||
int focusIdx = -1, inputIdx = -1;
|
||||
for (int i = 0; i < renderer.Calls.Count; i++)
|
||||
{
|
||||
if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i;
|
||||
else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i;
|
||||
}
|
||||
Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested.");
|
||||
Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field.");
|
||||
Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere()
|
||||
{
|
||||
var panel = new ChatPanel(new ChatVM(new ChatLog()));
|
||||
var renderer = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
|
||||
|
||||
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocusInput_OnlyAffectsTheNextRender_OneShot()
|
||||
{
|
||||
var panel = new ChatPanel(new ChatVM(new ChatLog()));
|
||||
|
||||
// Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere.
|
||||
var r1 = new FakePanelRenderer();
|
||||
panel.FocusInput();
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
|
||||
Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere");
|
||||
|
||||
// Frame 2 — no further FocusInput call → must NOT re-fire.
|
||||
var r2 = new FakePanelRenderer();
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
|
||||
Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
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 local text via
|
||||
// ChatLog.OnSystemMessage and do NOT round-trip the server — that's
|
||||
// what prevented the "Unknown command: help" duplicate ACE was
|
||||
// firing back.
|
||||
//
|
||||
// Campaign CH user-gate round 3 (2026-08-10): retail's DoHelp
|
||||
// prints via exactly TWO scroll entries (Note, then the 13-item
|
||||
// "Available help:" listing), never one acdream-invented blob — see
|
||||
// RetailCommandHelpTable's class remarks for the full trace.
|
||||
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 entries = log.Snapshot();
|
||||
Assert.Equal(2, entries.Length);
|
||||
Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind));
|
||||
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
||||
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/?")]
|
||||
// "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a
|
||||
// retail-registered verb (registry doc §4's removal list).
|
||||
[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.Equal(2, log.Snapshot().Length);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase J Tier 3: <see cref="ChatPanel.Render"/> must reserve footer
|
||||
/// space for the separator + input field so the input stays anchored
|
||||
/// at the bottom across window resizes (the user reported the input
|
||||
/// disappearing when the window shrank). The pattern is the standard
|
||||
/// ImGui chat-window layout: a scrollable child filling
|
||||
/// <c>(0, -footerHeight)</c>, then the separator + input below it.
|
||||
/// </summary>
|
||||
public sealed class ChatPanelLayoutTests
|
||||
{
|
||||
private sealed class NoBus : ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { /* no-op */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
log.OnSystemMessage("seed", chatType: 0);
|
||||
var vm = new ChatVM(log);
|
||||
var panel = new ChatPanel(vm);
|
||||
var renderer = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
||||
|
||||
var methods = renderer.Calls.Select(c => c.Method).ToList();
|
||||
int beginIdx = methods.IndexOf("Begin");
|
||||
int beginChildIdx = methods.IndexOf("BeginChild");
|
||||
int endChildIdx = methods.IndexOf("EndChild");
|
||||
// L.0 follow-up: Copy-mode toggle adds a Separator above the
|
||||
// chat tail, so multiple Separators now exist. The footer
|
||||
// separator (the one we care about for input layout) is the
|
||||
// LAST one — between EndChild and the input field.
|
||||
int separatorIdx = methods.LastIndexOf("Separator");
|
||||
int inputSubmitIdx = methods.IndexOf("InputTextSubmit");
|
||||
int endIdx = methods.IndexOf("End");
|
||||
|
||||
// All present
|
||||
Assert.True(beginIdx >= 0, "Begin missing");
|
||||
Assert.True(beginChildIdx >= 0, "BeginChild missing");
|
||||
Assert.True(endChildIdx >= 0, "EndChild missing");
|
||||
Assert.True(separatorIdx >= 0, "Separator missing");
|
||||
Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing");
|
||||
Assert.True(endIdx >= 0, "End missing");
|
||||
|
||||
// Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End
|
||||
Assert.True(beginIdx < beginChildIdx);
|
||||
Assert.True(beginChildIdx < endChildIdx);
|
||||
Assert.True(endChildIdx < separatorIdx);
|
||||
Assert.True(separatorIdx < inputSubmitIdx);
|
||||
Assert.True(inputSubmitIdx < endIdx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
var panel = new ChatPanel(vm);
|
||||
var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
|
||||
|
||||
// L.0 follow-up: the chat panel now wraps its body in an outer
|
||||
// ##chatbody BeginChild (so empty-space clicks can't drag the
|
||||
// parent window). The inner ##chattail BeginChild is the one
|
||||
// that reserves the footer; that's what this test asserts.
|
||||
var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild"
|
||||
&& (string)c.Args[0]! == "##chattail");
|
||||
var size = (System.Numerics.Vector2)chattailCall.Args[1]!;
|
||||
// Width 0 = fill available; height < 0 = "fill minus this".
|
||||
// Reserved height should equal FrameHeightWithSpacing + a small
|
||||
// separator pad (~6f) so the input never visually clips the
|
||||
// last chat line.
|
||||
Assert.Equal(0f, size.X);
|
||||
Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}");
|
||||
Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_NewEntries_ScrollsToBottom()
|
||||
{
|
||||
// First render establishes the baseline (no auto-scroll because
|
||||
// _lastRenderedCount == lines.Count == 0). Then a second render
|
||||
// after a new entry should fire SetScrollHereY(1.0f).
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
var panel = new ChatPanel(vm);
|
||||
var renderer = new FakePanelRenderer();
|
||||
var ctx = new PanelContext(0.016f, new NoBus());
|
||||
|
||||
panel.Render(ctx, renderer);
|
||||
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
||||
|
||||
// Append a new entry, render again — auto-scroll should fire.
|
||||
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
|
||||
renderer.Calls.Clear();
|
||||
panel.Render(ctx, renderer);
|
||||
|
||||
var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY");
|
||||
Assert.Equal(1.0f, (float)scrollCall.Args[0]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_NoNewEntries_DoesNotForceScroll()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
log.OnSystemMessage("seed", chatType: 0);
|
||||
var vm = new ChatVM(log);
|
||||
var panel = new ChatPanel(vm);
|
||||
var renderer = new FakePanelRenderer();
|
||||
var ctx = new PanelContext(0.016f, new NoBus());
|
||||
|
||||
// First render establishes count baseline (1 entry). The first
|
||||
// render auto-scrolls because lines.Count (1) > _lastRenderedCount
|
||||
// (0). Subsequent renders without new entries should NOT scroll.
|
||||
panel.Render(ctx, renderer);
|
||||
renderer.Calls.Clear();
|
||||
panel.Render(ctx, renderer);
|
||||
|
||||
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
|
||||
}
|
||||
}
|
||||
|
|
@ -56,41 +56,6 @@ public sealed class ChatVMCombatTests
|
|||
Assert.Equal("Alice says, \"hi\"", line.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatPanel_RendersCombatLine_ViaTextColored()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
|
||||
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).",
|
||||
logTextType: 0x06u, kind: CombatLineKind.Info);
|
||||
|
||||
var panel = new ChatPanel(vm);
|
||||
var bus = new RecordingChatBus();
|
||||
var renderer = new FakePanelRenderer { InputTextSubmitNextSubmitted = null };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
// Plain LocalSpeech entry → Text; combat entry → TextColored, now
|
||||
// sourced from RetailChatColorTable (Campaign CH slice CH1) keyed
|
||||
// by LogTextType, not ChatPanel.ColorForCombat's severity bucket.
|
||||
// The 0x06 generic Combat slot (colorDarkRed) is passed explicitly
|
||||
// above — a registered approximation of retail's per-message
|
||||
// dispatch (register row AP-176), not a ChatLog default.
|
||||
Assert.Contains(renderer.Calls, c =>
|
||||
c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\"");
|
||||
var coloredCall = Assert.Single(
|
||||
renderer.Calls,
|
||||
c => c.Method == "TextColored");
|
||||
Assert.Equal(
|
||||
"You hit Mosswart for 5 slashing damage (50.0%).",
|
||||
(string?)coloredCall.Args[1]);
|
||||
RetailChatColorTable.TryGetColor(0x06u, out var expectedColor);
|
||||
Assert.Equal(
|
||||
expectedColor,
|
||||
(System.Numerics.Vector4)coloredCall.Args[0]!);
|
||||
}
|
||||
|
||||
private sealed class RecordingChatBus : ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { /* no-op */ }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue