acdream/tests/AcDream.Core.Net.Tests/Messages/SetTurbineChatChannelsTests.cs
Erik ca968fc766 feat(net+chat): #19 TurbineChat (0xF7DE) codec + ChatChannelInfo + SetTurbineChatChannels parser
Full port of holtburger's TurbineChat sidecar wire path:

- TurbineChat.cs: 0xF7DE codec with three payload variants
  (EventSendToRoom S->C, RequestSendToRoomById C->S, Response).
  10-field outer header (size_first/blob_type/dispatch_type/
  target_type/target_id/transport_type/transport_id/cookie/
  size_second + payload).
- UTF-16LE turbine string codec with 1-or-2 byte variable-length
  prefix (high bit on first byte signals 2-byte form). Mirrors
  holtburger's read_turbine_string / write_turbine_string at
  references/holtburger/.../messages/chat/turbine.rs:502-544.
- SetTurbineChatChannels.cs: 0x0295 GameEvent sub-opcode parser
  (10 x u32 channel ids). Wired through GameEventDispatcher in
  WorldSession ctor; routes to GameEventWiring + TurbineChatState.
- ChatChannelInfo.cs (Core): unified record union with Legacy
  (channel id + name) and Turbine (room id + chat type +
  dispatch type + name) variants, plus IsSelfEchoChannel
  predicate (Tells = false, channels = true so optimistic echo
  is suppressed where the server will echo).
- TurbineChatState.cs (Core): Enabled flag + 10 cached room ids
  + NextContextId() cookie counter starting at 1.
- WorldSession adds TurbineChatReceived + TurbineChannelsReceived
  events; SendTurbineChatTo outbound builds RequestSendToRoomById
  + sends through SendGameAction. ProcessDatagram dispatches
  0xF7DE at the top level.
- GameWindow constructs TurbineChatState, subscribes inbound
  EventSendToRoom -> ChatLog.OnChannelBroadcast; extends I.3's
  SendChatCmd handler to route Turbine kinds (General/Trade/Lfg/
  Roleplay/Society/Olthoi) through TurbineChat first, fall back
  to legacy ChatChannel send when state.Enabled == false.

Round-trip golden fixtures from holtburger source verified for
all three payload variants + UTF-16LE strings (short + long
prefix + non-ASCII Cafe + empty) + SetTurbineChatChannels.

26 new tests:
- TurbineChatTests, SetTurbineChatChannelsTests in Core.Net.Tests
- ChatChannelInfoTests, TurbineChatStateTests in Core.Tests

Solution total: 960 green (243 Core.Net + 625 Core + 92 UI).

ACE doesn't run a TurbineChat server, so codec is "ready when
needed" for retail-server-emulating setups. Legacy ChatChannel
fallback continues to work for current ACE-against-acdream play.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:44:56 +02:00

96 lines
3.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Phase I.6: <see cref="SetTurbineChatChannels"/> 10-u32 GameEvent
/// payload parser.
///
/// <para>
/// Golden fixture taken from holtburger
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
/// lines 654-679 (test <c>set_turbine_chat_channels_fixture</c>) — itself
/// generated by ACE's <c>SyntheticProtocolTests.GenerateTurbineChatFixtures</c>.
/// </para>
/// </summary>
public sealed class SetTurbineChatChannelsTests
{
[Fact]
public void TryParse_HoltburgerGoldenFixture_RoundTrips()
{
// Full game-message body from turbine.rs:655-657:
// B0F70000 outer 0xF7B0 GameEvent opcode
// 00000000 guid 0
// 21000000 sequence 0x21
// 95020000 eventType 0x0295 (SetTurbineChatChannels)
// <40-byte payload>
byte[] full = HexDecode(
"B0F7000000000000210000009502000078563412" +
"020000000300000004000000050000000A000000" +
"09000000070000000800000009000000");
// The parser consumes only the 40-byte payload after the
// GameEventEnvelope header (16 bytes).
var env = GameEventEnvelope.TryParse(full);
Assert.NotNull(env);
Assert.Equal(GameEventType.SetTurbineChatChannels, env!.Value.EventType);
var parsed = SetTurbineChatChannels.TryParse(env.Value.Payload.Span);
Assert.NotNull(parsed);
// Allegiance: 0x12345678 (Unknown id used in fixture).
Assert.Equal(0x12345678u, parsed!.Value.AllegianceRoom);
// General..Olthoi
Assert.Equal(2u, parsed.Value.GeneralRoom);
Assert.Equal(3u, parsed.Value.TradeRoom);
Assert.Equal(4u, parsed.Value.LfgRoom);
Assert.Equal(5u, parsed.Value.RoleplayRoom);
Assert.Equal(10u, parsed.Value.OlthoiRoom);
// Society = SocietyRadiantBlood (id 9) per fixture
Assert.Equal(9u, parsed.Value.SocietyRoom);
// Sub-society rooms 7, 8, 9
Assert.Equal(7u, parsed.Value.SocietyCelestialHandRoom);
Assert.Equal(8u, parsed.Value.SocietyEldrytchWebRoom);
Assert.Equal(9u, parsed.Value.SocietyRadiantBloodRoom);
}
[Fact]
public void Serialize_RoundTripsThroughTryParse()
{
var original = new SetTurbineChatChannels.Parsed(
AllegianceRoom: 0x100,
GeneralRoom: 0x200,
TradeRoom: 0x300,
LfgRoom: 0x400,
RoleplayRoom: 0x500,
OlthoiRoom: 0x600,
SocietyRoom: 0x700,
SocietyCelestialHandRoom: 0x800,
SocietyEldrytchWebRoom: 0x900,
SocietyRadiantBloodRoom: 0xA00);
byte[] payload = SetTurbineChatChannels.Serialize(original);
Assert.Equal(SetTurbineChatChannels.PayloadSize, payload.Length);
var roundTripped = SetTurbineChatChannels.TryParse(payload);
Assert.NotNull(roundTripped);
Assert.Equal(original, roundTripped!.Value);
}
[Fact]
public void TryParse_TruncatedPayload_ReturnsNull()
{
// 39 bytes — one short of the 40-byte minimum.
Assert.Null(SetTurbineChatChannels.TryParse(new byte[39]));
}
private static byte[] HexDecode(string hex)
{
if ((hex.Length & 1) != 0) throw new ArgumentException("hex length must be even");
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return result;
}
}