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.
|
||||
// Exposed publicly so plugins + UI panels can bind directly.
|
||||
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();
|
||||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||
// 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
|
||||
// the corresponding client-side state without further glue.
|
||||
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.
|
||||
_liveSession.SpeechHeard += speech =>
|
||||
|
|
@ -1227,6 +1232,24 @@ public sealed class GameWindow : IDisposable
|
|||
senderGuid: speech.SenderGuid,
|
||||
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
|
||||
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
||||
// 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 chat = Chat;
|
||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||||
var turbineChat = TurbineChat;
|
||||
uint playerGuid = _playerServerGuid;
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||||
|
|
@ -1252,6 +1277,35 @@ public sealed class GameWindow : IDisposable
|
|||
targetOrChannel: cmd.TargetName);
|
||||
break;
|
||||
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);
|
||||
if (resolved is null) return;
|
||||
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
||||
|
|
@ -4896,4 +4950,74 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
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}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue