fix(chat): BuildTell wire field order + retail-style FormatEntry + suppress duplicate Channel echo
Three follow-up fixes from the 2026-04-25 live verify session.
1. CRITICAL: BuildTell wire field order. Our outbound layout was
[target_name, message] but ACE's GameActionTell.Handle reads
[message, target_name] (verified against
references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
/tell since Phase I.3 has been failing with WeenieError 0x052B
(CharacterNotAvailable) because ACE was looking up the message
text as the recipient name. Swapped the field order in
ChatRequests.BuildTell so message is written first; updated the
pinned BuildTell test to expect the corrected layout. The
WorldSessionChatTests round-trip continues to pass since SendTell
delegates to BuildTell.
2. Retail-style FormatEntry. The user asked for the canonical retail
strings:
/say (own): You say, "text"
/say (incoming): Name says, "text"
/tell (own echo): You tell Caith, "text"
/tell (incoming): Caith tells you, "text"
channel: [Trade] +Acdream says, "text"
/shout (own): You shout, "text"
/shout (incoming):Name shouts, "text"
Discriminators: SenderGuid == 0 distinguishes our own outbound
echoes (set by OnSelfSent) from real incoming whispers (carry the
sender's player guid). Sender == "" or "You" distinguishes our own
/say echoes (OnLocalSpeech substitutes "You" when the wire sender
is empty per holtburger client/messages.rs:476-487).
ChatEntry gains a new ChannelName slot so Channel-kind entries
render with the friendly room name ("Trade") instead of "ch 3".
Falls back to "ch {ChannelId}" when ChannelName isn't populated
(legacy ChatChannel inbound or older callers).
3. Suppress optimistic Channel echo. The user saw duplicates per
/trade /lfg in the live trace:
[ch 0] Trade: hello <-- our optimistic
[ch 3] +Acdream: [Trade] hello <-- ACE's TurbineChat broadcast
ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
broadcasts EventSendToRoom to ALL recipients in the room including
the sender, so the canonical echo always arrives via 0xF7DE. Drop
the optimistic OnSelfSent for Turbine kinds in GameWindow's
SendChatCmd handler; trust the server. Legacy ChatChannel paths
(Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
keep the optimistic echo because the legacy 0x0147 broadcast may
not always come back to the sender.
Inbound TurbineChat also stops embedding "[Trade] " into the
message text — passes the friendly name out-of-band via the new
channelName parameter on ChatLog.OnChannelBroadcast.
11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.
Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e17caa2942
commit
3f7821c18d
7 changed files with 204 additions and 47 deletions
|
|
@ -35,8 +35,13 @@ public sealed class ChatTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTell_IncludesBothStrings()
|
||||
public void BuildTell_WritesMessageFirstThenTarget()
|
||||
{
|
||||
// Wire order is message-then-target — ACE GameActionTell.Handle
|
||||
// reads `var message = ...; var target = ...;` in that sequence.
|
||||
// The previous (target-first) layout caused a 2026-04-25 live
|
||||
// bug where every /tell failed with WeenieError 0x052B because
|
||||
// ACE was looking up the message text as the recipient name.
|
||||
byte[] body = ChatRequests.BuildTell(
|
||||
gameActionSequence: 5, targetName: "Alice", message: "hey");
|
||||
|
||||
|
|
@ -45,14 +50,14 @@ public sealed class ChatTests
|
|||
|
||||
int pos = 12;
|
||||
ushort len1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
|
||||
Assert.Equal(5, len1);
|
||||
Assert.Equal("Alice", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 5)));
|
||||
Assert.Equal(3, len1);
|
||||
Assert.Equal("hey", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 3)));
|
||||
|
||||
// "Alice" record = 2+5=7, pad 1 → advance by 8.
|
||||
// "hey" record = 2+3=5, pad 3 → advance by 8.
|
||||
pos += 8;
|
||||
ushort len2 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
|
||||
Assert.Equal(3, len2);
|
||||
Assert.Equal("hey", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 3)));
|
||||
Assert.Equal(5, len2);
|
||||
Assert.Equal("Alice", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ public sealed class ChatVMTests
|
|||
var lines = vm.RecentLines();
|
||||
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.Equal("Caith: hello", lines[0]);
|
||||
Assert.Equal("Regal: world", lines[1]);
|
||||
Assert.Equal("Caith says, \"hello\"", lines[0]);
|
||||
Assert.Equal("Regal says, \"world\"", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -41,36 +41,62 @@ public sealed class ChatVMTests
|
|||
|
||||
// Tail = msg25..msg29 (5 entries, oldest first).
|
||||
Assert.Equal(5, lines.Count);
|
||||
Assert.Equal("A: msg25", lines[0]);
|
||||
Assert.Equal("A: msg29", lines[4]);
|
||||
Assert.Equal("A says, \"msg25\"", lines[0]);
|
||||
Assert.Equal("A says, \"msg29\"", lines[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatEntry_LocalSpeech_SenderColonText()
|
||||
public void FormatEntry_LocalSpeech_RetailStyleSays()
|
||||
{
|
||||
var entry = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
||||
Assert.Equal("Caith: hello", ChatVM.FormatEntry(entry));
|
||||
// Retail format: "Name says, \"text\"" for someone else;
|
||||
// "You say, \"text\"" for our own /say (sender == "" or "You").
|
||||
var incoming = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
||||
Assert.Equal("Caith says, \"hello\"", ChatVM.FormatEntry(incoming));
|
||||
|
||||
var ownEcho = new ChatEntry(ChatKind.LocalSpeech, "", "hi there", 0, 0);
|
||||
Assert.Equal("You say, \"hi there\"", ChatVM.FormatEntry(ownEcho));
|
||||
|
||||
var ownEchoSubst = new ChatEntry(ChatKind.LocalSpeech, "You", "shouted echo", 0, 0);
|
||||
Assert.Equal("You say, \"shouted echo\"", ChatVM.FormatEntry(ownEchoSubst));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatEntry_RangedSpeech_IncludesDistanceHint()
|
||||
public void FormatEntry_RangedSpeech_RetailStyleShouts()
|
||||
{
|
||||
var entry = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
||||
Assert.Equal("Caith says distantly: hello", ChatVM.FormatEntry(entry));
|
||||
var incoming = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
||||
Assert.Equal("Caith shouts, \"hello\"", ChatVM.FormatEntry(incoming));
|
||||
|
||||
var ownEcho = new ChatEntry(ChatKind.RangedSpeech, "You", "loud", 0, 0);
|
||||
Assert.Equal("You shout, \"loud\"", ChatVM.FormatEntry(ownEcho));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatEntry_Channel_IncludesChannelId()
|
||||
public void FormatEntry_Channel_UsesChannelNameWhenPresent()
|
||||
{
|
||||
var entry = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u);
|
||||
Assert.Equal("[ch 7] Caith: g'day", ChatVM.FormatEntry(entry));
|
||||
// Friendly name takes precedence — "[Trade] Caith says, \"...\""
|
||||
var named = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u)
|
||||
{
|
||||
ChannelName = "Trade",
|
||||
};
|
||||
Assert.Equal("[Trade] Caith says, \"g'day\"", ChatVM.FormatEntry(named));
|
||||
|
||||
// Falls back to "ch {id}" when ChannelName isn't set (legacy
|
||||
// path / older callers).
|
||||
var unnamed = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u);
|
||||
Assert.Equal("[ch 7] Caith says, \"g'day\"", ChatVM.FormatEntry(unnamed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatEntry_Tell_PrefixedWithTellTag()
|
||||
public void FormatEntry_Tell_RetailStyleTells()
|
||||
{
|
||||
var entry = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0);
|
||||
Assert.Equal("[Tell] Regal: psst", ChatVM.FormatEntry(entry));
|
||||
// SenderGuid != 0 -> incoming whisper -> "Regal tells you, ..."
|
||||
var incoming = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0);
|
||||
Assert.Equal("Regal tells you, \"psst\"", ChatVM.FormatEntry(incoming));
|
||||
|
||||
// SenderGuid == 0 -> our own outbound echo -> Sender carries
|
||||
// the target name -> "You tell Regal, ..."
|
||||
var ownEcho = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0, 0);
|
||||
Assert.Equal("You tell Regal, \"psst\"", ChatVM.FormatEntry(ownEcho));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -113,6 +139,6 @@ public sealed class ChatVMTests
|
|||
log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false);
|
||||
var after = vm.RecentLines();
|
||||
Assert.Single(after);
|
||||
Assert.Equal("Caith: hello", after[0]);
|
||||
Assert.Equal("Caith says, \"hello\"", after[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ public sealed class ChatVMCombatTests
|
|||
var line = Assert.Single(vm.RecentLinesDetailed());
|
||||
Assert.Equal(ChatKind.LocalSpeech, line.Kind);
|
||||
Assert.Null(line.CombatKind);
|
||||
Assert.Equal("Alice: hi", line.Text);
|
||||
Assert.Equal("Alice says, \"hi\"", line.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -73,7 +73,7 @@ public sealed class ChatVMCombatTests
|
|||
|
||||
// Plain LocalSpeech entry → Text; combat entry → TextColored.
|
||||
Assert.Contains(renderer.Calls, c =>
|
||||
c.Method == "Text" && (string?)c.Args[0] == "Alice: hi");
|
||||
c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\"");
|
||||
var coloredCall = Assert.Single(
|
||||
renderer.Calls,
|
||||
c => c.Method == "TextColored");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue