feat(chat): Phase J - welcome message + own-echo dedup + long-form slash aliases + WeenieError templates

Six fixes from the 2026-04-25 live verify session.

1. ServerMessage (0xF7E0) wired to ChatLog. ACE's
   GameMessageSystemChat - used for the login banner "Welcome to
   Asheron's Call ... powered by ACEmulator ... type @acehelp" plus
   any future server broadcast - rides opcode 0xF7E0. The parser
   shipped in I.5 but the WorldSession.ServerMessageReceived event
   was never subscribed by GameWindow, so the welcome line was
   silently dropped. Subscribed now; same wave wires the missing
   EmoteHeard / SoulEmoteHeard / PlayerKilledReceived events that
   I.5 also left orphan.

2. Drop optimistic /say echo + plumb local-player-guid into ChatLog.
   ACE's HandleActionTalk broadcasts a HearSpeech back to the sender
   too, so we were double-printing every /say (own optimistic +
   server echo). New ChatLog.SetLocalPlayerGuid() pushes the chosen
   character guid in (mirrors VitalsVM pattern); OnLocalSpeech
   detects own-guid match and substitutes Sender="" so the formatter
   's IsOwnSpeaker path renders "You say, ..." instead of
   "+Acdream says, ...". Single line per /say.

3. IsOwnSpeaker check now applies to ChatKind.Channel too. Empty/
   "You" sender -> "[Allegiance] You say, \"text\"" instead of the
   "[Allegiance]  says, \"text\"" double-space hole that Phase I.6's
   OnSelfSent left when echoing legacy ChatChannel sends.

4. Long-form slash aliases: /general /allegiance /patron /vassals
   /monarch /covassals /fellowship /fellow /lookingforgroup
   /roleplay /rp /tr /gen, plus /s as alias for /say. Retail muscle
   memory expected these; the prior parser only recognized /g /a /p
   /v /m /cv /lfg /role and friends, so "/patron hello" fell
   through as /say with the literal "/patron" prefix.

5. WeenieError templates filled in for the codes the user hit:
   - 0x0414 YouAreNotInAllegiance  -> "You are not in an allegiance!"
   - 0x050F YouDoNotBelongToAFellowship -> "You do not belong to a Fellowship."
   Replaces the cryptic "WeenieError 0x0414" / "0x050F" lines.

6. @ command pass-through: ACE handles @help / @acehelp / @tele etc.
   server-side by intercepting Talk text with @ prefix; the user's
   message isn't broadcast and ACE replies via SystemChat. Drop the
   optimistic /say echo so the chat shows only the server's response
   (the SystemChat wiring from #1 surfaces it as [System] {help}).

Tests:
- 11 long-form-alias Theory cases on ChatInputParser.
- 3 own-guid-substitution cases on ChatLog (own match, different
  guid, pre-login fallback).
- Existing PrefixSubstring test refactored to "/genio" since the
  previous "/general" stub is now a real verb.

Solution total: 1021 green (243 Core.Net + 125 UI + 653 Core),
0 warnings, 0 errors. +14 tests.

Acceptance: at login, [System] Welcome to Asheron's Call appears.
Single "You say, \"hi\"" per /say. /allegiance with no allegiance
shows [Allegiance] You say, ... + [System] You are not in an
allegiance!. /patron / /vassals / /monarch route correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 21:07:56 +02:00
parent 3f7821c18d
commit 7726f62528
7 changed files with 177 additions and 20 deletions

View file

@ -1254,6 +1254,24 @@ public sealed class GameWindow : IDisposable
// Phase I.6: feed inbound TurbineChat events into the chat log. // Phase I.6: feed inbound TurbineChat events into the chat log.
// The Response variant is fire-and-forget (server-side ack); // The Response variant is fire-and-forget (server-side ack);
// EventSendToRoom is a real chat message broadcast to a room. // 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 => _liveSession.TurbineChatReceived += parsed =>
{ {
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev) if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
@ -1292,8 +1310,15 @@ public sealed class GameWindow : IDisposable
switch (cmd.Channel) switch (cmd.Channel)
{ {
case AcDream.UI.Abstractions.ChatChannelKind.Say: 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); liveSession.SendTalk(cmd.Text);
chat.OnSelfSent(AcDream.Core.Chat.ChatKind.LocalSpeech, cmd.Text);
break; break;
case AcDream.UI.Abstractions.ChatChannelKind.Tell: case AcDream.UI.Abstractions.ChatChannelKind.Tell:
if (string.IsNullOrEmpty(cmd.TargetName)) return; if (string.IsNullOrEmpty(cmd.TargetName)) return;
@ -1398,6 +1423,7 @@ public sealed class GameWindow : IDisposable
var chosen = _liveSession.Characters.Characters[0]; var chosen = _liveSession.Characters.Characters[0];
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry _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 _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 _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0); _liveSession.EnterWorld(user, characterIndex: 0);

View file

@ -24,6 +24,7 @@ public sealed class ChatLog
{ {
private readonly ConcurrentQueue<ChatEntry> _buffer = new(); private readonly ConcurrentQueue<ChatEntry> _buffer = new();
private readonly int _maxEntries; private readonly int _maxEntries;
private uint _localPlayerGuid;
public ChatLog(int maxEntries = 500) public ChatLog(int maxEntries = 500)
{ {
@ -39,6 +40,16 @@ public sealed class ChatLog
public int Count => _buffer.Count; public int Count => _buffer.Count;
/// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly
/// once per live session. Used by <see cref="OnLocalSpeech"/> 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 <c>"You say, ..."</c>.
/// </summary>
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
// ── Inbound adapters ───────────────────────────────────────────────────── // ── Inbound adapters ─────────────────────────────────────────────────────
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary> /// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>
@ -51,7 +62,13 @@ public sealed class ChatLog
/// </remarks> /// </remarks>
public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged) 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( Append(new ChatEntry(
Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech, Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech,
Sender: effectiveSender, Sender: effectiveSender,

View file

@ -77,6 +77,11 @@ public static class WeenieErrorMessages
// Trade // Trade
[0x0529] = "Trade Complete!", // TradeComplete [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 // Allegiance
[0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved [0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved
[0x0497] = "Your patron's Allegiance to you has been broken!", [0x0497] = "Your patron's Allegiance to you has been broken!",

View file

@ -39,26 +39,43 @@ public static class ChatInputParser
// Alias tables. Order matters only for error messages — verb // Alias tables. Order matters only for error messages — verb
// matching is exact-token, not prefix. // 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[] TellAliases = { "/tell", "/t" };
private static readonly string[] ReplyAliases = { "/reply", "/r" }; private static readonly string[] ReplyAliases = { "/reply", "/r" };
// Channel aliases. Each maps a single verb token to a channel kind. // Channel aliases. Each maps a single verb token to a channel kind.
// The same list drives both the verb test and the prefix-strip. // 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 = private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
{ {
("/g", ChatChannelKind.General), ("/g", ChatChannelKind.General),
("/f", ChatChannelKind.Fellowship), ("/general", ChatChannelKind.General),
("/a", ChatChannelKind.Allegiance), ("/gen", ChatChannelKind.General),
("/m", ChatChannelKind.Monarch), ("/f", ChatChannelKind.Fellowship),
("/p", ChatChannelKind.Patron), ("/fellow", ChatChannelKind.Fellowship),
("/v", ChatChannelKind.Vassals), ("/fellowship", ChatChannelKind.Fellowship),
("/cv", ChatChannelKind.CoVassals), ("/a", ChatChannelKind.Allegiance),
("/lfg", ChatChannelKind.Lfg), ("/allegiance", ChatChannelKind.Allegiance),
("/trade", ChatChannelKind.Trade), ("/m", ChatChannelKind.Monarch),
("/role", ChatChannelKind.Roleplay), ("/monarch", ChatChannelKind.Monarch),
("/society", ChatChannelKind.Society), ("/p", ChatChannelKind.Patron),
("/olthoi", ChatChannelKind.Olthoi), ("/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),
}; };
/// <summary> /// <summary>

View file

@ -114,7 +114,14 @@ public sealed class ChatVM
// is populated by callers that know the friendly name (the // is populated by callers that know the friendly name (the
// TurbineChat inbound dispatch and OnSelfSent for Channel // TurbineChat inbound dispatch and OnSelfSent for Channel
// kinds); falls back to "ch {ChannelId}" if not set. // 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 // Tell: SenderGuid != 0 means an incoming whisper; == 0 is the
// OnSelfSent echo where Sender carries the target name. Retail // OnSelfSent echo where Sender carries the target name. Retail
// wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"". // wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"".

View file

@ -0,0 +1,56 @@
using AcDream.Core.Chat;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Phase J: <see cref="ChatLog.SetLocalPlayerGuid"/> 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.
/// </summary>
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);
}
}

View file

@ -225,12 +225,41 @@ public sealed class ChatInputParserTests
[Fact] [Fact]
public void PrefixSubstring_IsNotAVerbMatch() public void PrefixSubstring_IsNotAVerbMatch()
{ {
// "/general" (no leading "/g " token) is NOT /g; it's just text. // Phase J added long-form aliases (/general, /allegiance,
// Must not be misclassified as /g + "eneral". // /patron, etc.). The exact-token rule still applies — a
var parsed = ChatInputParser.Parse("/general public", ChatChannelKind.Say, lastTellSender: null); // 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.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel); 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);
} }
} }