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")); + } +}