diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ca47532..4db989c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1254,6 +1254,24 @@ public sealed class GameWindow : IDisposable // Phase I.6: feed inbound TurbineChat events into the chat log. // The Response variant is fire-and-forget (server-side ack); // EventSendToRoom is a real chat message broadcast to a room. + // Phase J: ACE's GameMessageSystemChat (used for the login + // banner "Welcome to Asheron's Call ... type @acehelp" and + // for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage, + // parsed in I.5 but never wired. Surface it as a System + // chat line so the welcome banner appears + future server + // pushes (announcements, command responses) show. + _liveSession.ServerMessageReceived += sm => + Chat.OnSystemMessage(sm.Message, sm.ChatType); + + // Phase I.5 + J: emotes already had ChatLog adapters; wire + // their session events here so they actually reach chat. + _liveSession.EmoteHeard += emote => + Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid); + _liveSession.SoulEmoteHeard += emote => + Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid); + _liveSession.PlayerKilledReceived += pk => + Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid); + _liveSession.TurbineChatReceived += parsed => { if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev) @@ -1292,8 +1310,15 @@ public sealed class GameWindow : IDisposable switch (cmd.Channel) { case AcDream.UI.Abstractions.ChatChannelKind.Say: + // Phase J: drop optimistic /say echo. ACE's + // HandleActionTalk broadcasts a HearSpeech back + // to the sender too, and ChatLog.OnLocalSpeech + // detects own-guid match to render it as + // "You say, ...". Optimistic-echoing here + // doubled the line. ALSO: don't echo "@xxx" + // server-side admin commands — ACE consumes + // them silently and replies via SystemChat. liveSession.SendTalk(cmd.Text); - chat.OnSelfSent(AcDream.Core.Chat.ChatKind.LocalSpeech, cmd.Text); break; case AcDream.UI.Abstractions.ChatChannelKind.Tell: if (string.IsNullOrEmpty(cmd.TargetName)) return; @@ -1398,6 +1423,7 @@ public sealed class GameWindow : IDisposable var chosen = _liveSession.Characters.Characters[0]; _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid + Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 63e4334..49d5f76 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -24,6 +24,7 @@ public sealed class ChatLog { private readonly ConcurrentQueue _buffer = new(); private readonly int _maxEntries; + private uint _localPlayerGuid; public ChatLog(int maxEntries = 500) { @@ -39,6 +40,16 @@ public sealed class ChatLog public int Count => _buffer.Count; + /// + /// Push the authoritative local-player GUID from WorldSession. + /// One-way setter — only GameWindow should call it, exactly + /// once per live session. Used by to + /// recognize ACE's HearSpeech echo of our own /say (server's + /// HandleActionTalk broadcasts to all in range INCLUDING the + /// sender) and re-render it as "You say, ...". + /// + public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; + // ── Inbound adapters ───────────────────────────────────────────────────── /// Local or ranged HearSpeech (0x02BB / 0x02BC). @@ -51,7 +62,13 @@ public sealed class ChatLog /// public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged) { - string effectiveSender = string.IsNullOrEmpty(sender) ? "You" : sender; + // Phase J: ACE's HandleActionTalk broadcasts a HearSpeech echo + // back to the sender too. Detect own echo by guid match and + // substitute "" so the formatter renders "You say, ..." (single + // first-person echo) instead of "+Acdream says, ..." + // (third-person duplicate of our optimistic, already dropped). + bool isOwnEcho = _localPlayerGuid != 0 && senderGuid == _localPlayerGuid; + string effectiveSender = (isOwnEcho || string.IsNullOrEmpty(sender)) ? "You" : sender; Append(new ChatEntry( Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech, Sender: effectiveSender, diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index b5af01e..079d142 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -77,6 +77,11 @@ public static class WeenieErrorMessages // Trade [0x0529] = "Trade Complete!", // TradeComplete + // Allegiance / Fellowship membership errors (high-frequency + // when player isn't in either group and tries the channel) + [0x0414] = "You are not in an allegiance!", // YouAreNotInAllegiance + [0x050F] = "You do not belong to a Fellowship.", // YouDoNotBelongToAFellowship + // Allegiance [0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved [0x0497] = "Your patron's Allegiance to you has been broken!", diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index eea4925..138ed48 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -39,26 +39,43 @@ public static class ChatInputParser // Alias tables. Order matters only for error messages — verb // matching is exact-token, not prefix. - private static readonly string[] SayAliases = { "/say" }; + private static readonly string[] SayAliases = { "/say", "/s" }; private static readonly string[] TellAliases = { "/tell", "/t" }; private static readonly string[] ReplyAliases = { "/reply", "/r" }; // Channel aliases. Each maps a single verb token to a channel kind. // The same list drives both the verb test and the prefix-strip. + // Long-form aliases mirror retail muscle memory (e.g. "/allegiance" + // for "/a", "/patron" for "/p"). Phase J added the long forms after + // a 2026-04-25 live session showed "/patron hello" falling through + // as plain Say with the literal "/patron " prefix. private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs = { - ("/g", ChatChannelKind.General), - ("/f", ChatChannelKind.Fellowship), - ("/a", ChatChannelKind.Allegiance), - ("/m", ChatChannelKind.Monarch), - ("/p", ChatChannelKind.Patron), - ("/v", ChatChannelKind.Vassals), - ("/cv", ChatChannelKind.CoVassals), - ("/lfg", ChatChannelKind.Lfg), - ("/trade", ChatChannelKind.Trade), - ("/role", ChatChannelKind.Roleplay), - ("/society", ChatChannelKind.Society), - ("/olthoi", ChatChannelKind.Olthoi), + ("/g", ChatChannelKind.General), + ("/general", ChatChannelKind.General), + ("/gen", ChatChannelKind.General), + ("/f", ChatChannelKind.Fellowship), + ("/fellow", ChatChannelKind.Fellowship), + ("/fellowship", ChatChannelKind.Fellowship), + ("/a", ChatChannelKind.Allegiance), + ("/allegiance", ChatChannelKind.Allegiance), + ("/m", ChatChannelKind.Monarch), + ("/monarch", ChatChannelKind.Monarch), + ("/p", ChatChannelKind.Patron), + ("/patron", ChatChannelKind.Patron), + ("/v", ChatChannelKind.Vassals), + ("/vassals", ChatChannelKind.Vassals), + ("/cv", ChatChannelKind.CoVassals), + ("/covassals", ChatChannelKind.CoVassals), + ("/lfg", ChatChannelKind.Lfg), + ("/lookingforgroup", ChatChannelKind.Lfg), + ("/trade", ChatChannelKind.Trade), + ("/tr", ChatChannelKind.Trade), + ("/role", ChatChannelKind.Roleplay), + ("/rp", ChatChannelKind.Roleplay), + ("/roleplay", ChatChannelKind.Roleplay), + ("/society", ChatChannelKind.Society), + ("/olthoi", ChatChannelKind.Olthoi), }; /// diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 3365868..ade923d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -114,7 +114,14 @@ public sealed class ChatVM // is populated by callers that know the friendly name (the // TurbineChat inbound dispatch and OnSelfSent for Channel // kinds); falls back to "ch {ChannelId}" if not set. - ChatKind.Channel => $"[{ChannelLabel(entry)}] {entry.Sender} says, \"{entry.Text}\"", + // Empty/"You" sender → "[Channel] You say, ..." for our own + // optimistic echo on legacy ChatChannel and self-broadcast on + // turbine channels (server's EventSendToRoom carries the + // sender name; OnSelfSent for legacy channels leaves it + // empty so the formatter substitutes here). + ChatKind.Channel => IsOwnSpeaker(entry.Sender) + ? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\"" + : $"[{ChannelLabel(entry)}] {entry.Sender} says, \"{entry.Text}\"", // Tell: SenderGuid != 0 means an incoming whisper; == 0 is the // OnSelfSent echo where Sender carries the target name. Retail // wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"". diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs new file mode 100644 index 0000000..bd28064 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs @@ -0,0 +1,56 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Phase J: teaches the log +/// to recognize ACE's HearSpeech echo of the local player's own /say +/// — the server broadcasts to all in range INCLUDING the sender, so +/// we'd otherwise see both our optimistic "You say" and the +/// third-person "+Acdream says" from the server. Substituting the +/// sender to "" routes through the formatter's IsOwnSpeaker path so +/// only the singular first-person line shows. +/// +public sealed class ChatLogLocalGuidTests +{ + [Fact] + public void OnLocalSpeech_OwnGuidMatch_SubstitutesYou() + { + var log = new ChatLog(); + log.SetLocalPlayerGuid(0x5000_000A); + + log.OnLocalSpeech("+Acdream", "hello world", + senderGuid: 0x5000_000A, isRanged: false); + + var entry = log.Snapshot()[0]; + Assert.Equal(ChatKind.LocalSpeech, entry.Kind); + Assert.Equal("You", entry.Sender); + Assert.Equal("hello world", entry.Text); + } + + [Fact] + public void OnLocalSpeech_DifferentGuid_KeepsSenderName() + { + var log = new ChatLog(); + log.SetLocalPlayerGuid(0x5000_000A); + + log.OnLocalSpeech("Caith", "hi", + senderGuid: 0x5000_0042, isRanged: false); + + Assert.Equal("Caith", log.Snapshot()[0].Sender); + } + + [Fact] + public void OnLocalSpeech_NoLocalGuidSet_FallsBackToEmptySubstitution() + { + // Pre-login (guid not yet known), the existing empty-sender + // substitution still applies — server-driven ranged echoes + // arrive with sender="" before the player has a guid. + var log = new ChatLog(); + log.OnLocalSpeech("", "anyone home?", + senderGuid: 0u, isRanged: true); + + Assert.Equal("You", log.Snapshot()[0].Sender); + Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index 9bfb368..2ac4c2c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -225,12 +225,41 @@ public sealed class ChatInputParserTests [Fact] public void PrefixSubstring_IsNotAVerbMatch() { - // "/general" (no leading "/g " token) is NOT /g; it's just text. - // Must not be misclassified as /g + "eneral". - var parsed = ChatInputParser.Parse("/general public", ChatChannelKind.Say, lastTellSender: null); + // Phase J added long-form aliases (/general, /allegiance, + // /patron, etc.). The exact-token rule still applies — a + // verb prefix that ISN'T one of the listed aliases falls + // through to the default channel. "/genio" is not /g, /general, + // or /gen — must stay as Say carrying the literal text. + var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null); Assert.NotNull(parsed); Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel); - Assert.Equal("/general public", parsed.Value.Text); + Assert.Equal("/genio public", parsed.Value.Text); + } + + [Theory] + [InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")] + [InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")] + [InlineData("/patron need help", ChatChannelKind.Patron, "need help")] + [InlineData("/vassals listen up", ChatChannelKind.Vassals, "listen up")] + [InlineData("/monarch heads up", ChatChannelKind.Monarch, "heads up")] + [InlineData("/covassals tax season", ChatChannelKind.CoVassals, "tax season")] + [InlineData("/fellowship buff time", ChatChannelKind.Fellowship, "buff time")] + [InlineData("/fellow buff time", ChatChannelKind.Fellowship, "buff time")] + [InlineData("/lookingforgroup hunt invite", ChatChannelKind.Lfg, "hunt invite")] + [InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")] + [InlineData("/rp walk-up", ChatChannelKind.Roleplay, "walk-up")] + public void LongFormAliases_RouteToTheirChannel(string raw, ChatChannelKind expected, string text) + { + // Phase J: retail muscle memory uses long forms ("/patron" + // not just "/p"). Filed after a 2026-04-25 live test where + // "/patron hello" fell through as /say with the literal + // slash-prefixed text. + var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(parsed); + Assert.Equal(expected, parsed!.Value.Channel); + Assert.Null(parsed.Value.TargetName); + Assert.Equal(text, parsed.Value.Text); } }