The two-client user gate PASSED 2026-08-14 (open both ways, stage with the retail trading marker, accept/decline, executed swap, Clear All, cancel text). Every TEMPORARY [trade] probe line from gate rounds 1-2 is stripped (ItemInteractionController, SelectionInteractionController, SecureTradeUiController, LiveSessionCommandRouter, WorldSession, RuntimeTradeState). Roadmap gains the shipped-trade ledger row. Suites after strip: App 4,992/3, Runtime 1,626 - green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
635 lines
30 KiB
C#
635 lines
30 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Net;
|
|
|
|
internal sealed record LiveSessionCommandBindings(
|
|
ClientCommandController.Bindings ClientCommands,
|
|
ChatLog Chat,
|
|
TurbineChatState TurbineChat,
|
|
Func<uint> PlayerGuid,
|
|
Action<string> SendTalk,
|
|
Action<string, string> SendTell,
|
|
Action<uint, string> SendChannel,
|
|
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
|
|
Action<ShortcutEntry> AddShortcut,
|
|
Action<uint> RemoveShortcut,
|
|
Action<uint, int, int> AddFavorite,
|
|
Action<uint, int> RemoveFavorite,
|
|
Action<uint> SetSpellbookFilter,
|
|
Action<uint> ForgetSpell,
|
|
Action<uint, uint> SetDesiredComponent,
|
|
Action ClearDesiredComponents,
|
|
Action<uint, ulong> RaiseAttribute,
|
|
Action<uint, ulong> RaiseVital,
|
|
Action<uint, ulong> RaiseSkill,
|
|
Action<uint, uint> TrainSkill,
|
|
Action<string> AddFriend,
|
|
Action<uint> RemoveFriend,
|
|
Action ClearFriends,
|
|
Action RequestLegacyFriends,
|
|
// Secure trade (2026-08-14) — the CM_Trade senders.
|
|
Action<uint> OpenTradeNegotiations,
|
|
Action CloseTradeNegotiations,
|
|
Action<uint> AddToTrade,
|
|
Action<uint, bool, bool> AcceptTrade,
|
|
Action DeclineTrade,
|
|
Action ResetTrade,
|
|
Action<bool, uint, string, uint> ModifyCharacterSquelch,
|
|
Action<bool, string> ModifyAccountSquelch,
|
|
Action<bool, uint> ModifyGlobalSquelch,
|
|
// Campaign CH slice CH3 (2026-08-09): the RuntimeCommunicationState.
|
|
// AddText chokepoint (local refusals — Turbine unavailable / not
|
|
// listening) and the RuntimeCharacterState owner (Hear*Chat options +
|
|
// IsOlthoiPlayer) the Turbine membership gate reads, plus the
|
|
// SetSingleCharacterOption (0x0005) sender that replaced the malformed,
|
|
// callerless full-blob SetCharacterOptions (0x01A1) path.
|
|
RuntimeCommunicationState Communication,
|
|
RuntimeCharacterState CharacterState,
|
|
Action<uint, bool> SendSingleCharacterOption,
|
|
// Campaign OP slice OP1 (2026-08-10): the real SetCharacterOptions
|
|
// (0x01A1) blob-flush verb — CH3's TODO, now resurrected per wire
|
|
// research §2.3-§2.7. No-ops when the batched module is clean, matching
|
|
// retail's CPlayerModule::SaveToServer(force: 0).
|
|
Action SaveCharacterOptions,
|
|
// Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send
|
|
// wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's
|
|
// direct session.SendXxx calls.
|
|
Action<string, bool> SendFellowshipCreate,
|
|
Action<uint> SendFellowshipRecruit,
|
|
Action<uint> SendFellowshipDismiss,
|
|
Action<bool> SendFellowshipQuit,
|
|
Action<uint> SendFellowshipAssignNewLeader,
|
|
Action<bool> SendFellowshipChangeOpenness,
|
|
Action<bool> SendFellowshipUpdateRequest,
|
|
Action<uint> SendAllegianceSwear,
|
|
Action<uint> SendAllegianceBreak,
|
|
Action<uint> SendAllegianceKick,
|
|
Action<string> SendAllegianceInfoRequest,
|
|
Action<bool> SendAllegianceUpdateRequest,
|
|
Action<string>? Log = null);
|
|
|
|
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
|
|
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
|
|
internal readonly record struct AddFavoriteRuntimeCmd(
|
|
uint SpellId,
|
|
int Position,
|
|
int TabIndex);
|
|
internal readonly record struct RemoveFavoriteRuntimeCmd(
|
|
uint SpellId,
|
|
int TabIndex);
|
|
internal readonly record struct SetSpellbookFilterRuntimeCmd(uint Filters);
|
|
internal readonly record struct ForgetSpellRuntimeCmd(uint SpellId);
|
|
internal readonly record struct SetDesiredComponentRuntimeCmd(
|
|
uint ComponentId,
|
|
uint Amount);
|
|
internal readonly record struct ClearDesiredComponentsRuntimeCmd;
|
|
internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost);
|
|
internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost);
|
|
internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost);
|
|
internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost);
|
|
internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
|
|
uint OptionId,
|
|
bool Value);
|
|
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
|
|
internal readonly record struct AddFriendRuntimeCmd(string Name);
|
|
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
|
|
|
|
// ── Secure trade (2026-08-14) ──────────────────────────────────────────────
|
|
internal readonly record struct OpenTradeNegotiationsRuntimeCmd(uint PartnerGuid);
|
|
internal readonly record struct CloseTradeNegotiationsRuntimeCmd;
|
|
internal readonly record struct AddToTradeRuntimeCmd(uint ItemGuid);
|
|
internal readonly record struct AcceptTradeRuntimeCmd(
|
|
uint PartnerGuid,
|
|
bool SelfAccepted,
|
|
bool PartnerAccepted);
|
|
internal readonly record struct DeclineTradeRuntimeCmd;
|
|
internal readonly record struct ResetTradeRuntimeCmd;
|
|
internal readonly record struct ClearFriendsRuntimeCmd;
|
|
internal readonly record struct RequestLegacyFriendsRuntimeCmd;
|
|
internal readonly record struct ModifyCharacterSquelchRuntimeCmd(
|
|
bool Add,
|
|
uint CharacterId,
|
|
string Name,
|
|
uint MessageType);
|
|
internal readonly record struct ModifyAccountSquelchRuntimeCmd(
|
|
bool Add,
|
|
string Name);
|
|
internal readonly record struct ModifyGlobalSquelchRuntimeCmd(
|
|
bool Add,
|
|
uint MessageType);
|
|
|
|
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────────
|
|
internal readonly record struct FellowshipCreateRuntimeCmd(
|
|
string FellowshipName,
|
|
bool ShareXp);
|
|
internal readonly record struct FellowshipRecruitRuntimeCmd(uint TargetGuid);
|
|
internal readonly record struct FellowshipDismissRuntimeCmd(uint TargetGuid);
|
|
internal readonly record struct FellowshipQuitRuntimeCmd(bool Disband);
|
|
internal readonly record struct FellowshipAssignNewLeaderRuntimeCmd(uint NewLeaderGuid);
|
|
internal readonly record struct FellowshipChangeOpennessRuntimeCmd(bool IsOpen);
|
|
internal readonly record struct FellowshipUpdateRequestRuntimeCmd(bool PanelOpen);
|
|
internal readonly record struct AllegianceSwearRuntimeCmd(uint PatronGuid);
|
|
internal readonly record struct AllegianceBreakRuntimeCmd(uint TargetGuid);
|
|
internal readonly record struct AllegianceKickRuntimeCmd(uint VassalGuid);
|
|
internal readonly record struct AllegianceInfoRequestRuntimeCmd(string PlayerName);
|
|
internal readonly record struct AllegianceUpdateRequestRuntimeCmd(bool On);
|
|
|
|
/// <summary>
|
|
/// Owns the command surface for one exact live-session generation. The router
|
|
/// itself is the published bus, so a retained reference becomes inert before
|
|
/// the displaced transport is disposed.
|
|
/// </summary>
|
|
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|
{
|
|
private readonly object _gate = new();
|
|
private LiveCommandBus? _commands;
|
|
private ClientCommandController.Bindings? _clientCommands;
|
|
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
|
|
|
public LiveSessionCommandRouter(LiveSessionCommandBindings bindings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
ArgumentNullException.ThrowIfNull(bindings.ClientCommands);
|
|
ArgumentNullException.ThrowIfNull(bindings.Chat);
|
|
ArgumentNullException.ThrowIfNull(bindings.TurbineChat);
|
|
ArgumentNullException.ThrowIfNull(bindings.PlayerGuid);
|
|
ArgumentNullException.ThrowIfNull(bindings.SendTalk);
|
|
ArgumentNullException.ThrowIfNull(bindings.SendTell);
|
|
ArgumentNullException.ThrowIfNull(bindings.SendChannel);
|
|
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
|
|
ArgumentNullException.ThrowIfNull(bindings.Communication);
|
|
ArgumentNullException.ThrowIfNull(bindings.CharacterState);
|
|
|
|
_clientCommands = bindings.ClientCommands;
|
|
var commands = new LiveCommandBus();
|
|
var clientCommands = new ClientCommandController(
|
|
BuildGuardedClientCommands(bindings.ClientCommands));
|
|
commands.Register<ExecuteClientCommandCmd>(clientCommands.Execute);
|
|
commands.Register<SendServerCommandCmd>(command =>
|
|
{
|
|
if (!string.IsNullOrEmpty(command.Text))
|
|
SendIfActive(() => bindings.SendTalk(command.Text));
|
|
});
|
|
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
|
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
|
// ChannelSystem::GetChannelID fallback tags — bypasses
|
|
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
|
// legacy ChatChannel (0x0147) broadcast directly.
|
|
commands.Register<SendRawChannelCmd>(
|
|
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
|
|
commands.Register<AddShortcutRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
|
|
commands.Register<RemoveShortcutRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.RemoveShortcut(command.Index)));
|
|
commands.Register<AddFavoriteRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.AddFavorite(
|
|
command.SpellId,
|
|
command.Position,
|
|
command.TabIndex)));
|
|
commands.Register<RemoveFavoriteRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.RemoveFavorite(
|
|
command.SpellId,
|
|
command.TabIndex)));
|
|
commands.Register<SetSpellbookFilterRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SetSpellbookFilter(command.Filters)));
|
|
commands.Register<ForgetSpellRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.ForgetSpell(command.SpellId)));
|
|
commands.Register<SetDesiredComponentRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.SetDesiredComponent(
|
|
command.ComponentId,
|
|
command.Amount)));
|
|
commands.Register<ClearDesiredComponentsRuntimeCmd>(
|
|
_ => SendIfActive(bindings.ClearDesiredComponents));
|
|
commands.Register<RaiseAttributeRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.RaiseAttribute(command.StatId, command.Cost)));
|
|
commands.Register<RaiseVitalRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.RaiseVital(command.StatId, command.Cost)));
|
|
commands.Register<RaiseSkillRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.RaiseSkill(command.StatId, command.Cost)));
|
|
commands.Register<TrainSkillRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.TrainSkill(command.StatId, command.Cost)));
|
|
commands.Register<SetSingleCharacterOptionRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendSingleCharacterOption(
|
|
command.OptionId,
|
|
command.Value)));
|
|
commands.Register<SaveCharacterOptionsRuntimeCmd>(
|
|
_ => SendIfActive(bindings.SaveCharacterOptions));
|
|
commands.Register<AddFriendRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.AddFriend(command.Name)));
|
|
commands.Register<OpenTradeNegotiationsRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.OpenTradeNegotiations(command.PartnerGuid)));
|
|
commands.Register<CloseTradeNegotiationsRuntimeCmd>(
|
|
_ => SendIfActive(bindings.CloseTradeNegotiations));
|
|
commands.Register<AddToTradeRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid)));
|
|
commands.Register<AcceptTradeRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.AcceptTrade(
|
|
command.PartnerGuid,
|
|
command.SelfAccepted,
|
|
command.PartnerAccepted)));
|
|
commands.Register<DeclineTradeRuntimeCmd>(
|
|
_ => SendIfActive(bindings.DeclineTrade));
|
|
commands.Register<ResetTradeRuntimeCmd>(
|
|
_ => SendIfActive(bindings.ResetTrade));
|
|
commands.Register<RemoveFriendRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.RemoveFriend(command.CharacterId)));
|
|
commands.Register<ClearFriendsRuntimeCmd>(
|
|
_ => SendIfActive(bindings.ClearFriends));
|
|
commands.Register<RequestLegacyFriendsRuntimeCmd>(
|
|
_ => SendIfActive(bindings.RequestLegacyFriends));
|
|
commands.Register<ModifyCharacterSquelchRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.ModifyCharacterSquelch(
|
|
command.Add,
|
|
command.CharacterId,
|
|
command.Name,
|
|
command.MessageType)));
|
|
commands.Register<ModifyAccountSquelchRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.ModifyAccountSquelch(
|
|
command.Add,
|
|
command.Name)));
|
|
commands.Register<ModifyGlobalSquelchRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.ModifyGlobalSquelch(
|
|
command.Add,
|
|
command.MessageType)));
|
|
commands.Register<FellowshipCreateRuntimeCmd>(
|
|
command => SendIfActive(() => bindings.SendFellowshipCreate(
|
|
command.FellowshipName,
|
|
command.ShareXp)));
|
|
commands.Register<FellowshipRecruitRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipRecruit(command.TargetGuid)));
|
|
commands.Register<FellowshipDismissRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipDismiss(command.TargetGuid)));
|
|
commands.Register<FellowshipQuitRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipQuit(command.Disband)));
|
|
commands.Register<FellowshipAssignNewLeaderRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipAssignNewLeader(command.NewLeaderGuid)));
|
|
commands.Register<FellowshipChangeOpennessRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipChangeOpenness(command.IsOpen)));
|
|
commands.Register<FellowshipUpdateRequestRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendFellowshipUpdateRequest(command.PanelOpen)));
|
|
commands.Register<AllegianceSwearRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendAllegianceSwear(command.PatronGuid)));
|
|
commands.Register<AllegianceBreakRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendAllegianceBreak(command.TargetGuid)));
|
|
commands.Register<AllegianceKickRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendAllegianceKick(command.VassalGuid)));
|
|
commands.Register<AllegianceInfoRequestRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendAllegianceInfoRequest(command.PlayerName)));
|
|
commands.Register<AllegianceUpdateRequestRuntimeCmd>(
|
|
command => SendIfActive(() =>
|
|
bindings.SendAllegianceUpdateRequest(command.On)));
|
|
_commands = commands;
|
|
}
|
|
|
|
public bool IsActive
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _state == 1;
|
|
}
|
|
}
|
|
|
|
public void Activate()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_state == 2)
|
|
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
|
|
_state = 1;
|
|
}
|
|
}
|
|
|
|
public void Publish<T>(T command) where T : notnull
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_state == 1)
|
|
_commands?.Publish(command);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
LiveCommandBus? commands;
|
|
lock (_gate)
|
|
{
|
|
_state = 2;
|
|
commands = _commands;
|
|
_commands = null;
|
|
_clientCommands = null;
|
|
}
|
|
|
|
commands?.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
|
|
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
|
|
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
|
|
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
|
|
/// is legacy-only (0x0147) — those pipelines never overlap Turbine.
|
|
/// <see cref="ChatChannelKind.Allegiance"/> is the one exception, and
|
|
/// <see cref="RouteChat"/> special-cases it BEFORE this table is
|
|
/// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original
|
|
/// CH3 filing (research doc §5.3) — retail's <c>/a</c> is bound to the
|
|
/// LEGACY <c>AllegianceBroadcast</c> bitflag by default and is only
|
|
/// rebound to <c>DoTurbineChat_Allegiance</c> once
|
|
/// <c>StartupTurbineChatSystem</c> successfully starts Turbine chat
|
|
/// (research doc §4.3). So "Turbine never started" (<c>TurbineChat.
|
|
/// Enabled == false</c>) still falls back to legacy, while "Turbine is
|
|
/// up but this character has no allegiance room" (<c>Enabled == true</c>,
|
|
/// <c>AllegianceRoom == 0</c>) correctly keeps retail's local
|
|
/// "Turbine chat is not available." refusal at the membership gate.
|
|
/// </summary>
|
|
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> TurbineChannelKinds = new()
|
|
{
|
|
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
|
|
[ChatChannelKind.General] = ChatChannelKindLite.General,
|
|
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
|
|
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
|
|
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
|
|
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
|
|
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
|
|
};
|
|
|
|
private void RouteChat(
|
|
LiveSessionCommandBindings bindings,
|
|
SendChatCmd command)
|
|
{
|
|
if (string.IsNullOrEmpty(command.Text))
|
|
return;
|
|
|
|
switch (command.Channel)
|
|
{
|
|
case ChatChannelKind.Say:
|
|
// ACE echoes HearSpeech to the sender. Retail therefore uses
|
|
// the authoritative inbound line rather than a local echo.
|
|
SendIfActive(() => bindings.SendTalk(command.Text));
|
|
return;
|
|
|
|
case ChatChannelKind.Tell:
|
|
if (string.IsNullOrEmpty(command.TargetName))
|
|
return;
|
|
if (!SendIfActive(() =>
|
|
bindings.SendTell(command.TargetName, command.Text)))
|
|
return;
|
|
bindings.Chat.OnSelfSent(
|
|
ChatKind.Tell,
|
|
command.Text,
|
|
// Retail's own "You tell ..." echo is Speech_Direct_Send
|
|
// (0x04), distinct from an incoming Tell's 0x03 — see
|
|
// ChatMessageType.OutgoingTell's "You tell ..." comment.
|
|
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
|
|
targetOrChannel: command.TargetName);
|
|
return;
|
|
}
|
|
|
|
// S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc
|
|
// comment above — Turbine chat never having started (no 0x0295
|
|
// SetTurbineChatChannels received at all) still routes /a through
|
|
// the legacy AllegianceBroadcast bitflag, exactly like retail's
|
|
// default binding before StartupTurbineChatSystem runs.
|
|
if (command.Channel == ChatChannelKind.Allegiance
|
|
&& !bindings.TurbineChat.Enabled)
|
|
{
|
|
RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text);
|
|
return;
|
|
}
|
|
|
|
if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind))
|
|
{
|
|
RouteTurbineChat(bindings, liteKind, command.Text);
|
|
return;
|
|
}
|
|
|
|
RouteLegacyChannel(bindings, command.Channel, command.Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Step 2 of the CH3 fix list: retail
|
|
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
|
|
/// membership gate, raised through the same
|
|
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
|
|
/// every other client-raised refusal.
|
|
/// </summary>
|
|
private void RouteTurbineChat(
|
|
LiveSessionCommandBindings bindings,
|
|
ChatChannelKindLite kind,
|
|
string text)
|
|
{
|
|
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
|
kind,
|
|
bindings.TurbineChat,
|
|
bindings.CharacterState.Options,
|
|
bindings.CharacterState.IsOlthoiPlayer);
|
|
|
|
// N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is
|
|
// now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via
|
|
// TurbineChatMembershipGate.ResolveRefusalText — this used to be an
|
|
// independent copy of the same switch.
|
|
if (gate.Status != TurbineChatGateStatus.Allowed)
|
|
{
|
|
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
|
|
(string refusalText, RetailLogTextType refusalType))
|
|
{
|
|
bindings.Communication.AddText(refusalText, refusalType);
|
|
}
|
|
return;
|
|
}
|
|
|
|
uint cookie = bindings.TurbineChat.NextContextId();
|
|
uint senderGuid = bindings.PlayerGuid();
|
|
bindings.Log?.Invoke(
|
|
$"chat: outbound TurbineChat {gate.DisplayName} " +
|
|
$"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " +
|
|
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
|
|
SendIfActive(() => bindings.SendTurbineChat(
|
|
gate.RoomId,
|
|
gate.ChatType,
|
|
(uint)TurbineChat.DispatchType.SendToRoomById,
|
|
senderGuid,
|
|
text,
|
|
cookie));
|
|
}
|
|
|
|
private void RouteLegacyChannel(
|
|
LiveSessionCommandBindings bindings,
|
|
ChatChannelKind channel,
|
|
string text)
|
|
{
|
|
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
|
|
if (legacy is null)
|
|
{
|
|
bindings.Log?.Invoke(
|
|
$"chat: SendChatCmd kind={channel} dropped (no legacy id)");
|
|
return;
|
|
}
|
|
|
|
bindings.Log?.Invoke(
|
|
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
|
|
$"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
|
|
if (!SendIfActive(() =>
|
|
bindings.SendChannel(legacy.Value.ChannelId, text)))
|
|
return;
|
|
|
|
// Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends
|
|
// Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an
|
|
// empty sender name, so a local optimistic echo double-prints. S1
|
|
// (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into
|
|
// this SAME group: ACE's GameActionChatChannel handler iterates
|
|
// player.Allegiance.Members and the sender is one of them, so they
|
|
// get their own line back with their real name too — a different
|
|
// mechanism (no separate ""-sender resend) but the same
|
|
// double-print risk, so it must ALSO skip the local echo (research
|
|
// doc §3.7/§5.4, corrected).
|
|
bool serverEchoes = new ChatChannelInfo.Legacy(
|
|
legacy.Value.ChannelId,
|
|
legacy.Value.DisplayName).IsSelfEchoChannel();
|
|
if (serverEchoes)
|
|
return;
|
|
|
|
bindings.Chat.OnSelfSent(
|
|
ChatKind.Channel,
|
|
text,
|
|
targetOrChannel: legacy.Value.DisplayName,
|
|
// Precise per-bit own-send type (LegacyChannelChatType.Resolve's
|
|
// ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/
|
|
// Vassal/Follower become 0x0B, the admin/audit/sentinel
|
|
// catch-all becomes 0x09 Channel_Send (corrected 2026-08-09,
|
|
// Opus review of 172c6f9a — was wrongly 0x0E).
|
|
logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true));
|
|
}
|
|
|
|
private ClientCommandController.Bindings BuildGuardedClientCommands(
|
|
ClientCommandController.Bindings source) => new(
|
|
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),
|
|
TeleportToMarketplace: () => InvokeClient(static b => b.TeleportToMarketplace()),
|
|
TeleportToPkArena: () => InvokeClient(static b => b.TeleportToPkArena()),
|
|
TeleportToPkLiteArena: () => InvokeClient(static b => b.TeleportToPkLiteArena()),
|
|
TeleportToHouse: () => InvokeClient(static b => b.TeleportToHouse()),
|
|
TeleportToMansion: () => InvokeClient(static b => b.TeleportToMansion()),
|
|
QueryAge: () => InvokeClient(static b => b.QueryAge()),
|
|
QueryBirth: () => InvokeClient(static b => b.QueryBirth()),
|
|
ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()),
|
|
ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()),
|
|
ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)),
|
|
ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)),
|
|
PlayerPublicWeenieBitfield: () =>
|
|
ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)),
|
|
ClientVersion: () => ReadClient(static b => b.ClientVersion(), string.Empty),
|
|
CurrentPosition: () =>
|
|
ReadClient(static b => b.CurrentPosition(), default(AcDream.Core.Physics.Position?)),
|
|
LastOutsideCorpsePosition: () =>
|
|
ReadClient(static b => b.LastOutsideCorpsePosition(), default(AcDream.Core.Physics.Position?)),
|
|
ShowConfirmation: (text, callback) =>
|
|
InvokeClient(b => b.ShowConfirmation(text, callback)),
|
|
Suicide: () => InvokeClient(static b => b.Suicide()),
|
|
ClearChat: all => InvokeClient(b => b.ClearChat(all)),
|
|
SaveUi: name => InvokeClient(b => b.SaveUi(name)),
|
|
LoadUi: name => InvokeClient(b => b.LoadUi(name)),
|
|
SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()),
|
|
LoadAutoUi: () => InvokeClient(static b => b.LoadAutoUi()),
|
|
IsAway: () => ReadClient(static b => b.IsAway(), false),
|
|
SetAway: away => InvokeClient(b => b.SetAway(away)),
|
|
SetAwayMessage: message => InvokeClient(b => b.SetAwayMessage(message)),
|
|
AcceptLootPermits: () => ReadClient(static b => b.AcceptLootPermits(), false),
|
|
SetAcceptLootPermits: accept => InvokeClient(b => b.SetAcceptLootPermits(accept)),
|
|
DisplayConsent: () => InvokeClient(static b => b.DisplayConsent()),
|
|
ClearConsent: () => InvokeClient(static b => b.ClearConsent()),
|
|
RemoveConsent: name => InvokeClient(b => b.RemoveConsent(name)),
|
|
SendEmote: text => InvokeClient(b => b.SendEmote(text)),
|
|
Friends: source.Friends,
|
|
AddFriend: name => InvokeClient(b => b.AddFriend(name)),
|
|
RemoveFriend: id => InvokeClient(b => b.RemoveFriend(id)),
|
|
ClearFriends: () => InvokeClient(static b => b.ClearFriends()),
|
|
RequestLegacyFriends: () => InvokeClient(static b => b.RequestLegacyFriends()),
|
|
Squelch: source.Squelch,
|
|
ModifyCharacterSquelch: (add, id, name, chatType) =>
|
|
InvokeClient(b => b.ModifyCharacterSquelch(add, id, name, chatType)),
|
|
ModifyAccountSquelch: (add, name) =>
|
|
InvokeClient(b => b.ModifyAccountSquelch(add, name)),
|
|
ModifyGlobalSquelch: (add, chatType) =>
|
|
InvokeClient(b => b.ModifyGlobalSquelch(add, chatType)),
|
|
LastTeller: () => ReadClient(static b => b.LastTeller(), default(string?)),
|
|
ClearDesiredComponents: () => InvokeClient(static b => b.ClearDesiredComponents()),
|
|
HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false),
|
|
FillComponentBuyList: (componentId, targetCount) =>
|
|
InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)),
|
|
EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()),
|
|
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
|
IsUsingTurbineChat: () => ReadClient(static b => b.IsUsingTurbineChat(), false),
|
|
SetChatTitle: title => InvokeClient(b => b.SetChatTitle(title)),
|
|
SetSingleCharacterOption: (optionId, value) =>
|
|
InvokeClient(b => b.SetSingleCharacterOption(optionId, value)),
|
|
AddPlayerPermission: name => InvokeClient(b => b.AddPlayerPermission(name)),
|
|
RemovePlayerPermission: name => InvokeClient(b => b.RemovePlayerPermission(name)),
|
|
RequestAvailableHouses: houseType => InvokeClient(b => b.RequestAvailableHouses(houseType)),
|
|
RequestChannelIndex: () => InvokeClient(static b => b.RequestChannelIndex()),
|
|
RequestChannelList: channelId => InvokeClient(b => b.RequestChannelList(channelId)),
|
|
JoinGmChannel: channelId => InvokeClient(b => b.JoinGmChannel(channelId)),
|
|
LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)),
|
|
RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()),
|
|
RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)),
|
|
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()));
|
|
|
|
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ClientCommandController.Bindings? bindings = _clientCommands;
|
|
if (_state != 1 || bindings is null)
|
|
return false;
|
|
invoke(bindings);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private TResult ReadClient<TResult>(
|
|
Func<ClientCommandController.Bindings, TResult> read,
|
|
TResult fallback)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ClientCommandController.Bindings? bindings = _clientCommands;
|
|
return _state == 1 && bindings is not null
|
|
? read(bindings)
|
|
: fallback;
|
|
}
|
|
}
|
|
|
|
private bool SendIfActive(Action send)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_state != 1)
|
|
return false;
|
|
send();
|
|
return true;
|
|
}
|
|
}
|
|
}
|