acdream/tests/AcDream.Core.Net.Tests/Messages/ChatTests.cs
Erik 3f7821c18d 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>
2026-04-25 20:49:02 +02:00

161 lines
6.4 KiB
C#

using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class ChatTests
{
[Fact]
public void BuildTalk_EmitsOpcodeAndString16L()
{
byte[] body = ChatRequests.BuildTalk(gameActionSequence: 3, message: "hi");
Assert.Equal(ChatRequests.TalkOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
// Verify the string16L starts at offset 12.
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
Assert.Equal(2, len);
Assert.Equal("hi", Encoding.ASCII.GetString(body.AsSpan(14, 2)));
// Record size = 2+2 = 4, no padding needed.
Assert.Equal(16, body.Length);
}
[Fact]
public void BuildTalk_EmitsPadding_WhenMessageLengthRequiresIt()
{
byte[] body = ChatRequests.BuildTalk(gameActionSequence: 3, message: "h");
// 2+1=3 bytes record → pad 1 byte.
// Total body = 12 (envelope) + 4 (str16L aligned) = 16.
Assert.Equal(16, body.Length);
}
[Fact]
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");
Assert.Equal(ChatRequests.TellOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
int pos = 12;
ushort len1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
Assert.Equal(3, len1);
Assert.Equal("hey", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 3)));
// "hey" record = 2+3=5, pad 3 → advance by 8.
pos += 8;
ushort len2 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
Assert.Equal(5, len2);
Assert.Equal("Alice", Encoding.ASCII.GetString(body.AsSpan(pos + 2, 5)));
}
[Fact]
public void BuildChatChannel_IncludesChannelId()
{
byte[] body = ChatRequests.BuildChatChannel(
gameActionSequence: 1, channelId: 42, message: "tell me the good dungeons");
Assert.Equal(ChatRequests.ChatChannelOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(42u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Fact]
public void HearSpeech_TryParse_LocalRoundTrip()
{
// Build a 0x02BB message and re-parse it.
byte[] talkBody = ChatRequests.BuildTalk(gameActionSequence: 0, message: "hello");
// Now synthesize the inbound HearSpeech format.
byte[] msg = PackString16L("hello");
byte[] sender = PackString16L("Alice");
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
int pos = 0;
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.LocalOpcode);
pos += 4;
Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length;
Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0xCAFEu); pos += 4;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0x0B); pos += 4; // Speech
var parsed = HearSpeech.TryParse(inbound);
Assert.NotNull(parsed);
Assert.Equal("hello", parsed!.Value.Text);
Assert.Equal("Alice", parsed.Value.SenderName);
Assert.Equal(0xCAFEu, parsed.Value.SenderGuid);
Assert.Equal(0x0Bu, parsed.Value.ChatType);
Assert.False(parsed.Value.IsRanged);
}
[Fact]
public void HearSpeech_TryParse_RangedFlag()
{
byte[] msg = PackString16L("X");
byte[] sender = PackString16L("Y");
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.RangedOpcode);
int pos = 4;
Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length;
Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0); pos += 4;
var parsed = HearSpeech.TryParse(inbound);
Assert.NotNull(parsed);
Assert.True(parsed!.Value.IsRanged);
}
[Fact]
public void HearSpeech_TryParse_WrongOpcode_ReturnsNull()
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(HearSpeech.TryParse(body));
}
[Fact]
public void HearSpeech_TryParse_PreservesWindows1252_RoundTrip()
{
// Phase I.5: ASCII would munge non-ASCII bytes into '?'. CP1252
// round-trips them. The high-byte 0xE9 = 'é' in Latin-1/CP1252.
byte[] msg = PackString16L("Café");
byte[] sender = PackString16L("Élise");
byte[] inbound = new byte[4 + msg.Length + sender.Length + 8];
int pos = 0;
BinaryPrimitives.WriteUInt32LittleEndian(inbound, HearSpeech.LocalOpcode);
pos += 4;
Array.Copy(msg, 0, inbound, pos, msg.Length); pos += msg.Length;
Array.Copy(sender, 0, inbound, pos, sender.Length); pos += sender.Length;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0u); pos += 4;
BinaryPrimitives.WriteUInt32LittleEndian(inbound.AsSpan(pos), 0u);
var parsed = HearSpeech.TryParse(inbound);
Assert.NotNull(parsed);
Assert.Equal("Café", parsed!.Value.Text);
Assert.Equal("Élise", parsed.Value.SenderName);
}
private static byte[] PackString16L(string s)
{
// Test helper now uses CP1252 to match the production codec.
byte[] data = Encoding.GetEncoding(1252).GetBytes(s);
int recordSize = 2 + data.Length;
int padding = (4 - (recordSize & 3)) & 3;
byte[] result = new byte[recordSize + padding];
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
Array.Copy(data, 0, result, 2, data.Length);
return result;
}
}