acdream/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
Erik 04161defd8 fix(ui): FA4 re-review REOPEN — re-declare 0x00A6 from the post-world seam, not the pre-world reset
The FA4 fix round's MUST-FIX 3 placed the 0x00A6 reconnect re-arm at the
wrong lifecycle point (re-review 8bbceff5): ResetSessionTransientUi runs
via the SessionDialogs reset stage BEFORE _inWorld=true, so SetPanelOpen
(world-gated, Validate requireWorld:true) returned Inactive and published
nothing — yet _pageVisible was latched true anyway, so no later hook
re-declared and fellow vitals stayed frozen for the whole new session.
The unit test passed only because the fake recorded unconditionally.

Two-part fix, both retail-faithful mechanisms not suppressions:
- SocialFellowshipPageController.SetPageVisible advances the edge-trigger
  latch ONLY when the declaration is Accepted (published), so a dropped
  pre-world send leaves the latch clear and a later attempt retries.
- ResetSessionDeclaration (pre-world) now ONLY clears the latch; the new
  RedeclareAfterWorldEntry fires from the LiveSession EnteredWorld seam
  (wired via RestoreLayout, idempotent if a persisted layout already
  re-showed the page) so a still-open Fellowship page re-declares 0x00A6
  in world and vitals resume.

Regression pins that actually catch it (the prior test could not):
- SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld
  (widget-level root, world-gated fake);
- Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset +
  Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen (panel-level,
  world-gated). RED-verified: reintroducing the pre-world declaration
  fails the reconnect test.

Full Release suite: 13,286 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:59:16 +02:00

625 lines
31 KiB
C#

using System.Diagnostics;
using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.Spells;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Content;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Net;
internal sealed record LiveSessionPlayerRuntime(
LocalPlayerIdentityState Identity,
RuntimeLocalPlayerMovementState Controller,
LiveWorldOriginState WorldOrigin);
internal sealed record LiveSessionDomainRuntime(
GameRuntime Runtime,
RuntimeEntityObjectLifetime EntityObjects,
RuntimeCharacterState Character,
RuntimeActionState Actions,
RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication);
internal sealed record LiveSessionUiRuntime(
RetailUiRuntime? RetailUi,
VitalsVM? Vitals,
CharacterSheetProvider? CharacterSheet,
MagicRuntime? Magic,
PaperdollFramePresenter? Paperdoll);
internal sealed record LiveSessionInteractionRuntime(
RuntimeSettingsController Settings,
GameplayInputFrameController GameplayInput,
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
ItemInteractionController ItemInteraction,
RuntimeCombatAttackState CombatAttack,
SelectionInteractionController SelectionInteractions);
internal sealed record LiveSessionWorldRuntime(
IDatReaderWriter Dats,
GpuWorldState WorldState,
LiveEntityRuntime LiveEntities,
LiveEntitySessionController EntitySession,
WorldEnvironmentController Environment,
DeferredLocalPlayerTeleportNetworkSink Teleport,
DatSpawnClaimHydrationClassifier SpawnClaims,
EquippedChildRenderController EquippedChildren,
RetailSelectionScene SelectionScene,
ParticleVisibilityController ParticleVisibility,
RetailInboundEventDispatcher InboundEvents,
LiveEntityLivenessController Liveness,
LiveEntityNetworkUpdateController NetworkUpdates,
LiveEntityHydrationController Hydration,
EntityEffectController EntityEffects,
AnimationHookFrameQueue AnimationHookFrames,
LiveEntityPresentationController Presentation,
RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow,
RuntimePlacementPresentationSink PlacementProjection,
RuntimePlacementProjectionRetrySlot PlacementRetries,
RuntimeFirstEntryDriveController FirstEntryDrive,
RuntimeAcceptedPositionDriveController AcceptedPositionDrive,
RuntimeRemotePlacementDriveController RemotePlacementDrive);
/// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live
/// session. It owns no mirrored session state and retains no window callback.
/// </summary>
internal sealed class LiveSessionRuntimeFactory
{
private readonly LiveSessionPlayerRuntime _player;
private readonly LiveSessionDomainRuntime _domain;
private readonly LiveSessionUiRuntime _ui;
private readonly LiveSessionInteractionRuntime _interaction;
private readonly LiveSessionWorldRuntime _world;
private readonly LiveSessionCommandSurface _commands;
private readonly Action<string> _log;
private readonly LiveMovementStatsApplier _movementStats;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
LiveSessionDomainRuntime domain,
LiveSessionUiRuntime ui,
LiveSessionInteractionRuntime interaction,
LiveSessionWorldRuntime world,
LiveSessionCommandSurface commands,
Action<string> log)
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
_ui = ui ?? throw new ArgumentNullException(nameof(ui));
_interaction = interaction
?? throw new ArgumentNullException(nameof(interaction));
_world = world ?? throw new ArgumentNullException(nameof(world));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? throw new ArgumentNullException(nameof(log));
// C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier(
_player.Controller,
_domain.Character.MovementSkills,
_log);
}
public LiveSessionHost Create(
LiveSessionController controller,
LiveSessionConnectOptions connectOptions)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(connectOptions);
var resetHost = new GraphicalRuntimeGenerationResetHost(
_world.LiveEntities,
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
LiveSessionResetPlan reset =
LiveSessionResetManifest.Create(
CreateResetBindings(resetHost));
return new LiveSessionHost(controller, new LiveSessionHostBindings(
Routing: new(
CreateEventRouter,
session => _commands.Attach(new LiveSessionCommandRouter(
CreateCommandBindings(session)))),
Reset: reset.Execute,
Selection: new(
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
MarkPersistent: _world.WorldState.MarkPersistent,
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
ClearCombat: _domain.Actions.Combat.Clear),
EnteredWorld: new(
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
RestoreLayout: () =>
{
_ui.RetailUi?.RestoreLayout();
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
// still-open Fellowship page's 0x00A6 now we are in world —
// RestoreLayout is the post-world UI-restore moment, and
// this is idempotent if RestoreLayout already re-showed the
// page (the latch is already set). ResetSessionTransientUi
// (pre-world) only cleared the latch.
_ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry();
},
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),
Connecting: (host, port, user) =>
_domain.Communication.Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
_domain.Communication.Chat.OnSystemMessage(
"connected — character list received",
chatType: 1)),
connectOptions);
}
private LiveSessionResetBindings CreateResetBindings(
IRuntimeGenerationResetHost resetHost) => new()
{
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
TeleportPresentation =
_world.Teleport.ResetGenerationPresentation,
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear,
InteractionPresentation =
_interaction.SelectionInteractions.ResetGenerationPresentation,
SelectionPresentation = _world.SelectionScene.Reset,
ParticleVisibility = _world.ParticleVisibility.Reset,
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
RuntimeGeneration = generation =>
_domain.Runtime.ResetGeneration(generation, resetHost),
SessionIdentityPresentation = ResetIdentityPresentation,
NetworkEffects = _world.EntityEffects.ClearNetworkState,
AnimationHookFrames = _world.AnimationHookFrames.Clear,
LivePresentation = _world.Presentation.Clear,
RemoteMovementDiagnostics = _world.RemoteMovementObservations.Clear,
};
private void ResetPlayerPresentation()
{
_movementStats.Reset();
_interaction.PlayerMode.ResetSession();
_world.SpawnClaims.Reset();
}
private void ResetIdentityPresentation(
RuntimeGenerationToken retiringGeneration)
{
RuntimeGenerationResetSnapshot reset =
_domain.Runtime.GenerationReset.CaptureSnapshot();
if (reset.IsActive
|| reset.LastCompletedGeneration != retiringGeneration)
{
throw new InvalidOperationException(
$"Runtime generation {retiringGeneration.Value} has not "
+ "converged before App identity projection teardown.");
}
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
// components, and character option objects. CPlayerSystem::Begin
// @ 0x0055D410 resets player identity and session fields. The option
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
_ui.Vitals?.SetLocalPlayerGuid(0u);
EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey();
_world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState();
_ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic
// landblock while still forcing first-spawn initialization.
_player.WorldOrigin.Reset();
}
private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
{
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
if (_ui.CharacterSheet is not null)
{
_ui.CharacterSheet.SkillTable = skillTable;
_ui.CharacterSheet.ExperienceTable =
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
}
var route = new LiveSessionEventRouter(
session,
_world.EntitySession.CreateSink(),
new LiveEnvironmentSessionSink(
_world.Environment.ApplyAdminEnvirons,
_world.Environment.SynchronizeFromServer),
CreateInventoryBindings(),
CreateCharacterBindings(skillTable),
new LiveSocialSessionBindings(
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
_domain.Communication.Friends,
_domain.Communication.Squelch,
(text, type) => _domain.Communication.AddText(text, type),
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
_world.PlacementProjection,
_world.PlacementRetries,
_world.FirstEntryDrive,
_ => session.SendGameAction(GameActionLoginComplete.Build()),
_world.AcceptedPositionDrive,
_world.RemotePlacementDrive);
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Inventory.Objects,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: _domain.Inventory.Shortcuts.Load,
OnUseDone: error =>
{
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
_domain.Actions.Transactions.CompleteUse(error);
},
_domain.Inventory.ItemMana,
ExternalContainers: _domain.Inventory.ExternalContainers,
OnAppraisal: appraisal =>
{
if (_ui.RetailUi is { } retailUi)
retailUi.HandleAppraisal(appraisal);
else
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
},
Vendor: _domain.Inventory.Vendor);
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)
{
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
return new LiveCharacterSessionBindings(
_domain.Actions.Combat,
_domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
_movementStats.Apply("skills"),
OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done),
ClientTime: ClientTimerNow,
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
// reactively re-apply through the SAME seam skills already used
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
// movement owner's typed application entry — see
// LiveMovementStatsApplier.
OnMovementStatsUpdated: () => _movementStats.Apply("stats"),
// Campaign CH slice CH3 (2026-08-09): reseed the Settings Chat
// draft from server truth every time a PlayerDescription lands
// (research doc §5.2/§6.4). N4 (CH3 Opus review, 2026-08-09)
// aligned ChatSettings.Default itself to ACE's real
// CharacterOptions2.Default, but this reseed stays load-bearing
// regardless — a per-character persisted settings.json can
// still diverge from server truth (e.g. an older save, or a
// character whose allegiance/society changed), and the server
// is always authoritative.
//
// OP4 review-fix round (2026-08-11, MF-1/blast M1): re-seed
// LockUI's visual push the SAME way — the radar's polled
// `GetOptionBit` lambda self-corrects every frame, but
// `host.Root.UiLocked` (InteractionRetainedUiComposition.cs)
// is a ONE-SHOT assignment taken pre-login, at composition
// time, from RuntimeCharacterOptionsState's constructor-
// default word. Routing the real value through SetUiLocked
// here — the exact seam ToggleUiLock already uses to push
// RuntimeSettingsTargets.ApplyUiLock — converges
// the retained window lock to server truth on every fresh
// PlayerDescription, matching the radar's own convergence
// instead of only updating on the next manual /lockui toggle.
OnCharacterOptionsChanged: (_, options2) =>
{
_interaction.Settings.SyncChatFromServerOptions(options2);
_interaction.Settings.SetUiLocked(
_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI));
// OP4 re-review R2: open option-bearing panels re-read live
// bits at every seed (login + reconnect) — see
// RuntimeSettingsController.ServerOptionsSeeded.
_interaction.Settings.NotifyServerOptionsSeeded();
});
}
private LiveSessionCommandBindings CreateCommandBindings(
WorldSession session)
{
// CH4 re-review SHOULD-FIX 2 (2026-08-09), widened by Campaign OP
// slice OP1 (2026-08-10): single write-then-send/dirty chokepoint
// for retail's PlayerModule::OnChanged policy, reached by BOTH
// entrances that can flip a character option — @join/@leave
// (ClientCommandController.Bindings.SetSingleCharacterOption below)
// and the Settings Chat toggles (LiveSessionCommandBindings.
// SendSingleCharacterOption at the bottom of this method, routed
// through RuntimeSettingsController.PublishHearOptionChange ->
// RuntimeSettingsTargets.SetSingleCharacterOption ->
// SetSingleCharacterOptionRuntimeCmd -> LiveSessionCommandRouter).
// OP1 moved the actual write-then-send/dirty POLICY into
// RuntimeCharacterOptionsState.TrySetOption (the shared Runtime seam
// src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs's
// SetSingleOption now also calls) so graphical and headless hosts —
// and every entrance within this host — share exactly one ordering:
// write the bit locally FIRST, then either send 0x0005 immediately
// (retail's auto-save ids) or mark the batched module dirty.
void SendSingleCharacterOption(uint optionId, bool value) =>
_domain.Character.Options.TrySetOption(
optionId,
value,
// MF-2 (OP1 review fix, 2026-08-11): TrySetOption now takes
// (id, value) so its fellowship mutual-exclusion recursion
// can send a DIFFERENT id/value than this call's own.
sendAutoSave: session.SendSetSingleCharacterOption);
// OP1: the explicit SaveOptions verb — retail's
// CPlayerModule::SaveToServer(force: 0). No-ops when the batched
// module is clean.
void SaveCharacterOptionsIfDirty() =>
_domain.Character.Options.TryFlush(() =>
{
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
_domain.Character,
_domain.Inventory.Shortcuts);
session.SendSetCharacterOptions(
echo.Options1,
echo.Options2,
echo.Shortcuts,
echo.FavoriteSpells,
echo.DesiredComponents,
echo.SpellbookFilters);
});
return new(
ClientCommands: new ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
TeleportToPkArena: session.SendTeleportToPkArena,
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
TeleportToHouse: session.SendTeleportToHouse,
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): flips the
// SERVER bit through the shared write-then-send/dirty seam
// (SendSingleCharacterOption above, itself
// RuntimeCharacterOptionsState.TrySetOption) — LockUI is
// auto-save (CharacterOptionTable), so this sends 0x0005
// immediately, exactly like the Character-tab panel row.
// SetUiLocked still runs too, for its existing
// _runtimeTargets?.ApplyUiLock(locked) immediate visual push
// (host.Root.UiLocked) — the legacy GameplaySettings mirror it
// used to also maintain was retired outright at OP9.
ToggleUiLock: () =>
{
bool locked = !_domain.Character.Options.GetOptionBit(
CharacterOptionId.LockUI);
SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
_interaction.Settings.SetUiLocked(locked);
},
// ClientCommandController's informational output sink — 0x00
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
// 172c6f9a). Retail types command output like @version/@loc
// green; 0x1A (bright red) is reserved for genuine refusals.
//
// Comment corrected 2026-08-09, CH2 REJECT-review rework
// (NIT 2, docs/research/2026-08-09-ch2-review-findings.md):
// CH2's SpewBox routing covers WeenieError/WeenieErrorWithString
// ids (see ShowWeenieError below), which carry their own
// resolved RetailLogTextType — it did NOT reach this sink,
// which takes plain pre-formatted text with no error code
// attached. Per-call-site refusal-vs-informational
// classification of this sink's callers, plus retail's
// windowId dual-destination echo (register row AP-180), remain
// unstarted — CH4/CH5 scope at the earliest, not CH2.
ShowSystemMessage:
text => _domain.Communication.Chat.OnSystemMessage(text, 0x00u),
// SHOULD-FIX 3 (docs/research/2026-08-09-ch2-review-findings.md):
// route through the AddText chokepoint instead of the deleted
// ChatLog.OnWeenieError, which hardcoded LogTextType 0x00 —
// several ShowWeenieError call sites (e.g. 0x0561, the friends-
// list-full refusal) resolve to ClientLocal and belong in the
// SpewBox, not green in chat. An id WeenieErrorMessages has no
// row for resolves to a null Text — retail's switch has no
// default case, so it produces no player-facing text.
ShowWeenieError:
code =>
{
(string? text, RetailLogTextType type) = WeenieErrorMessages.Resolve(code, null);
if (text is not null)
_domain.Communication.AddText(text, type);
else
// CH2 re-review nit 5 (docs/plans/2026-08-09-chat-parity-campaign.md):
// matches GameEventWiring's WeenieError/
// WeenieErrorWithString handlers, which already log
// this same case. An unmapped id resolves to a null
// Text — retail's switch has no default case, so it
// produces no player-facing text — but the raw id
// stays visible to us instead of silently vanishing.
Console.WriteLine($"[weenie-error] unmapped code=0x{code:X4}");
},
PlayerPublicWeenieBitfield: () =>
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
.PublicWeenieBitfield,
ClientVersion: () =>
typeof(LiveSessionRuntimeFactory).Assembly
.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
LastOutsideCorpsePosition: () =>
_domain.Character.LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => _domain.Communication.Chat.Clear(),
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
IsAway: () =>
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
.Properties.GetBool(0x6Eu) == true,
SetAway: session.SendSetAfkMode,
SetAwayMessage: session.SendSetAfkMessage,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): closes the
// unfiled divergence character-options-map.md §0 names —
// `/consent on|off` flipped only the client-local
// GameplaySettings.AcceptLootPermits bool and never reached the
// wire, even though retail auto-saves this id (0x0005
// immediately). Now routes through the SAME
// SendSingleCharacterOption seam the Character-tab panel row
// uses. OP9 retired the GameplaySettings mirror this used to
// ALSO maintain (RuntimeSettingsController.SetAcceptLootPermits)
// — the server bit read below is the sole authority now.
AcceptLootPermits: () =>
_domain.Character.Options.GetOptionBit(
CharacterOptionId.AcceptLootPermits),
SetAcceptLootPermits: value =>
SendSingleCharacterOption(
(uint)CharacterOptionId.AcceptLootPermits, value),
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
_domain.Communication.Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
_domain.Communication.Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () =>
_domain.Communication.CommandTargets.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { },
EnterPkLite: session.SendEnterPkLite,
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
IsUsingTurbineChat: () => _domain.Communication.TurbineChat.Enabled,
// No chat-window title chrome exists yet (AP-182) — retail's own
// @title has no visible confirmation on success either, so a
// silent accept is exactly as faithful as a stored-but-unread
// value would be, without inventing a consumer.
SetChatTitle: _ => { },
// CH4 re-review SHOULD-FIX 2 (2026-08-09): @join/@leave route
// through the same SendSingleCharacterOption local function as
// the Settings-route binding below — see the chokepoint comment
// at the top of CreateCommandBindings. Retail's
// PlayerModule::SetHear*Chat family writes the bit into the
// local options copy FIRST, then notifies the server — match
// that ordering so TurbineChatMembershipGate (which reads
// _domain.Character.Options) stops refusing the newly-joined
// room before the next PlayerDescription happens to arrive.
SetSingleCharacterOption: SendSingleCharacterOption,
AddPlayerPermission: session.SendAddPlayerPermission,
RemovePlayerPermission: session.SendRemovePlayerPermission,
RequestAvailableHouses: session.SendListAvailableHouses,
RequestChannelIndex: session.SendIndexChannels,
RequestChannelList: session.SendListChannel,
JoinGmChannel: session.SendOnChannel,
LeaveGmChannel: session.SendOffChannel,
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
AbandonHouse: session.SendAbandonHouse),
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
PlayerGuid: () => _player.Identity.ServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: (
roomId,
chatType,
dispatchType,
senderGuid,
text,
cookie) => session.SendTurbineChatTo(
roomId,
chatType,
dispatchType,
senderGuid,
text,
cookie),
AddShortcut: session.SendAddShortcut,
RemoveShortcut: session.SendRemoveShortcut,
AddFavorite: session.SendAddSpellFavorite,
RemoveFavorite: session.SendRemoveSpellFavorite,
SetSpellbookFilter: session.SendSpellbookFilter,
ForgetSpell: session.SendRemoveSpell,
SetDesiredComponent: session.SendSetDesiredComponentLevel,
ClearDesiredComponents: session.SendClearDesiredComponents,
RaiseAttribute: session.SendRaiseAttribute,
RaiseVital: session.SendRaiseVital,
RaiseSkill: session.SendRaiseSkill,
TrainSkill: session.SendTrainSkill,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Communication: _domain.Communication,
CharacterState: _domain.Character,
SendSingleCharacterOption: SendSingleCharacterOption,
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
// Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send
// wrappers, matching the WorldSession.SendXxx methods FA2 added.
SendFellowshipCreate: session.SendFellowshipCreate,
SendFellowshipRecruit: session.SendFellowshipRecruit,
SendFellowshipDismiss: session.SendFellowshipDismiss,
SendFellowshipQuit: session.SendFellowshipQuit,
SendFellowshipAssignNewLeader: session.SendFellowshipAssignNewLeader,
SendFellowshipChangeOpenness: session.SendFellowshipChangeOpenness,
SendFellowshipUpdateRequest: session.SendFellowshipUpdateRequest,
SendAllegianceSwear: session.SendAllegianceSwear,
SendAllegianceBreak: session.SendAllegianceBreak,
SendAllegianceKick: session.SendAllegianceKick,
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest,
Log: _log);
}
private static double ClientTimerNow() =>
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
}