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>
This commit is contained in:
parent
f14296c75f
commit
ca968fc766
11 changed files with 1604 additions and 8 deletions
|
|
@ -277,6 +277,10 @@ public sealed class GameWindow : IDisposable
|
||||||
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
||||||
// Exposed publicly so plugins + UI panels can bind directly.
|
// Exposed publicly so plugins + UI panels can bind directly.
|
||||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||||
|
// Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global
|
||||||
|
// chat rooms. Empty/disabled until the server fires
|
||||||
|
// SetTurbineChatChannels (0x0295) shortly after EnterWorld.
|
||||||
|
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
|
||||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||||
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||||||
|
|
@ -1217,7 +1221,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// notifications / spell learns / wield events all update
|
// notifications / spell learns / wield events all update
|
||||||
// the corresponding client-side state without further glue.
|
// the corresponding client-side state without further glue.
|
||||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
AcDream.Core.Net.GameEventWiring.WireAll(
|
||||||
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer);
|
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer,
|
||||||
|
TurbineChat);
|
||||||
|
|
||||||
// Phase H.1: feed inbound HearSpeech into the chat log.
|
// Phase H.1: feed inbound HearSpeech into the chat log.
|
||||||
_liveSession.SpeechHeard += speech =>
|
_liveSession.SpeechHeard += speech =>
|
||||||
|
|
@ -1227,6 +1232,24 @@ public sealed class GameWindow : IDisposable
|
||||||
senderGuid: speech.SenderGuid,
|
senderGuid: speech.SenderGuid,
|
||||||
isRanged: speech.IsRanged);
|
isRanged: speech.IsRanged);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
_liveSession.TurbineChatReceived += parsed =>
|
||||||
|
{
|
||||||
|
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
|
||||||
|
{
|
||||||
|
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
|
||||||
|
Chat.OnChannelBroadcast(
|
||||||
|
channelId: ev.RoomId,
|
||||||
|
sender: ev.SenderName,
|
||||||
|
text: $"[{label}] {ev.Message}");
|
||||||
|
}
|
||||||
|
// Response (server ack of an outbound RequestSendToRoomById)
|
||||||
|
// and Unknown payloads are intentionally not surfaced — the
|
||||||
|
// user already saw their optimistic local echo.
|
||||||
|
};
|
||||||
|
|
||||||
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
|
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
|
||||||
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
||||||
// plus a local echo into ChatLog so the player sees their own
|
// plus a local echo into ChatLog so the player sees their own
|
||||||
|
|
@ -1235,6 +1258,8 @@ public sealed class GameWindow : IDisposable
|
||||||
var liveSession = _liveSession;
|
var liveSession = _liveSession;
|
||||||
var chat = Chat;
|
var chat = Chat;
|
||||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||||||
|
var turbineChat = TurbineChat;
|
||||||
|
uint playerGuid = _playerServerGuid;
|
||||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||||||
|
|
@ -1252,6 +1277,35 @@ public sealed class GameWindow : IDisposable
|
||||||
targetOrChannel: cmd.TargetName);
|
targetOrChannel: cmd.TargetName);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
// Phase I.6: try TurbineChat first for the global
|
||||||
|
// community channels (General/Trade/LFG/Roleplay/
|
||||||
|
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
|
||||||
|
// Allegiance is double-routed: try TurbineChat first
|
||||||
|
// (when the player has a Turbine allegiance room) and
|
||||||
|
// fall back to the legacy 0x0147 ChatChannel.
|
||||||
|
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
|
||||||
|
if (turbine is not null)
|
||||||
|
{
|
||||||
|
uint cookie = turbineChat.NextContextId();
|
||||||
|
// Use the live player guid if it's been captured;
|
||||||
|
// otherwise 0 (server treats unknown sender_id
|
||||||
|
// gracefully — the cookie is what we care about).
|
||||||
|
uint senderGuid = _playerServerGuid != 0u
|
||||||
|
? _playerServerGuid
|
||||||
|
: playerGuid;
|
||||||
|
liveSession.SendTurbineChatTo(
|
||||||
|
roomId: turbine.Value.RoomId,
|
||||||
|
chatType: turbine.Value.ChatType,
|
||||||
|
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
|
||||||
|
senderGuid: senderGuid,
|
||||||
|
text: cmd.Text,
|
||||||
|
cookie: cookie);
|
||||||
|
chat.OnSelfSent(
|
||||||
|
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
|
||||||
|
targetOrChannel: turbine.Value.DisplayName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
|
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
|
||||||
if (resolved is null) return;
|
if (resolved is null) return;
|
||||||
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
||||||
|
|
@ -4896,4 +4950,74 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _window?.Dispose();
|
public void Dispose() => _window?.Dispose();
|
||||||
|
|
||||||
|
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
|
||||||
|
/// to a runtime Turbine room. Returned by
|
||||||
|
/// <see cref="ResolveTurbineForKind"/> when the player has access
|
||||||
|
/// to that Turbine channel; null otherwise.
|
||||||
|
/// </summary>
|
||||||
|
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
|
||||||
|
/// runtime Turbine room id + chat-type. Returns null when
|
||||||
|
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
|
||||||
|
/// or the channel has no assigned room (e.g. player not in a society).
|
||||||
|
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
|
||||||
|
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
|
||||||
|
/// </summary>
|
||||||
|
private static TurbineResolution? ResolveTurbineForKind(
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind kind,
|
||||||
|
AcDream.Core.Chat.TurbineChatState state)
|
||||||
|
{
|
||||||
|
if (!state.Enabled) return null;
|
||||||
|
|
||||||
|
var (room, chatType, name) = kind switch
|
||||||
|
{
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
|
||||||
|
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.General =>
|
||||||
|
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
|
||||||
|
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
|
||||||
|
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
|
||||||
|
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Society =>
|
||||||
|
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
|
||||||
|
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
|
||||||
|
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
|
||||||
|
_ => (0u, 0u, string.Empty),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (room == 0u) return null;
|
||||||
|
return new TurbineResolution(room, chatType, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pick a human-readable label for a Turbine room broadcast. Uses
|
||||||
|
/// the chat-type when known (semantic name), falls back to the
|
||||||
|
/// numeric room id for unknown rooms.
|
||||||
|
/// </summary>
|
||||||
|
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
|
||||||
|
{
|
||||||
|
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
|
||||||
|
{
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||||||
|
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
|
||||||
|
_ => $"Room 0x{roomId:X8}",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ public static class GameEventWiring
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
ChatLog chat,
|
ChatLog chat,
|
||||||
LocalPlayerState? localPlayer = null)
|
LocalPlayerState? localPlayer = null,
|
||||||
|
TurbineChatState? turbineChat = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
@ -66,6 +67,34 @@ public static class GameEventWiring
|
||||||
if (s is not null) chat.OnPopup(s);
|
if (s is not null) chat.OnPopup(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
|
||||||
|
// Phase I.6: arrives once at login (and after chat-server reconnect)
|
||||||
|
// listing the per-session room ids assigned to General / Trade /
|
||||||
|
// LFG / Roleplay / Society / Olthoi (and the optional Allegiance
|
||||||
|
// Turbine room). Without this the TurbineChat outbound path stays
|
||||||
|
// disabled and the chat panel falls back to the legacy ChatChannel
|
||||||
|
// GameAction. See holtburger client/messages.rs:220-223 for the
|
||||||
|
// server-message handling pattern.
|
||||||
|
if (turbineChat is not null)
|
||||||
|
{
|
||||||
|
dispatcher.Register(GameEventType.SetTurbineChatChannels, e =>
|
||||||
|
{
|
||||||
|
var p = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||||||
|
if (p is null) return;
|
||||||
|
turbineChat.OnChannelsReceived(
|
||||||
|
allegianceRoom: p.Value.AllegianceRoom,
|
||||||
|
generalRoom: p.Value.GeneralRoom,
|
||||||
|
tradeRoom: p.Value.TradeRoom,
|
||||||
|
lfgRoom: p.Value.LfgRoom,
|
||||||
|
roleplayRoom: p.Value.RoleplayRoom,
|
||||||
|
olthoiRoom: p.Value.OlthoiRoom,
|
||||||
|
societyRoom: p.Value.SocietyRoom,
|
||||||
|
societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom,
|
||||||
|
societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom,
|
||||||
|
societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Errors ───────────────────────────────────────────────
|
// ── Errors ───────────────────────────────────────────────
|
||||||
// Phase I.5: WeenieError + WeenieErrorWithString parsers existed
|
// Phase I.5: WeenieError + WeenieErrorWithString parsers existed
|
||||||
// (GameEvents.ParseWeenieError(WithString)) but were never registered.
|
// (GameEvents.ParseWeenieError(WithString)) but were never registered.
|
||||||
|
|
|
||||||
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>SetTurbineChatChannels (0x0295)</c> — GameEvent (0xF7B0 sub-opcode)
|
||||||
|
/// the server fires once at login (and after a chat-server reconnect)
|
||||||
|
/// listing the runtime room ids assigned to the global community
|
||||||
|
/// channels (General, Trade, LFG, Roleplay, Society + sub-societies,
|
||||||
|
/// Olthoi, plus the optional Allegiance Turbine room).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Wire layout (10 sequential u32s, payload only — the 0xF7B0 envelope
|
||||||
|
/// is parsed by <see cref="GameEventEnvelope"/>; this parser consumes
|
||||||
|
/// the payload slice):
|
||||||
|
/// <code>
|
||||||
|
/// u32 allegianceRoom // 0 = None (server-side flag)
|
||||||
|
/// u32 generalRoom
|
||||||
|
/// u32 tradeRoom
|
||||||
|
/// u32 lfgRoom
|
||||||
|
/// u32 roleplayRoom
|
||||||
|
/// u32 olthoiRoom
|
||||||
|
/// u32 societyRoom // 0 = None (player not in a society)
|
||||||
|
/// u32 societyCelestialHandRoom
|
||||||
|
/// u32 societyEldrytchWebRoom
|
||||||
|
/// u32 societyRadiantBloodRoom
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Source: holtburger
|
||||||
|
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
|
||||||
|
/// lines 132-209 (struct + ProtocolUnpack/ProtocolPack).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class SetTurbineChatChannels
|
||||||
|
{
|
||||||
|
/// <summary>GameEvent sub-opcode (NOT a top-level GameMessage opcode).</summary>
|
||||||
|
public const uint EventType = 0x0295u;
|
||||||
|
|
||||||
|
/// <summary>Payload size in bytes (10 u32 fields).</summary>
|
||||||
|
public const int PayloadSize = 40;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed channel ids. Optional fields (<see cref="AllegianceRoom"/>,
|
||||||
|
/// <see cref="SocietyRoom"/>) carry <c>0u</c> when the server signals
|
||||||
|
/// "not applicable" — callers should treat 0 as None.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct Parsed(
|
||||||
|
uint AllegianceRoom,
|
||||||
|
uint GeneralRoom,
|
||||||
|
uint TradeRoom,
|
||||||
|
uint LfgRoom,
|
||||||
|
uint RoleplayRoom,
|
||||||
|
uint OlthoiRoom,
|
||||||
|
uint SocietyRoom,
|
||||||
|
uint SocietyCelestialHandRoom,
|
||||||
|
uint SocietyEldrytchWebRoom,
|
||||||
|
uint SocietyRadiantBloodRoom);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the payload of a 0xF7B0 envelope whose event type is
|
||||||
|
/// <see cref="EventType"/>. The slice must contain exactly the 10
|
||||||
|
/// channel u32s (40 bytes); shorter buffers return null.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
if (payload.Length < PayloadSize) return null;
|
||||||
|
|
||||||
|
uint allegiance = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
|
||||||
|
uint general = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4));
|
||||||
|
uint trade = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8, 4));
|
||||||
|
uint lfg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12, 4));
|
||||||
|
uint roleplay = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(16, 4));
|
||||||
|
uint olthoi = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(20, 4));
|
||||||
|
uint society = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(24, 4));
|
||||||
|
uint societyCelHan = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(28, 4));
|
||||||
|
uint societyEldWeb = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(32, 4));
|
||||||
|
uint societyRadBlo = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(36, 4));
|
||||||
|
|
||||||
|
return new Parsed(
|
||||||
|
AllegianceRoom: allegiance,
|
||||||
|
GeneralRoom: general,
|
||||||
|
TradeRoom: trade,
|
||||||
|
LfgRoom: lfg,
|
||||||
|
RoleplayRoom: roleplay,
|
||||||
|
OlthoiRoom: olthoi,
|
||||||
|
SocietyRoom: society,
|
||||||
|
SocietyCelestialHandRoom: societyCelHan,
|
||||||
|
SocietyEldrytchWebRoom: societyEldWeb,
|
||||||
|
SocietyRadiantBloodRoom: societyRadBlo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize <paramref name="parsed"/> into a 40-byte payload buffer
|
||||||
|
/// (the 0xF7B0 envelope is the caller's responsibility). Used by
|
||||||
|
/// fixture round-trip tests.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] Serialize(Parsed parsed)
|
||||||
|
{
|
||||||
|
var buf = new byte[PayloadSize];
|
||||||
|
var span = buf.AsSpan();
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span, parsed.AllegianceRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4, 4), parsed.GeneralRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8, 4), parsed.TradeRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12, 4), parsed.LfgRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), parsed.RoleplayRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(20, 4), parsed.OlthoiRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(24, 4), parsed.SocietyRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(28, 4), parsed.SocietyCelestialHandRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(32, 4), parsed.SocietyEldrytchWebRoom);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(36, 4), parsed.SocietyRadiantBloodRoom);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
491
src/AcDream.Core.Net/Messages/TurbineChat.cs
Normal file
491
src/AcDream.Core.Net/Messages/TurbineChat.cs
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>TurbineChat (0xF7DE)</c> — top-level GameMessage for retail's
|
||||||
|
/// global chat rooms (General / Trade / LFG / Roleplay / Society /
|
||||||
|
/// Olthoi). Carries three payload variants: server-to-client event,
|
||||||
|
/// client-to-server request, and server-to-client response (ack).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Wire layout — 9-u32 header (36 bytes) followed by a length-prefixed
|
||||||
|
/// payload. Strings inside the payload use a Turbine-specific
|
||||||
|
/// 1-or-2-byte-prefix UTF-16LE encoding (NOT the CP1252 String16L used
|
||||||
|
/// elsewhere).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Source: holtburger
|
||||||
|
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
|
||||||
|
/// lines 211-544 (struct, ProtocolUnpack, ProtocolPack,
|
||||||
|
/// read/write_turbine_string).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TurbineChat
|
||||||
|
{
|
||||||
|
/// <summary>Top-level GameMessage opcode.</summary>
|
||||||
|
public const uint Opcode = 0xF7DEu;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ACE's hard-coded request id for SendToRoomById — server rejects any
|
||||||
|
/// other value (see turbine.rs:5,317-321).
|
||||||
|
/// </summary>
|
||||||
|
private const uint SendToRoomByIdResponseId = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ACE's hard-coded method id for SendToRoomById (see turbine.rs:6).
|
||||||
|
/// </summary>
|
||||||
|
private const uint SendToRoomByIdMethodId = 2;
|
||||||
|
|
||||||
|
/// <summary>Header field count × u32 (= 36 bytes).</summary>
|
||||||
|
public const int HeaderSize = 36;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Enums (raw u32 values lifted from turbine.rs:8-95)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public enum BlobType : uint
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
EventBinary = 1,
|
||||||
|
EventXmlRpc = 2,
|
||||||
|
RequestBinary = 3,
|
||||||
|
RequestXmlRpc = 4,
|
||||||
|
ResponseBinary = 5,
|
||||||
|
ResponseXmlRpc = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DispatchType : uint
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
SendToRoomByName = 1,
|
||||||
|
SendToRoomById = 2,
|
||||||
|
CreateRoom = 3,
|
||||||
|
InviteClientToRoomById = 4,
|
||||||
|
EjectClientFromRoomById = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChatType : uint
|
||||||
|
{
|
||||||
|
Undef = 0,
|
||||||
|
Allegiance = 1,
|
||||||
|
General = 2,
|
||||||
|
Trade = 3,
|
||||||
|
Lfg = 4,
|
||||||
|
Roleplay = 5,
|
||||||
|
Society = 6,
|
||||||
|
SocietyCelHan = 7,
|
||||||
|
SocietyEldWeb = 8,
|
||||||
|
SocietyRadBlo = 9,
|
||||||
|
Olthoi = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Payload variants (matches turbine.rs:223-250)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Discriminated union over the three known payload shapes.</summary>
|
||||||
|
public abstract record Payload
|
||||||
|
{
|
||||||
|
/// <summary>S→C: server announces a chat message in a Turbine room.</summary>
|
||||||
|
public sealed record EventSendToRoom(
|
||||||
|
uint RoomId,
|
||||||
|
string SenderName,
|
||||||
|
string Message,
|
||||||
|
uint ExtraDataSize,
|
||||||
|
uint SenderId,
|
||||||
|
int HResult,
|
||||||
|
uint ChatType) : Payload;
|
||||||
|
|
||||||
|
/// <summary>C→S: client sends a chat message into a Turbine room by id.</summary>
|
||||||
|
public sealed record RequestSendToRoomById(
|
||||||
|
uint ContextId,
|
||||||
|
uint RoomId,
|
||||||
|
string Message,
|
||||||
|
uint ExtraDataSize,
|
||||||
|
uint SenderId,
|
||||||
|
int HResult,
|
||||||
|
uint ChatType) : Payload;
|
||||||
|
|
||||||
|
/// <summary>S→C ack for a previous client request (cookie echo + result).</summary>
|
||||||
|
public sealed record Response(
|
||||||
|
uint ContextId,
|
||||||
|
uint ResponseId,
|
||||||
|
uint MethodId,
|
||||||
|
int HResult) : Payload;
|
||||||
|
|
||||||
|
/// <summary>Catch-all for unknown blob_type / dispatch_type pairs.</summary>
|
||||||
|
public sealed record Unknown(byte[] Bytes) : Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parsed result of <see cref="TryParse"/>.</summary>
|
||||||
|
public readonly record struct Parsed(
|
||||||
|
BlobType BlobType,
|
||||||
|
DispatchType DispatchType,
|
||||||
|
uint TargetType,
|
||||||
|
uint TargetId,
|
||||||
|
uint TransportType,
|
||||||
|
uint TransportId,
|
||||||
|
uint Cookie,
|
||||||
|
Payload Body);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Parse — body INCLUDES the leading 0xF7DE opcode word.
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a TurbineChat GameMessage body. <paramref name="body"/>
|
||||||
|
/// must include the leading <see cref="Opcode"/> u32. Returns null
|
||||||
|
/// for any structural error (truncation, unknown blob_type, ACE
|
||||||
|
/// id-mismatch in RequestSendToRoomById, malformed UTF-16 string).
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(byte[] body)
|
||||||
|
{
|
||||||
|
if (body is null || body.Length < 4 + HeaderSize) return null;
|
||||||
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pos = 4;
|
||||||
|
uint sizeFirst = ReadU32(body, ref pos); // covers header+payload+4
|
||||||
|
uint blobTypeRaw = ReadU32(body, ref pos);
|
||||||
|
uint dispatchTypeRaw = ReadU32(body, ref pos);
|
||||||
|
uint targetType = ReadU32(body, ref pos);
|
||||||
|
uint targetId = ReadU32(body, ref pos);
|
||||||
|
uint transportType = ReadU32(body, ref pos);
|
||||||
|
uint transportId = ReadU32(body, ref pos);
|
||||||
|
uint cookie = ReadU32(body, ref pos);
|
||||||
|
uint sizeSecond = ReadU32(body, ref pos); // = 8 + payload.len
|
||||||
|
|
||||||
|
// sizeSecond - 8 = expected payload bytes per turbine.rs:373
|
||||||
|
if (sizeSecond < 8) return null;
|
||||||
|
int expectedPayload = checked((int)(sizeSecond - 8));
|
||||||
|
if (body.Length - pos < expectedPayload) return null;
|
||||||
|
|
||||||
|
// Validate blob_type / dispatch_type discriminants — Rust's
|
||||||
|
// from_repr would return None for unknown values; we mirror
|
||||||
|
// by treating those as "Unknown payload bytes" rather than
|
||||||
|
// hard-rejecting (be permissive on unrecognised servers).
|
||||||
|
if (!IsKnownBlobType(blobTypeRaw)) return null;
|
||||||
|
if (!IsKnownDispatchType(dispatchTypeRaw)) return null;
|
||||||
|
|
||||||
|
BlobType blobType = (BlobType)blobTypeRaw;
|
||||||
|
DispatchType dispatchType = (DispatchType)dispatchTypeRaw;
|
||||||
|
int payloadStart = pos;
|
||||||
|
|
||||||
|
Payload payload;
|
||||||
|
switch ((blobType, dispatchType))
|
||||||
|
{
|
||||||
|
case (BlobType.EventBinary, DispatchType.SendToRoomByName):
|
||||||
|
{
|
||||||
|
uint channelId = ReadU32(body, ref pos);
|
||||||
|
string senderName = ReadTurbineString(body, ref pos);
|
||||||
|
string message = ReadTurbineString(body, ref pos);
|
||||||
|
if (body.Length - pos < 16) return null;
|
||||||
|
uint extraDataSize = ReadU32(body, ref pos);
|
||||||
|
uint senderId = ReadU32(body, ref pos);
|
||||||
|
int hresult = (int)ReadU32(body, ref pos);
|
||||||
|
uint chatTypeRaw = ReadU32(body, ref pos);
|
||||||
|
|
||||||
|
payload = new Payload.EventSendToRoom(
|
||||||
|
RoomId: channelId,
|
||||||
|
SenderName: senderName,
|
||||||
|
Message: message,
|
||||||
|
ExtraDataSize: extraDataSize,
|
||||||
|
SenderId: senderId,
|
||||||
|
HResult: hresult,
|
||||||
|
ChatType: chatTypeRaw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case (BlobType.RequestBinary, DispatchType.SendToRoomById):
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 16) return null;
|
||||||
|
uint contextId = ReadU32(body, ref pos);
|
||||||
|
uint responseId = ReadU32(body, ref pos);
|
||||||
|
uint methodId = ReadU32(body, ref pos);
|
||||||
|
uint roomId = ReadU32(body, ref pos);
|
||||||
|
|
||||||
|
// ACE rejects unless the inner request and method ids
|
||||||
|
// are the canonical SendToRoomById pair.
|
||||||
|
if (responseId != SendToRoomByIdResponseId
|
||||||
|
|| methodId != SendToRoomByIdMethodId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string message = ReadTurbineString(body, ref pos);
|
||||||
|
if (body.Length - pos < 16) return null;
|
||||||
|
uint extraDataSize = ReadU32(body, ref pos);
|
||||||
|
uint senderId = ReadU32(body, ref pos);
|
||||||
|
int hresult = (int)ReadU32(body, ref pos);
|
||||||
|
uint chatTypeRaw = ReadU32(body, ref pos);
|
||||||
|
|
||||||
|
payload = new Payload.RequestSendToRoomById(
|
||||||
|
ContextId: contextId,
|
||||||
|
RoomId: roomId,
|
||||||
|
Message: message,
|
||||||
|
ExtraDataSize: extraDataSize,
|
||||||
|
SenderId: senderId,
|
||||||
|
HResult: hresult,
|
||||||
|
ChatType: chatTypeRaw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case (BlobType.ResponseBinary, _):
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 16) return null;
|
||||||
|
uint contextId = ReadU32(body, ref pos);
|
||||||
|
uint responseId = ReadU32(body, ref pos);
|
||||||
|
uint methodId = ReadU32(body, ref pos);
|
||||||
|
int hresult = (int)ReadU32(body, ref pos);
|
||||||
|
|
||||||
|
payload = new Payload.Response(
|
||||||
|
ContextId: contextId,
|
||||||
|
ResponseId: responseId,
|
||||||
|
MethodId: methodId,
|
||||||
|
HResult: hresult);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
// Unknown blob/dispatch combination — capture the
|
||||||
|
// payload bytes verbatim so callers can route or
|
||||||
|
// log without hard-rejecting the message.
|
||||||
|
int remaining = expectedPayload;
|
||||||
|
if (body.Length - pos < remaining) return null;
|
||||||
|
var bytes = new byte[remaining];
|
||||||
|
Array.Copy(body, pos, bytes, 0, remaining);
|
||||||
|
pos += remaining;
|
||||||
|
payload = new Payload.Unknown(bytes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip any trailing padding the server tacked on (per
|
||||||
|
// turbine.rs:372-376 — consumed may be less than expected).
|
||||||
|
int consumed = pos - payloadStart;
|
||||||
|
if (consumed < expectedPayload)
|
||||||
|
{
|
||||||
|
int slack = expectedPayload - consumed;
|
||||||
|
if (body.Length - pos < slack) return null;
|
||||||
|
pos += slack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// size_first should equal 40 + payload.len; sanity-check but
|
||||||
|
// don't reject if the server padded oddly.
|
||||||
|
_ = sizeFirst;
|
||||||
|
|
||||||
|
return new Parsed(
|
||||||
|
BlobType: blobType,
|
||||||
|
DispatchType: dispatchType,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
TransportType: transportType,
|
||||||
|
TransportId: transportId,
|
||||||
|
Cookie: cookie,
|
||||||
|
Body: payload);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Build — produces the full GameMessage body (with the opcode).
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a TurbineChat GameMessage body. The returned bytes include
|
||||||
|
/// the leading <see cref="Opcode"/> u32, so they can be sent
|
||||||
|
/// directly through <c>WorldSession.SendGameMessage</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Sizes (<c>size_first</c>, <c>size_second</c>) are computed from
|
||||||
|
/// <paramref name="payload"/>; callers should not pre-size them.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] Build(
|
||||||
|
BlobType blobType,
|
||||||
|
DispatchType dispatchType,
|
||||||
|
uint targetType,
|
||||||
|
uint targetId,
|
||||||
|
uint transportType,
|
||||||
|
uint transportId,
|
||||||
|
uint cookie,
|
||||||
|
Payload payload)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(payload);
|
||||||
|
|
||||||
|
// Step 1: serialise the payload to its own buffer so we know its size.
|
||||||
|
var payloadBuf = new List<byte>(64);
|
||||||
|
WritePayload(payloadBuf, payload);
|
||||||
|
|
||||||
|
uint payloadLen = (uint)payloadBuf.Count;
|
||||||
|
// ACE: first_size = 40 + payload.len; second_size = 8 + payload.len.
|
||||||
|
// (per turbine.rs:447-449 — covers the span plus an additional 4 bytes.)
|
||||||
|
uint sizeFirst = 40u + payloadLen;
|
||||||
|
uint sizeSecond = 8u + payloadLen;
|
||||||
|
|
||||||
|
// Step 2: assemble the framed GameMessage: opcode (4) + header (36) + payload.
|
||||||
|
var buf = new byte[4 + HeaderSize + payloadBuf.Count];
|
||||||
|
var span = buf.AsSpan();
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
WriteU32(span, ref pos, Opcode);
|
||||||
|
WriteU32(span, ref pos, sizeFirst);
|
||||||
|
WriteU32(span, ref pos, (uint)blobType);
|
||||||
|
WriteU32(span, ref pos, (uint)dispatchType);
|
||||||
|
WriteU32(span, ref pos, targetType);
|
||||||
|
WriteU32(span, ref pos, targetId);
|
||||||
|
WriteU32(span, ref pos, transportType);
|
||||||
|
WriteU32(span, ref pos, transportId);
|
||||||
|
WriteU32(span, ref pos, cookie);
|
||||||
|
WriteU32(span, ref pos, sizeSecond);
|
||||||
|
|
||||||
|
for (int i = 0; i < payloadBuf.Count; i++)
|
||||||
|
buf[pos + i] = payloadBuf[i];
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePayload(List<byte> buf, Payload payload)
|
||||||
|
{
|
||||||
|
switch (payload)
|
||||||
|
{
|
||||||
|
case Payload.EventSendToRoom e:
|
||||||
|
AppendU32(buf, e.RoomId);
|
||||||
|
WriteTurbineString(buf, e.SenderName);
|
||||||
|
WriteTurbineString(buf, e.Message);
|
||||||
|
AppendU32(buf, e.ExtraDataSize);
|
||||||
|
AppendU32(buf, e.SenderId);
|
||||||
|
AppendU32(buf, unchecked((uint)e.HResult));
|
||||||
|
AppendU32(buf, e.ChatType);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Payload.RequestSendToRoomById r:
|
||||||
|
AppendU32(buf, r.ContextId);
|
||||||
|
AppendU32(buf, SendToRoomByIdResponseId);
|
||||||
|
AppendU32(buf, SendToRoomByIdMethodId);
|
||||||
|
AppendU32(buf, r.RoomId);
|
||||||
|
WriteTurbineString(buf, r.Message);
|
||||||
|
AppendU32(buf, r.ExtraDataSize);
|
||||||
|
AppendU32(buf, r.SenderId);
|
||||||
|
AppendU32(buf, unchecked((uint)r.HResult));
|
||||||
|
AppendU32(buf, r.ChatType);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Payload.Response resp:
|
||||||
|
AppendU32(buf, resp.ContextId);
|
||||||
|
AppendU32(buf, resp.ResponseId);
|
||||||
|
AppendU32(buf, resp.MethodId);
|
||||||
|
AppendU32(buf, unchecked((uint)resp.HResult));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Payload.Unknown u:
|
||||||
|
buf.AddRange(u.Bytes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// UTF-16LE turbine string codec (turbine.rs:502-544)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read a Turbine UTF-16LE string with the 1-or-2-byte length
|
||||||
|
/// prefix. The length is in UTF-16 code units (NOT bytes); when
|
||||||
|
/// the high bit of the first byte is set the prefix is two bytes
|
||||||
|
/// (high 7 bits of byte 0 + all 8 bits of byte 1).
|
||||||
|
/// </summary>
|
||||||
|
public static string ReadTurbineString(byte[] data, ref int pos)
|
||||||
|
{
|
||||||
|
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len");
|
||||||
|
int chars = data[pos];
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
if ((chars & 0x80) != 0)
|
||||||
|
{
|
||||||
|
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len2");
|
||||||
|
chars = ((chars & 0x7F) << 8) | data[pos];
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
long bytesLen = (long)chars * 2L;
|
||||||
|
if (bytesLen > int.MaxValue || data.Length - pos < bytesLen)
|
||||||
|
throw new FormatException("turbine str: truncated body");
|
||||||
|
|
||||||
|
// String.from_utf16 in Rust validates surrogate pairs; .NET's
|
||||||
|
// Encoding.Unicode.GetString matches that semantics.
|
||||||
|
string s = Encoding.Unicode.GetString(data, pos, (int)bytesLen);
|
||||||
|
pos += (int)bytesLen;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write a Turbine UTF-16LE string. Strings shorter than 0x80 code
|
||||||
|
/// units use a 1-byte prefix; longer strings use a 2-byte prefix
|
||||||
|
/// with the high bit of byte 0 set as the discriminator.
|
||||||
|
/// </summary>
|
||||||
|
public static void WriteTurbineString(List<byte> buf, string s)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(buf);
|
||||||
|
ArgumentNullException.ThrowIfNull(s);
|
||||||
|
|
||||||
|
// UTF-16 code-unit count (NOT char count for surrogate pairs).
|
||||||
|
// s.Length on a .NET string is exactly the UTF-16 code-unit count.
|
||||||
|
int chars = s.Length;
|
||||||
|
if (chars >= 0x8000)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"turbine string exceeds 2-byte length prefix range (max 0x7FFF code units)",
|
||||||
|
nameof(s));
|
||||||
|
|
||||||
|
if (chars < 0x80)
|
||||||
|
{
|
||||||
|
buf.Add((byte)chars);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buf.Add((byte)(0x80 | ((chars >> 8) & 0x7F)));
|
||||||
|
buf.Add((byte)(chars & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] utf16 = Encoding.Unicode.GetBytes(s);
|
||||||
|
buf.AddRange(utf16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static uint ReadU32(byte[] data, ref int pos)
|
||||||
|
{
|
||||||
|
uint v = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(pos, 4));
|
||||||
|
pos += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteU32(Span<byte> buf, ref int pos, uint value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buf.Slice(pos, 4), value);
|
||||||
|
pos += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendU32(List<byte> buf, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
|
||||||
|
buf.Add(tmp[0]);
|
||||||
|
buf.Add(tmp[1]);
|
||||||
|
buf.Add(tmp[2]);
|
||||||
|
buf.Add(tmp[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownBlobType(uint v) => v <= 6;
|
||||||
|
private static bool IsKnownDispatchType(uint v) => v <= 5;
|
||||||
|
}
|
||||||
|
|
@ -141,6 +141,24 @@ public sealed class WorldSession : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
|
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: fires when a <c>TurbineChat (0xF7DE)</c> top-level
|
||||||
|
/// GameMessage is received. Carries the unified
|
||||||
|
/// <see cref="TurbineChat.Parsed"/> envelope (header + payload
|
||||||
|
/// variant). Subscribers typically switch on the payload variant
|
||||||
|
/// and route <c>EventSendToRoom</c> into <c>ChatLog.OnChannelBroadcast</c>.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<TurbineChat.Parsed>? TurbineChatReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: fires when a <c>SetTurbineChatChannels (0x0295)</c>
|
||||||
|
/// GameEvent (sub-opcode of 0xF7B0) is received — listing the
|
||||||
|
/// runtime room ids assigned to General / Trade / LFG / Roleplay /
|
||||||
|
/// Society / Olthoi (and the optional Allegiance Turbine room).
|
||||||
|
/// Subscribers typically feed <c>TurbineChatState.OnChannelsReceived</c>.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<SetTurbineChatChannels.Parsed>? TurbineChannelsReceived;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
|
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
|
||||||
/// — full per-vital snapshot (ranks / start / xp / current).
|
/// — full per-vital snapshot (ranks / start / xp / current).
|
||||||
|
|
@ -315,6 +333,17 @@ public sealed class WorldSession : IDisposable
|
||||||
_loginEndpoint = serverLogin;
|
_loginEndpoint = serverLogin;
|
||||||
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
|
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
|
||||||
_net = new NetClient(serverLogin);
|
_net = new NetClient(serverLogin);
|
||||||
|
|
||||||
|
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
|
||||||
|
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
|
||||||
|
// the dispatcher and surface a typed event so downstream wiring
|
||||||
|
// (GameEventWiring → TurbineChatState) doesn't need to know the
|
||||||
|
// GameEvent envelope encoding.
|
||||||
|
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
|
||||||
|
{
|
||||||
|
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||||||
|
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -680,13 +709,17 @@ public sealed class WorldSession : IDisposable
|
||||||
if (parsed is not null)
|
if (parsed is not null)
|
||||||
PlayerKilledReceived?.Invoke(parsed.Value);
|
PlayerKilledReceived?.Invoke(parsed.Value);
|
||||||
}
|
}
|
||||||
else if (op == 0x0295u)
|
else if (op == TurbineChat.Opcode)
|
||||||
{
|
{
|
||||||
// Phase I.5 stub: 0x0295 SetTurbineChatChannels lands the
|
// Phase I.6: 0xF7DE TurbineChat — global community chat
|
||||||
// global chat channel list (Trade, LFG, etc). I.6 will
|
// (General / Trade / LFG / Roleplay / Society / Olthoi).
|
||||||
// land the parser here. For now we silently absorb the
|
// Three payload variants live inside the same opcode;
|
||||||
// packet so it doesn't show up in ACDREAM_DUMP_OPCODES.
|
// dispatch to subscribers by raising a typed event.
|
||||||
// TODO(I.6): parse + route to ChatLog.OnSetTurbineChatChannels.
|
// SetTurbineChatChannels (0x0295) is NOT here — it's a
|
||||||
|
// sub-opcode of the 0xF7B0 GameEvent envelope and rides
|
||||||
|
// the dispatcher path (registered in the ctor).
|
||||||
|
var parsed = TurbineChat.TryParse(body);
|
||||||
|
if (parsed is not null) TurbineChatReceived?.Invoke(parsed.Value);
|
||||||
}
|
}
|
||||||
else if (op == PrivateUpdateVital.FullOpcode)
|
else if (op == PrivateUpdateVital.FullOpcode)
|
||||||
{
|
{
|
||||||
|
|
@ -851,6 +884,60 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(body);
|
SendGameAction(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
|
||||||
|
/// global community room (General / Trade / LFG / Roleplay /
|
||||||
|
/// Society / Olthoi). Unlike <see cref="SendChannel"/> this is a
|
||||||
|
/// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it
|
||||||
|
/// rides <see cref="SendGameAction"/>'s capture seam (test-friendly)
|
||||||
|
/// but skips the GameAction sequence counter.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="cookie"/> must come from the parent's
|
||||||
|
/// <c>TurbineChatState.NextContextId()</c> — WorldSession does not
|
||||||
|
/// own that state because it lives at the GameWindow / chat-runtime
|
||||||
|
/// level. <paramref name="senderGuid"/> is the local player's guid
|
||||||
|
/// (the server uses it to attribute messages on the chat-server side).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public void SendTurbineChatTo(
|
||||||
|
uint roomId,
|
||||||
|
uint chatType,
|
||||||
|
uint dispatchType,
|
||||||
|
uint senderGuid,
|
||||||
|
string text,
|
||||||
|
uint cookie)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
|
||||||
|
// Holtburger always sets target_type=1, target_id=0, transport_type=0,
|
||||||
|
// transport_id=0 for outbound RequestSendToRoomById (commands.rs:288-291)
|
||||||
|
// — those fields are populated by ACE on inbound events but are
|
||||||
|
// semantically empty for client-issued requests.
|
||||||
|
_ = dispatchType; // outbound is always RequestSendToRoomById; see comment
|
||||||
|
|
||||||
|
var payload = new TurbineChat.Payload.RequestSendToRoomById(
|
||||||
|
ContextId: cookie,
|
||||||
|
RoomId: roomId,
|
||||||
|
Message: text,
|
||||||
|
ExtraDataSize: 0x0Cu, // ACE-side magic per holtburger commands.rs:297
|
||||||
|
SenderId: senderGuid,
|
||||||
|
HResult: 0,
|
||||||
|
ChatType: chatType);
|
||||||
|
|
||||||
|
byte[] body = TurbineChat.Build(
|
||||||
|
blobType: TurbineChat.BlobType.RequestBinary,
|
||||||
|
dispatchType: TurbineChat.DispatchType.SendToRoomById,
|
||||||
|
targetType: 1u,
|
||||||
|
targetId: 0u,
|
||||||
|
transportType: 0u,
|
||||||
|
transportId: 0u,
|
||||||
|
cookie: 0u, // outer header cookie is 0; inner context_id is the user-visible cookie
|
||||||
|
payload: payload);
|
||||||
|
|
||||||
|
SendGameAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
private void SendGameMessage(byte[] gameMessageBody)
|
private void SendGameMessage(byte[] gameMessageBody)
|
||||||
{
|
{
|
||||||
var fragment = GameMessageFragment.BuildSingleFragment(
|
var fragment = GameMessageFragment.BuildSingleFragment(
|
||||||
|
|
|
||||||
94
src/AcDream.Core/Chat/ChatChannelInfo.cs
Normal file
94
src/AcDream.Core/Chat/ChatChannelInfo.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
namespace AcDream.Core.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source/transport classification for a chat channel — distinguishes
|
||||||
|
/// retail's two parallel chat channel pipelines.
|
||||||
|
/// </summary>
|
||||||
|
public enum ChatChannelSource
|
||||||
|
{
|
||||||
|
/// <summary>Legacy <c>ChatChannel</c> bitflag id rides 0x0147 ChatChannel.</summary>
|
||||||
|
Legacy,
|
||||||
|
|
||||||
|
/// <summary>Turbine room id rides 0xF7DE TurbineChat.</summary>
|
||||||
|
Turbine,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unified info about a chat channel — either a legacy ChatChannel id
|
||||||
|
/// (Fellowship, Allegiance, Vassals, Patron, Monarch, CoVassals) or a
|
||||||
|
/// Turbine room id (General, Trade, LFG, Roleplay, Society*, Olthoi).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Mirrors holtburger's <c>ChatChannelInfo</c>
|
||||||
|
/// (<c>references/holtburger/crates/holtburger-core/src/client/types.rs</c>
|
||||||
|
/// lines 63-102). The two retail channel pipelines run side by side —
|
||||||
|
/// legacy <c>ChatChannel</c> for the player-organisation channels and
|
||||||
|
/// <c>TurbineChat</c> for the global community rooms — and a single
|
||||||
|
/// abstraction over both keeps the chat panel and command bus from
|
||||||
|
/// having to special-case the transport at every call site.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="IsSelfEchoChannel"/> tells callers whether the server
|
||||||
|
/// echoes the client's own outgoing messages back on this channel
|
||||||
|
/// (so the client should suppress its optimistic local echo). Per
|
||||||
|
/// holtburger's predicate at <c>chat.rs::is_self_echo_channel</c>
|
||||||
|
/// (lines 492-507) this is true ONLY for the legacy fellowship/vassals/
|
||||||
|
/// patron/monarch/co-vassals channels — server resends those with
|
||||||
|
/// empty sender. Turbine and tells do not echo.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Source)
|
||||||
|
{
|
||||||
|
/// <summary>Legacy <c>ChatChannel</c> bitflag id (0x00000800 etc.).</summary>
|
||||||
|
public sealed record Legacy(uint ChannelId, string DisplayName)
|
||||||
|
: ChatChannelInfo(DisplayName, ChatChannelSource.Legacy)
|
||||||
|
{
|
||||||
|
public override bool IsSelfEchoChannel()
|
||||||
|
{
|
||||||
|
// Per holtburger: the legacy fellowship + allegiance-tree
|
||||||
|
// channels are the ones the server echoes back to the sender
|
||||||
|
// with an empty sender field. Bitflag values from
|
||||||
|
// references/holtburger/.../messages/chat/types.rs::ChatChannel.
|
||||||
|
return ChannelId switch
|
||||||
|
{
|
||||||
|
0x00000800u => true, // Fellow
|
||||||
|
0x00001000u => true, // Vassals
|
||||||
|
0x00002000u => true, // Patron
|
||||||
|
0x00004000u => true, // Monarch
|
||||||
|
0x01000000u => true, // CoVassals
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TurbineChat room. <see cref="RoomId"/> is the runtime channel id
|
||||||
|
/// the server hands out via <c>SetTurbineChatChannels</c> (0x0295).
|
||||||
|
/// <see cref="ChatType"/> classifies the room semantically (General,
|
||||||
|
/// Trade, etc.); <see cref="DispatchType"/> chooses the wire dispatch
|
||||||
|
/// (SendToRoomById for outbound, SendToRoomByName for inbound events).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Turbine(
|
||||||
|
uint RoomId,
|
||||||
|
uint ChatType,
|
||||||
|
uint DispatchType,
|
||||||
|
string DisplayName)
|
||||||
|
: ChatChannelInfo(DisplayName, ChatChannelSource.Turbine)
|
||||||
|
{
|
||||||
|
public override bool IsSelfEchoChannel()
|
||||||
|
{
|
||||||
|
// Turbine rooms do NOT echo the sender's own messages back.
|
||||||
|
// The client must emit its own optimistic local echo to give
|
||||||
|
// the player feedback that the message was sent.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True iff the server echoes our own outgoing messages on this
|
||||||
|
/// channel — caller should suppress optimistic local echo to avoid
|
||||||
|
/// double-printing.
|
||||||
|
/// </summary>
|
||||||
|
public abstract bool IsSelfEchoChannel();
|
||||||
|
}
|
||||||
136
src/AcDream.Core/Chat/TurbineChatState.cs
Normal file
136
src/AcDream.Core/Chat/TurbineChatState.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
namespace AcDream.Core.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime state for retail's TurbineChat (0xF7DE) global chat rooms —
|
||||||
|
/// the General / Trade / LFG / Roleplay / Society / Olthoi pipeline.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Lifecycle:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Pre-login: <see cref="Enabled"/> false, all room ids 0, context counter 1.</item>
|
||||||
|
/// <item>Server fires <c>SetTurbineChatChannels (0x0295)</c> shortly after EnterWorld
|
||||||
|
/// → <see cref="OnChannelsReceived"/> populates the room ids and flips
|
||||||
|
/// <see cref="Enabled"/> on.</item>
|
||||||
|
/// <item>Outbound chat: caller asks for a fresh context id via
|
||||||
|
/// <see cref="NextContextId"/>, looks up the room id by
|
||||||
|
/// <see cref="ChatType"/>, and feeds the pair to
|
||||||
|
/// <see cref="WorldSession.SendTurbineChatTo"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Mirrors holtburger's <c>TurbineChatState</c>
|
||||||
|
/// (<c>references/holtburger/crates/holtburger-core/src/client/types.rs</c>
|
||||||
|
/// lines 657-672). Cookie counter starts at 1 (not 0) per holtburger:
|
||||||
|
/// <c>next_context_id.wrapping_add(1).max(1)</c> keeps 0 reserved for
|
||||||
|
/// "no context" / response cookies.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TurbineChatState
|
||||||
|
{
|
||||||
|
/// <summary>True after the first <c>SetTurbineChatChannels</c> arrives.</summary>
|
||||||
|
public bool Enabled { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Allegiance Turbine room (0 if player has no allegiance).</summary>
|
||||||
|
public uint AllegianceRoom { get; private set; }
|
||||||
|
public uint GeneralRoom { get; private set; }
|
||||||
|
public uint TradeRoom { get; private set; }
|
||||||
|
public uint LfgRoom { get; private set; }
|
||||||
|
public uint RoleplayRoom { get; private set; }
|
||||||
|
public uint OlthoiRoom { get; private set; }
|
||||||
|
/// <summary>Top-level Society room (0 if player has no society).</summary>
|
||||||
|
public uint SocietyRoom { get; private set; }
|
||||||
|
public uint SocietyCelestialHandRoom { get; private set; }
|
||||||
|
public uint SocietyEldrytchWebRoom { get; private set; }
|
||||||
|
public uint SocietyRadiantBloodRoom { get; private set; }
|
||||||
|
|
||||||
|
private uint _nextContextId = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get-and-increment the per-session context cookie. Wraps to 1 (not
|
||||||
|
/// 0) so 0 stays reserved for "no context" / Response cookies.
|
||||||
|
/// Mirrors holtburger's <c>wrapping_add(1).max(1)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public uint NextContextId()
|
||||||
|
{
|
||||||
|
uint cookie = _nextContextId;
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
_nextContextId += 1;
|
||||||
|
if (_nextContextId == 0) _nextContextId = 1;
|
||||||
|
}
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absorb a parsed <c>SetTurbineChatChannels</c> payload — flips
|
||||||
|
/// <see cref="Enabled"/> on and fills in the per-channel room ids.
|
||||||
|
/// Takes raw u32s (rather than the parser's struct directly) so
|
||||||
|
/// AcDream.Core stays free of an AcDream.Core.Net dependency.
|
||||||
|
/// </summary>
|
||||||
|
public void OnChannelsReceived(
|
||||||
|
uint allegianceRoom,
|
||||||
|
uint generalRoom,
|
||||||
|
uint tradeRoom,
|
||||||
|
uint lfgRoom,
|
||||||
|
uint roleplayRoom,
|
||||||
|
uint olthoiRoom,
|
||||||
|
uint societyRoom,
|
||||||
|
uint societyCelestialHandRoom,
|
||||||
|
uint societyEldrytchWebRoom,
|
||||||
|
uint societyRadiantBloodRoom)
|
||||||
|
{
|
||||||
|
Enabled = true;
|
||||||
|
AllegianceRoom = allegianceRoom;
|
||||||
|
GeneralRoom = generalRoom;
|
||||||
|
TradeRoom = tradeRoom;
|
||||||
|
LfgRoom = lfgRoom;
|
||||||
|
RoleplayRoom = roleplayRoom;
|
||||||
|
OlthoiRoom = olthoiRoom;
|
||||||
|
SocietyRoom = societyRoom;
|
||||||
|
SocietyCelestialHandRoom = societyCelestialHandRoom;
|
||||||
|
SocietyEldrytchWebRoom = societyEldrytchWebRoom;
|
||||||
|
SocietyRadiantBloodRoom = societyRadiantBloodRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Look up the runtime room id for a Turbine
|
||||||
|
/// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not
|
||||||
|
/// available (server hasn't populated it / player not in society / etc).
|
||||||
|
/// </summary>
|
||||||
|
public uint RoomFor(ChatChannelKindLite kind) => kind switch
|
||||||
|
{
|
||||||
|
ChatChannelKindLite.Allegiance => AllegianceRoom,
|
||||||
|
ChatChannelKindLite.General => GeneralRoom,
|
||||||
|
ChatChannelKindLite.Trade => TradeRoom,
|
||||||
|
ChatChannelKindLite.Lfg => LfgRoom,
|
||||||
|
ChatChannelKindLite.Roleplay => RoleplayRoom,
|
||||||
|
ChatChannelKindLite.Olthoi => OlthoiRoom,
|
||||||
|
ChatChannelKindLite.Society => SocietyRoom,
|
||||||
|
ChatChannelKindLite.SocietyCelestialHand => SocietyCelestialHandRoom,
|
||||||
|
ChatChannelKindLite.SocietyEldrytchWeb => SocietyEldrytchWebRoom,
|
||||||
|
ChatChannelKindLite.SocietyRadiantBlood => SocietyRadiantBloodRoom,
|
||||||
|
_ => 0u,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coarse Turbine channel selector usable from
|
||||||
|
/// <see cref="TurbineChatState.RoomFor"/> without dragging in the
|
||||||
|
/// AcDream.UI.Abstractions <c>ChatChannelKind</c> (which lives in a
|
||||||
|
/// different layer). Maps 1:1 onto the chat-type-id values from
|
||||||
|
/// holtburger turbine.rs (TurbineChatType, lines 31-45).
|
||||||
|
/// </summary>
|
||||||
|
public enum ChatChannelKindLite
|
||||||
|
{
|
||||||
|
Allegiance,
|
||||||
|
General,
|
||||||
|
Trade,
|
||||||
|
Lfg,
|
||||||
|
Roleplay,
|
||||||
|
Society,
|
||||||
|
SocietyCelestialHand,
|
||||||
|
SocietyEldrytchWeb,
|
||||||
|
SocietyRadiantBlood,
|
||||||
|
Olthoi,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
278
tests/AcDream.Core.Net.Tests/Messages/TurbineChatTests.cs
Normal file
278
tests/AcDream.Core.Net.Tests/Messages/TurbineChatTests.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: <see cref="TurbineChat"/> codec round-trip tests for all
|
||||||
|
/// three payload variants and the UTF-16LE Turbine string codec.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Golden fixtures from holtburger
|
||||||
|
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
|
||||||
|
/// lines 555-639 — generated by ACE's
|
||||||
|
/// <c>SyntheticProtocolTests.GenerateTurbineChatFixtures</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TurbineChatTests
|
||||||
|
{
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Round-trip fixtures (parse, then re-serialise, then parse again)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_EventSendToRoom_GoldenFixture()
|
||||||
|
{
|
||||||
|
byte[] fixture = HexDecode(
|
||||||
|
"DEF700005E000000010000000100000001000000" +
|
||||||
|
"B5000B0001000000B5000B00000000003E000000" +
|
||||||
|
"020000000541006C006900630065000B68006500" +
|
||||||
|
"6C006C006F00200077006F0072006C0064000C00" +
|
||||||
|
"0000010000500000000002000000");
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(fixture);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(TurbineChat.BlobType.EventBinary, parsed!.Value.BlobType);
|
||||||
|
Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType);
|
||||||
|
Assert.Equal(1u, parsed.Value.TargetType);
|
||||||
|
Assert.Equal(0x000B00B5u, parsed.Value.TargetId);
|
||||||
|
Assert.Equal(1u, parsed.Value.TransportType);
|
||||||
|
Assert.Equal(0x000B00B5u, parsed.Value.TransportId);
|
||||||
|
Assert.Equal(0u, parsed.Value.Cookie);
|
||||||
|
|
||||||
|
var ev = Assert.IsType<TurbineChat.Payload.EventSendToRoom>(parsed.Value.Body);
|
||||||
|
Assert.Equal(2u, ev.RoomId); // TurbineChatChannel.General
|
||||||
|
Assert.Equal("Alice", ev.SenderName);
|
||||||
|
Assert.Equal("hello world", ev.Message);
|
||||||
|
Assert.Equal(0x0Cu, ev.ExtraDataSize);
|
||||||
|
Assert.Equal(0x50000001u, ev.SenderId);
|
||||||
|
Assert.Equal(0, ev.HResult);
|
||||||
|
Assert.Equal(2u, ev.ChatType); // TurbineChatType.General
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_EventSendToRoom_RoundTripsThroughTryParse()
|
||||||
|
{
|
||||||
|
byte[] built = TurbineChat.Build(
|
||||||
|
blobType: TurbineChat.BlobType.EventBinary,
|
||||||
|
dispatchType: TurbineChat.DispatchType.SendToRoomByName,
|
||||||
|
targetType: 1u,
|
||||||
|
targetId: 0x000B00B5u,
|
||||||
|
transportType: 1u,
|
||||||
|
transportId: 0x000B00B5u,
|
||||||
|
cookie: 0u,
|
||||||
|
payload: new TurbineChat.Payload.EventSendToRoom(
|
||||||
|
RoomId: 2u,
|
||||||
|
SenderName: "Alice",
|
||||||
|
Message: "hello world",
|
||||||
|
ExtraDataSize: 0x0Cu,
|
||||||
|
SenderId: 0x50000001u,
|
||||||
|
HResult: 0,
|
||||||
|
ChatType: 2u));
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(built);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
var ev = Assert.IsType<TurbineChat.Payload.EventSendToRoom>(parsed!.Value.Body);
|
||||||
|
Assert.Equal("Alice", ev.SenderName);
|
||||||
|
Assert.Equal("hello world", ev.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_Response_GoldenFixture()
|
||||||
|
{
|
||||||
|
byte[] fixture = HexDecode(
|
||||||
|
"DEF7000038000000050000000100000001000000" +
|
||||||
|
"B5000B0001000000B5000B00000000001800000007000000" +
|
||||||
|
"020000000200000000000000");
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(fixture);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(TurbineChat.BlobType.ResponseBinary, parsed!.Value.BlobType);
|
||||||
|
Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType);
|
||||||
|
var resp = Assert.IsType<TurbineChat.Payload.Response>(parsed.Value.Body);
|
||||||
|
Assert.Equal(7u, resp.ContextId);
|
||||||
|
Assert.Equal(2u, resp.ResponseId);
|
||||||
|
Assert.Equal(2u, resp.MethodId);
|
||||||
|
Assert.Equal(0, resp.HResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Response_RoundTripsThroughTryParse()
|
||||||
|
{
|
||||||
|
byte[] built = TurbineChat.Build(
|
||||||
|
blobType: TurbineChat.BlobType.ResponseBinary,
|
||||||
|
dispatchType: TurbineChat.DispatchType.SendToRoomByName,
|
||||||
|
targetType: 1u, targetId: 0x000B00B5u,
|
||||||
|
transportType: 1u, transportId: 0x000B00B5u,
|
||||||
|
cookie: 0u,
|
||||||
|
payload: new TurbineChat.Payload.Response(
|
||||||
|
ContextId: 7u, ResponseId: 2u, MethodId: 2u, HResult: 0));
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(built);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
var resp = Assert.IsType<TurbineChat.Payload.Response>(parsed!.Value.Body);
|
||||||
|
Assert.Equal(7u, resp.ContextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_RequestSendToRoomById_GoldenFixture()
|
||||||
|
{
|
||||||
|
byte[] fixture = HexDecode(
|
||||||
|
"DEF700005D000000030000000200000001000000" +
|
||||||
|
"00000000000000000000000000000000" +
|
||||||
|
"3D000000" +
|
||||||
|
"07000000020000000200000002000000" +
|
||||||
|
"0A7400720061006400650020007300700061006D00" +
|
||||||
|
"0C000000010000500000000003000000");
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(fixture);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(TurbineChat.BlobType.RequestBinary, parsed!.Value.BlobType);
|
||||||
|
Assert.Equal(TurbineChat.DispatchType.SendToRoomById, parsed.Value.DispatchType);
|
||||||
|
var req = Assert.IsType<TurbineChat.Payload.RequestSendToRoomById>(parsed.Value.Body);
|
||||||
|
Assert.Equal(7u, req.ContextId);
|
||||||
|
Assert.Equal(2u, req.RoomId); // TurbineChatChannel.General
|
||||||
|
Assert.Equal("trade spam", req.Message);
|
||||||
|
Assert.Equal(0x0Cu, req.ExtraDataSize);
|
||||||
|
Assert.Equal(0x50000001u, req.SenderId);
|
||||||
|
Assert.Equal(0, req.HResult);
|
||||||
|
Assert.Equal(3u, req.ChatType); // TurbineChatType.Trade
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_RequestSendToRoomById_RoundTripsThroughTryParse()
|
||||||
|
{
|
||||||
|
byte[] built = TurbineChat.Build(
|
||||||
|
blobType: TurbineChat.BlobType.RequestBinary,
|
||||||
|
dispatchType: TurbineChat.DispatchType.SendToRoomById,
|
||||||
|
targetType: 1u, targetId: 0u,
|
||||||
|
transportType: 0u, transportId: 0u,
|
||||||
|
cookie: 0u,
|
||||||
|
payload: new TurbineChat.Payload.RequestSendToRoomById(
|
||||||
|
ContextId: 7u,
|
||||||
|
RoomId: 2u,
|
||||||
|
Message: "trade spam",
|
||||||
|
ExtraDataSize: 0x0Cu,
|
||||||
|
SenderId: 0x50000001u,
|
||||||
|
HResult: 0,
|
||||||
|
ChatType: 3u));
|
||||||
|
|
||||||
|
var parsed = TurbineChat.TryParse(built);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
var req = Assert.IsType<TurbineChat.Payload.RequestSendToRoomById>(parsed!.Value.Body);
|
||||||
|
Assert.Equal("trade spam", req.Message);
|
||||||
|
Assert.Equal(2u, req.RoomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_RequestRejectsNonAceRequestIds()
|
||||||
|
{
|
||||||
|
// Per turbine.rs:642-651: ACE's RequestSendToRoomById is the only
|
||||||
|
// (response_id=2, method_id=2) pair. Other ids are rejected.
|
||||||
|
byte[] fixture = HexDecode(
|
||||||
|
"DEF700005D000000030000000200000001000000" +
|
||||||
|
"00000000000000000000000000000000" +
|
||||||
|
"3D000000" +
|
||||||
|
"07000000020000000200000002000000" +
|
||||||
|
"0A7400720061006400650020007300700061006D00" +
|
||||||
|
"0C000000010000500000000003000000");
|
||||||
|
|
||||||
|
// Mutate response_id (offset 4 + 36 + 4 = 44) to 3 — must reject.
|
||||||
|
BitConverter.GetBytes(3u).CopyTo(fixture, 44);
|
||||||
|
Assert.Null(TurbineChat.TryParse(fixture));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// UTF-16LE Turbine string codec
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TurbineString_ShortPrefix_RoundTrips()
|
||||||
|
{
|
||||||
|
var buf = new List<byte>();
|
||||||
|
TurbineChat.WriteTurbineString(buf, "Hello");
|
||||||
|
// 1-byte prefix, then 5 UTF-16LE code units = 5 + 1 = 11 bytes.
|
||||||
|
Assert.Equal(11, buf.Count);
|
||||||
|
Assert.Equal(5, buf[0]); // length prefix
|
||||||
|
Assert.Equal(0, 0x80 & buf[0]); // high bit clear
|
||||||
|
|
||||||
|
byte[] data = buf.ToArray();
|
||||||
|
int pos = 0;
|
||||||
|
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
|
||||||
|
Assert.Equal("Hello", roundTrip);
|
||||||
|
Assert.Equal(data.Length, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TurbineString_LongPrefix_RoundTrips()
|
||||||
|
{
|
||||||
|
// 200 chars > 0x80 → 2-byte prefix form.
|
||||||
|
string s = new string('a', 200);
|
||||||
|
var buf = new List<byte>();
|
||||||
|
TurbineChat.WriteTurbineString(buf, s);
|
||||||
|
|
||||||
|
// Prefix: 2 bytes; high bit of first byte set.
|
||||||
|
Assert.NotEqual(0, buf[0] & 0x80);
|
||||||
|
// Decoded length = ((b0 & 0x7F) << 8) | b1
|
||||||
|
int decodedLen = ((buf[0] & 0x7F) << 8) | buf[1];
|
||||||
|
Assert.Equal(200, decodedLen);
|
||||||
|
Assert.Equal(2 + 200 * 2, buf.Count);
|
||||||
|
|
||||||
|
byte[] data = buf.ToArray();
|
||||||
|
int pos = 0;
|
||||||
|
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
|
||||||
|
Assert.Equal(s, roundTrip);
|
||||||
|
Assert.Equal(data.Length, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TurbineString_NonAscii_RoundTripsAsUtf16LE()
|
||||||
|
{
|
||||||
|
// 'Café' has 4 UTF-16 code units, last with 0x00E9 — verifies
|
||||||
|
// we're using UTF-16LE rather than CP1252 like the rest of the
|
||||||
|
// chat layer.
|
||||||
|
const string s = "Café";
|
||||||
|
var buf = new List<byte>();
|
||||||
|
TurbineChat.WriteTurbineString(buf, s);
|
||||||
|
// Bytes: prefix(1) + UTF-16LE C-a-f-é = 1 + 8 = 9 bytes.
|
||||||
|
Assert.Equal(9, buf.Count);
|
||||||
|
Assert.Equal(4, buf[0]);
|
||||||
|
// 'é' = 0xE9 0x00 in UTF-16LE.
|
||||||
|
Assert.Equal(0xE9, buf[1 + 6]);
|
||||||
|
Assert.Equal(0x00, buf[1 + 7]);
|
||||||
|
|
||||||
|
byte[] data = buf.ToArray();
|
||||||
|
int pos = 0;
|
||||||
|
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
|
||||||
|
Assert.Equal(s, roundTrip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TurbineString_EmptyString_RoundTrips()
|
||||||
|
{
|
||||||
|
var buf = new List<byte>();
|
||||||
|
TurbineChat.WriteTurbineString(buf, "");
|
||||||
|
Assert.Single(buf);
|
||||||
|
Assert.Equal(0, buf[0]);
|
||||||
|
|
||||||
|
byte[] data = buf.ToArray();
|
||||||
|
int pos = 0;
|
||||||
|
Assert.Equal("", TurbineChat.ReadTurbineString(data, ref pos));
|
||||||
|
Assert.Equal(1, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs
Normal file
61
tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: <see cref="ChatChannelInfo"/> + <see cref="ChatChannelSource"/>
|
||||||
|
/// behaviour. Mirrors holtburger's <c>is_self_echo_channel</c> predicate
|
||||||
|
/// (chat.rs:492-507) — only the legacy fellowship + allegiance-tree
|
||||||
|
/// channels echo the sender's own messages back with empty sender.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatChannelInfoTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_FellowshipBitflag_IsSelfEcho()
|
||||||
|
{
|
||||||
|
var c = new ChatChannelInfo.Legacy(0x00000800u, "Fellowship");
|
||||||
|
Assert.Equal(ChatChannelSource.Legacy, c.Source);
|
||||||
|
Assert.Equal("Fellowship", c.DisplayName);
|
||||||
|
Assert.True(c.IsSelfEchoChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x00001000u)] // Vassals
|
||||||
|
[InlineData(0x00002000u)] // Patron
|
||||||
|
[InlineData(0x00004000u)] // Monarch
|
||||||
|
[InlineData(0x01000000u)] // CoVassals
|
||||||
|
public void Legacy_AllegianceTreeBitflags_AreSelfEcho(uint id)
|
||||||
|
{
|
||||||
|
var c = new ChatChannelInfo.Legacy(id, "Allegiance");
|
||||||
|
Assert.True(c.IsSelfEchoChannel(), $"channel 0x{id:X8} should self-echo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_AllegianceBroadcastBitflag_IsNOTSelfEcho()
|
||||||
|
{
|
||||||
|
// 0x02000000 AllegianceBroadcast is the read-only motd channel —
|
||||||
|
// server does not echo client messages back on it (per holtburger
|
||||||
|
// chat.rs:492-507 predicate, only Fellow+Vassals+Patron+Monarch+CoVassals).
|
||||||
|
var c = new ChatChannelInfo.Legacy(0x02000000u, "AllegianceBroadcast");
|
||||||
|
Assert.False(c.IsSelfEchoChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Turbine_IsNeverSelfEcho()
|
||||||
|
{
|
||||||
|
// Turbine rooms don't echo — caller must emit local optimistic echo.
|
||||||
|
var t = new ChatChannelInfo.Turbine(
|
||||||
|
RoomId: 0x12345678u, ChatType: 2u, DispatchType: 2u, DisplayName: "General");
|
||||||
|
Assert.Equal(ChatChannelSource.Turbine, t.Source);
|
||||||
|
Assert.Equal("General", t.DisplayName);
|
||||||
|
Assert.False(t.IsSelfEchoChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_UnknownBitflag_IsNotSelfEcho()
|
||||||
|
{
|
||||||
|
var c = new ChatChannelInfo.Legacy(0xCAFEF00Du, "WatCha");
|
||||||
|
Assert.False(c.IsSelfEchoChannel());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs
Normal file
84
tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.6: <see cref="TurbineChatState"/> behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TurbineChatStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1()
|
||||||
|
{
|
||||||
|
var s = new TurbineChatState();
|
||||||
|
Assert.False(s.Enabled);
|
||||||
|
Assert.Equal(0u, s.GeneralRoom);
|
||||||
|
Assert.Equal(0u, s.TradeRoom);
|
||||||
|
Assert.Equal(0u, s.AllegianceRoom);
|
||||||
|
Assert.Equal(1u, s.NextContextId());
|
||||||
|
// After consuming one cookie, the next is 2
|
||||||
|
Assert.Equal(2u, s.NextContextId());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnChannelsReceived_PopulatesAllFieldsAndEnables()
|
||||||
|
{
|
||||||
|
var s = new TurbineChatState();
|
||||||
|
s.OnChannelsReceived(
|
||||||
|
allegianceRoom: 0xA0,
|
||||||
|
generalRoom: 0xA1,
|
||||||
|
tradeRoom: 0xA2,
|
||||||
|
lfgRoom: 0xA3,
|
||||||
|
roleplayRoom: 0xA4,
|
||||||
|
olthoiRoom: 0xA5,
|
||||||
|
societyRoom: 0xA6,
|
||||||
|
societyCelestialHandRoom: 0xA7,
|
||||||
|
societyEldrytchWebRoom: 0xA8,
|
||||||
|
societyRadiantBloodRoom: 0xA9);
|
||||||
|
|
||||||
|
Assert.True(s.Enabled);
|
||||||
|
Assert.Equal(0xA0u, s.AllegianceRoom);
|
||||||
|
Assert.Equal(0xA1u, s.GeneralRoom);
|
||||||
|
Assert.Equal(0xA2u, s.TradeRoom);
|
||||||
|
Assert.Equal(0xA3u, s.LfgRoom);
|
||||||
|
Assert.Equal(0xA4u, s.RoleplayRoom);
|
||||||
|
Assert.Equal(0xA5u, s.OlthoiRoom);
|
||||||
|
Assert.Equal(0xA6u, s.SocietyRoom);
|
||||||
|
Assert.Equal(0xA7u, s.SocietyCelestialHandRoom);
|
||||||
|
Assert.Equal(0xA8u, s.SocietyEldrytchWebRoom);
|
||||||
|
Assert.Equal(0xA9u, s.SocietyRadiantBloodRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NextContextId_IsMonotonicAndStartsAt1()
|
||||||
|
{
|
||||||
|
var s = new TurbineChatState();
|
||||||
|
Assert.Equal(1u, s.NextContextId());
|
||||||
|
Assert.Equal(2u, s.NextContextId());
|
||||||
|
Assert.Equal(3u, s.NextContextId());
|
||||||
|
Assert.Equal(4u, s.NextContextId());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoomFor_ReturnsConfiguredRoom()
|
||||||
|
{
|
||||||
|
var s = new TurbineChatState();
|
||||||
|
s.OnChannelsReceived(
|
||||||
|
allegianceRoom: 1, generalRoom: 2, tradeRoom: 3,
|
||||||
|
lfgRoom: 4, roleplayRoom: 5, olthoiRoom: 6,
|
||||||
|
societyRoom: 7, societyCelestialHandRoom: 8,
|
||||||
|
societyEldrytchWebRoom: 9, societyRadiantBloodRoom: 10);
|
||||||
|
|
||||||
|
Assert.Equal(1u, s.RoomFor(ChatChannelKindLite.Allegiance));
|
||||||
|
Assert.Equal(2u, s.RoomFor(ChatChannelKindLite.General));
|
||||||
|
Assert.Equal(3u, s.RoomFor(ChatChannelKindLite.Trade));
|
||||||
|
Assert.Equal(4u, s.RoomFor(ChatChannelKindLite.Lfg));
|
||||||
|
Assert.Equal(5u, s.RoomFor(ChatChannelKindLite.Roleplay));
|
||||||
|
Assert.Equal(6u, s.RoomFor(ChatChannelKindLite.Olthoi));
|
||||||
|
Assert.Equal(7u, s.RoomFor(ChatChannelKindLite.Society));
|
||||||
|
Assert.Equal(8u, s.RoomFor(ChatChannelKindLite.SocietyCelestialHand));
|
||||||
|
Assert.Equal(9u, s.RoomFor(ChatChannelKindLite.SocietyEldrytchWeb));
|
||||||
|
Assert.Equal(10u, s.RoomFor(ChatChannelKindLite.SocietyRadiantBlood));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue