From e62aaebda0f432029cd851d1f1c358916151ccef Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:10:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20CT-B2=20=E2=80=94=20"/r=20"=20exp?= =?UTF-8?q?ands=20to=20a=20tell=20at=20whoever=20last=20told=20you?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice B2, and the autocomplete the user asked about directly. Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 -> SetReplyTextInChatBox @0x004F4760. This is display sugar rather than routing: "/r hello" already SENT correctly through ChatInputParser's reply aliases. What was missing is that the player could not SEE who they were about to reply to before pressing enter. The trigger strings came out of the constant pool, not the decompiled listing — Binary Ninja renders them as bare data_* references with no preview: data_7C4C70 = "r " data_7C4C68 = "rp " data_7C4C58 = "reply " Retail stores them WITHOUT the leading prefix and tests the first character separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work. The research summary for this area listed the triggers as "/t ", "/tell " and "reply " — reading the pool corrected that. Three boundaries, each pinned by test because each is a way to get this subtly wrong: - The trailing space is PART of the trigger. "/r" alone must be left alone — the player may still be typing "/roleplay", and expanding early would hijack a different command mid-word. - Only on space. Running the replacer per keystroke would rewrite text out from under someone mid-word; retail keys on 0x20 specifically. - Only with the caret at the end. Otherwise the player is editing existing text, and expanding would corrupt a sentence they are part way through fixing. With nobody to reply to, nothing is rewritten — retail leaves the text alone rather than producing a tell addressed to nobody, and the ordinary submit path still reports "Someone must @tell you first!". Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 4 + src/AcDream.App/UI/UiField.cs | 26 +++++++ .../Chat/ChatTextReplacements.cs | 76 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiFieldTests.cs | 58 ++++++++++++++ .../Chat/ChatTextReplacementsTests.cs | 71 +++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 src/AcDream.Runtime/Chat/ChatTextReplacements.cs create mode 100644 tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 74c4682f..ccdf8f7b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -363,6 +363,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // its own authored background sprite (0x0600113A); same reasoning as the // transcript above. c.Input.SpriteResolve = resolve; + // Campaign CT slice B2: expand "/r " to "@tell {LastTeller}, " as the + // space lands, so the player sees the target before pressing enter. + c.Input.TextReplacer = text => + ChatTextReplacements.Expand(text, vm.LastIncomingTellSender); c.Input.OnSubmit = text => ChatCommandRouter.Submit( text, vm, busProvider(), c._activeChannel, c._tellTarget); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 43746a14..7f025c5f 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -156,6 +156,19 @@ public sealed class UiField : UiElement // ── Editing primitives ────────────────────────────────────────────── + /// + /// Offered the field's text each time a SPACE is typed at the end of it; + /// return a replacement to rewrite the field, or to + /// leave it alone. + /// + /// + /// Retail's typed-abbreviation expansion — "/r " becoming + /// "@tell {LastTeller}, " — lives here rather than in the submit path + /// because the player is meant to SEE who they are about to reply to + /// before pressing enter. + /// + public Func? TextReplacer { get; set; } + public void InsertChar(char c) { if (!Editable) return; @@ -169,6 +182,19 @@ public sealed class UiField : UiElement _text = _text.Insert(_caret, c.ToString()); _caret++; _historyIndex = -1; + + // Retail expands a typed abbreviation the moment the SPACE lands — + // ChatInterface::HandleTextReplacements @0x004F50D0 runs off the + // character-typed broadcast, keyed on 0x20. Only on space, and only + // when the caret is at the end, so it cannot rewrite text a player is + // editing in the middle of. + if (c == ' ' + && TextReplacer is { } replace + && _caret == _text.Length + && replace(_text) is { } replacement) + { + SetText(replacement); + } } public void Backspace() diff --git a/src/AcDream.Runtime/Chat/ChatTextReplacements.cs b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs new file mode 100644 index 00000000..6a8ff460 --- /dev/null +++ b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs @@ -0,0 +1,76 @@ +namespace AcDream.Runtime.Chat; + +/// +/// Retail's typed-abbreviation expansion in the chat entry. +/// +/// +/// +/// Typing /r rewrites the entry to @tell {LastTeller}, the +/// moment the space lands, so the player SEES who they are replying to before +/// pressing enter. ChatInterface::HandleTextReplacements @0x004F50D0 +/// runs off the character-typed broadcast and delegates to +/// SetReplyTextInChatBox @0x004F4760. +/// +/// +/// This is display sugar, not routing: /r hello already SENT correctly +/// without it (ChatInputParser's reply aliases). What was missing is +/// that the player could not see the target. +/// +/// +public static class ChatTextReplacements +{ + /// + /// The reply abbreviations, recovered from the constant pool rather than + /// the decompiled listing — Binary Ninja renders these as bare + /// data_* references. + /// + /// + /// data_7C4C70 = "r ", data_7C4C68 = "rp ", + /// data_7C4C58 = "reply ". Retail stores them WITHOUT the + /// leading prefix character and checks that separately, which is why both + /// prefixes work. + /// + private static readonly string[] ReplyVerbs = ["r", "rp", "reply"]; + + /// + /// Retail accepts either command prefix here — + /// SetReplyTextInChatBox tests the first character against + /// 0x2F ('/') OR 0x40 ('@') before comparing the verb. + /// + private const string CommandPrefixes = "/@"; + + /// + /// The expansion for , or + /// when it is not a reply abbreviation. + /// + /// + /// The last person to tell us. When absent, retail performs NO expansion — + /// it leaves the text alone rather than producing a tell addressed to + /// nobody, and the ordinary submit path is what reports + /// "Someone must @tell you first!". + /// + public static string? Expand(string? text, string? lastTeller) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(lastTeller)) + return null; + if (CommandPrefixes.IndexOf(text[0]) < 0) + return null; + + // The trailing space is part of the trigger: retail expands ON the + // space, so "/r" alone must be left alone — the player may still be + // typing "/roleplay". + foreach (string verb in ReplyVerbs) + { + if (text.Length == verb.Length + 2 + && text[^1] == ' ' + && string.Compare( + text, 1, verb, 0, verb.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + return $"@tell {lastTeller}, "; + } + } + + return null; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs index 21abf165..3184f7d9 100644 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -200,4 +200,62 @@ public class UiFieldTests Assert.Equal("a\nb", input.Text); Assert.Equal(0, submissions); } + + // ── CT-B2: typed-abbreviation expansion ───────────────────────────── + + [Fact] + public void TypingASpaceOffersTheTextToTheReplacer() + { + var input = new UiField(); + input.TextReplacer = text => text == "/r " ? "@tell Dww, " : null; + + foreach (char c in "/r ") + input.InsertChar(c); + + Assert.Equal("@tell Dww, ", input.Text); + Assert.Equal("@tell Dww, ".Length, input.CaretPos); + } + + [Fact] + public void ANonSpaceCharacterNeverTriggersTheReplacer() + { + // Retail keys the expansion on 0x20 specifically; running it on every + // keystroke would rewrite text out from under someone mid-word. + int calls = 0; + var input = new UiField(); + input.TextReplacer = _ => { calls++; return null; }; + + foreach (char c in "/reply") + input.InsertChar(c); + + Assert.Equal(0, calls); + } + + [Fact] + public void EditingInTheMiddleOfALineIsNotRewritten() + { + // The caret is not at the end, so the player is editing existing text + // rather than typing an abbreviation — expanding here would corrupt a + // sentence they are part way through fixing. + var input = new UiField(); + input.SetText("/r hello"); + input.MoveCaret(-5); // caret sits just after "/r" + input.TextReplacer = _ => "@tell Dww, "; + + input.InsertChar(' '); + + Assert.Equal("/r hello", input.Text); + } + + [Fact] + public void AReplacerReturningNullLeavesTheTextExactlyAsTyped() + { + var input = new UiField(); + input.TextReplacer = _ => null; + + foreach (char c in "hi ") + input.InsertChar(c); + + Assert.Equal("hi ", input.Text); + } } diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs new file mode 100644 index 00000000..36080908 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs @@ -0,0 +1,71 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Runtime.Tests.Chat; + +/// +/// Campaign CT slice B2: retail expands a typed reply abbreviation the moment +/// the space lands, so the player can see who they are replying to. +/// +public sealed class ChatTextReplacementsTests +{ + private const string LastTeller = "Dww"; + + [Theory] + // Recovered from the constant pool: data_7C4C70 "r ", data_7C4C68 "rp ", + // data_7C4C58 "reply " — and SetReplyTextInChatBox accepts either command + // prefix, testing the first character against '/' (0x2F) or '@' (0x40). + [InlineData("/r ")] + [InlineData("/rp ")] + [InlineData("/reply ")] + [InlineData("@r ")] + [InlineData("@rp ")] + [InlineData("@reply ")] + [InlineData("/R ")] + public void AReplyAbbreviationExpandsToATellAtTheLastTeller(string typed) + => Assert.Equal("@tell Dww, ", ChatTextReplacements.Expand(typed, LastTeller)); + + [Fact] + public void TheExpansionEndsInASpaceSoTypingContinuesCleanly() + { + // Without it the first character the player types joins the comma. + string expanded = ChatTextReplacements.Expand("/r ", LastTeller)!; + Assert.EndsWith(", ", expanded); + } + + [Theory] + // Still being typed — "/r" could yet become "/roleplay", so expanding + // before the space would hijack a different command mid-word. + [InlineData("/r")] + [InlineData("/rep")] + // A longer verb that merely STARTS with a reply verb. + [InlineData("/roleplay ")] + [InlineData("/rt ")] + // Already has a message: the trigger is the abbreviation ALONE. + [InlineData("/r hello ")] + // Not a command at all. + [InlineData("r ")] + [InlineData("hello ")] + [InlineData("")] + public void AnythingElseIsLeftAlone(string typed) + => Assert.Null(ChatTextReplacements.Expand(typed, LastTeller)); + + [Fact] + public void WithNobodyToReplyToNothingIsRewritten() + { + // Retail performs no expansion rather than producing a tell addressed + // to nobody; the ordinary submit path is what says + // "Someone must @tell you first!". + Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: null)); + Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: string.Empty)); + } + + [Fact] + public void ANameWithSpacesIsPreserved() + { + // AC names can be multiple words, and retail's own tell syntax needs + // the comma precisely because of that. + Assert.Equal( + "@tell Aunt Agatha, ", + ChatTextReplacements.Expand("/r ", "Aunt Agatha")); + } +}