acdream/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs

629 lines
26 KiB
C#

using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
public sealed record LiveEntitySessionSink(
Action<WorldSession.EntitySpawn> Spawned,
Action<DeleteObject.Parsed> Deleted,
Action<PickupEvent.Parsed> PickedUp,
Action<WorldSession.EntityMotionUpdate> MotionUpdated,
Action<WorldSession.EntityPositionUpdate> PositionUpdated,
Action<VectorUpdate.Parsed> VectorUpdated,
Action<SetState.Parsed> StateUpdated,
Action<ParentEvent.Parsed> ParentUpdated,
Action<uint> TeleportStarted,
Action<ObjDescEvent.Parsed> AppearanceUpdated,
Action<PlayPhysicsScript> PlayPhysicsScript,
Action<PlayPhysicsScriptType> PlayPhysicsScriptType);
public sealed record LiveEnvironmentSessionSink(
Action<uint> EnvironChanged,
Action<double> ServerTimeUpdated);
public sealed record LiveInventorySessionBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
public sealed record LiveCharacterSessionBindings(
CombatState Combat,
RuntimeCharacterState Character,
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Func<double>? ClientTime,
// Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden,
// stamina, OR (vitae/enchantment-adjusted) skill values change mid-
// session — the reactive re-apply-to-the-live-controller seam, mirroring
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
// unchanged.
Action? OnMovementStatsUpdated = null);
public sealed record LiveSocialSessionBindings(
ChatLog Chat,
TurbineChatState TurbineChat,
FriendsState? Friends,
SquelchState? Squelch);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
/// remains in the supplied sinks; this class owns only routing and teardown.
/// </summary>
public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{
private readonly LiveSessionSubscriptionSet _subscriptions = new();
private readonly Action<int>? _constructionCheckpoint;
private readonly WorldSession _session;
private readonly LiveEntitySessionSink _entities;
private readonly LiveEnvironmentSessionSink _environment;
private readonly LiveInventorySessionBindings _inventory;
private readonly LiveCharacterSessionBindings _character;
private readonly LiveSocialSessionBindings _social;
private int _constructionStep;
private int _accepting;
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
public LiveSessionEventRouter(
WorldSession session,
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social,
Action<int>? constructionCheckpoint = null)
{
ArgumentNullException.ThrowIfNull(session);
Validate(entities, environment, inventory, character, social);
_session = session;
_entities = entities;
_environment = environment;
_inventory = inventory;
_character = character;
_social = social;
_constructionCheckpoint = constructionCheckpoint;
}
public void Attach()
{
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
throw new InvalidOperationException(
"Live-session event routing can only attach once.");
Interlocked.Exchange(ref _accepting, 1);
WorldSession session = _session;
LiveEntitySessionSink entities = _entities;
LiveEnvironmentSessionSink environment = _environment;
LiveInventorySessionBindings inventory = _inventory;
LiveCharacterSessionBindings character = _character;
LiveSocialSessionBindings social = _social;
try
{
// Preserve the shipped pre-Connect registration order. Property
// state is installed before lifecycle packets can be dispatched.
_subscriptions.Add(ObjectTableWiring.Wire(
session,
inventory.Objects,
inventory.PlayerGuid,
character.Character.LocalPlayer,
IsAccepting));
ConstructionCheckpoint();
_subscriptions.Add(CombatStateWiring.Wire(
session,
character.Combat,
IsAccepting));
ConstructionCheckpoint();
Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned);
Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted);
Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp);
Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated);
Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated);
Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated);
Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated);
Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated);
Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted);
Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated);
Subscribe(
h => session.PlayPhysicsScriptReceived += h,
h => session.PlayPhysicsScriptReceived -= h,
entities.PlayPhysicsScript);
Subscribe(
h => session.PlayPhysicsScriptTypeReceived += h,
h => session.PlayPhysicsScriptTypeReceived -= h,
entities.PlayPhysicsScriptType);
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
_subscriptions.Add(GameEventWiring.WireAll(
session.GameEvents,
inventory.Objects,
character.Combat,
character.Character.Spellbook,
social.Chat,
character.Character.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: (runSkill, jumpSkill) =>
{
// Campaign P Slice P1 (2026-07-30): route the PD/skill
// base through the vitae/enchantment-adjusted recompute
// (CEnchantmentRegistry::EnchantSkill) instead of writing
// MovementSkills directly — see the pseudocode doc §9.
character.Character.UpdateMovementSkillBase(
runSkill,
jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
character.OnMovementStatsUpdated?.Invoke();
},
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
onUseDone: inventory.OnUseDone,
onAppraisal: inventory.OnAppraisal,
itemMana: inventory.ItemMana,
onConfirmationRequest: character.OnConfirmationRequest,
onConfirmationDone: character.OnConfirmationDone,
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: null,
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
accepting: IsAccepting));
ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
// the SAME event set IndicatorBarController.UpdateBurden already
// reacts to (Strength + augmentation property 0xE6 +
// EncumbranceVal property 5, falling back to SumCarriedBurden).
// See the pseudocode doc §9. Campaign P Slice P3 (2026-07-30)
// rides the SAME triggers for the player's own PWD bitfield
// (PK/PKLite/Impenetrable) and PlayerKillerStatus/
// LastPkAttackTimestamp — all live on the SAME ClientObject row.
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectAdded += h,
h => inventory.Objects.ObjectAdded -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectUpdated += h,
h => inventory.Objects.ObjectUpdated -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectRemoved += h,
h => inventory.Objects.ObjectRemoved -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObjectMove>(
h => inventory.Objects.ObjectMoved += h,
h => inventory.Objects.ObjectMoved -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<uint>(
h => inventory.Objects.ContainerContentsReplaced += h,
h => inventory.Objects.ContainerContentsReplaced -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeParameterless(
h => inventory.Objects.Cleared += h,
h => inventory.Objects.Cleared -= h,
() => RecomputePlayerQualities(inventory, character));
Subscribe<LocalPlayerState.AttributeKind>(
h => character.Character.LocalPlayer.AttributeChanged += h,
h => character.Character.LocalPlayer.AttributeChanged -= h,
kind =>
{
if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character);
});
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
Subscribe<LocalPlayerState.VitalKind>(
h => character.Character.LocalPlayer.Changed += h,
h => character.Character.LocalPlayer.Changed -= h,
kind => RecomputeStamina(kind, character));
_subscriptions.Add(new CombatChatTranslator(
character.Combat,
social.Chat,
IsAccepting));
ConstructionCheckpoint();
Subscribe<HearSpeech.Parsed>(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech =>
social.Chat.OnLocalSpeech(
speech.SenderName,
speech.Text,
speech.SenderGuid,
speech.IsRanged));
Subscribe<ServerMessage.Parsed>(
h => session.ServerMessageReceived += h,
h => session.ServerMessageReceived -= h,
message => social.Chat.OnSystemMessage(message.Message, message.ChatType));
Subscribe<EmoteText.Parsed>(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote =>
social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<SoulEmote.Parsed>(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote =>
social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<PlayerKilled.Parsed>(
h => session.PlayerKilledReceived += h,
h => session.PlayerKilledReceived -= h,
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,
parsed => RouteTurbineChat(social.Chat, parsed));
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
character.Character.LocalPlayer.OnVitalUpdate(
vital.VitalId,
vital.Ranks,
vital.Start,
vital.Xp,
vital.Current));
Subscribe<PrivateUpdateVital.ParsedCurrent>(
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
}
catch
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
throw;
}
}
public bool Accepting => IsAccepting();
public void Dispose()
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
_subscriptions.Dispose();
}
private void Subscribe<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action<T> sink)
{
Action<T> handler = value =>
{
if (Volatile.Read(ref _accepting) != 0)
sink(value);
};
attach(handler);
// Add assumes cleanup ownership before it can call an external detach.
// If the set is already closing, it retains/retries that exact edge;
// calling detach again here would replay a successful removal.
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
/// job is "something relevant changed, recompute" — thin wrapper over
/// <see cref="Subscribe{T}"/> that discards the payload.
/// </summary>
private void SubscribeToRecompute<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action recompute) =>
Subscribe(attach, detach, (T _) => recompute());
/// <summary>
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
/// no payload).
/// </summary>
private void SubscribeParameterless(
Action<Action> attach,
Action<Action> detach,
Action sink)
{
Action handler = () =>
{
if (Volatile.Read(ref _accepting) != 0)
sink();
};
attach(handler);
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
/// property 5, falling back to the summed carried burden) — the SAME
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
/// <c>InventoryController.RefreshBurden</c> already use for the burden
/// HUD. See the pseudocode doc §2/§9.
/// </summary>
private static void RecomputeBurden(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
int strength = (int)(character.Character.LocalPlayer
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u);
int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);
int burden = playerObject is not null
&& playerObject.Properties.Ints.TryGetValue(
(uint)PropertyInt.EncumbranceVal, out int wireBurden)
? wireBurden
: inventory.Objects.SumCarriedBurden(player);
float load = EncumbranceSystem.Load(capacity, burden);
character.Character.MovementSkills.UpdateBurden(load);
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
private static void RecomputePlayerQualities(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
{
RecomputeBurden(inventory, character, notify: false);
RecomputePvpStatus(inventory, character, notify: false);
uint player = inventory.PlayerGuid();
PropertyBundle properties = inventory.Objects.Get(player)?.Properties
?? character.Character.LocalPlayer.Properties;
character.Character.UpdateMovementSkillAugmentations(
PlayerSkillMath.AugmentationBonuses.FromProperties(properties));
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's
/// own <c>PublicWeenieDesc._bitfield</c> (PK/PKLite/Impenetrable
/// collision-exemption bits, already parsed at CreateObject time — see
/// <c>CreateObject.cs</c>'s <c>objectDescriptionFlags</c> read) and the
/// raw <c>PlayerKillerStatus</c>(0x86)/<c>LastPkAttackTimestamp</c>(0x91)
/// pair (retail <c>CACQualities::JumpStaminaCost</c>'s PK-timer bump)
/// into <see cref="RuntimeMovementSkillState"/>. Rides the SAME
/// ClientObject add/update/move/clear events <see cref="RecomputeBurden"/>
/// already reacts to — both live on the player's own row.
/// </summary>
private static void RecomputePvpStatus(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
uint bitfield = playerObject?.PublicWeenieBitfield ?? 0u;
int pkStatus = playerObject?.Properties.Ints.TryGetValue(
(uint)PropertyInt.PlayerKillerStatus, out int wirePkStatus) == true
? wirePkStatus
: -1;
float? lastPkAttackTimestamp =
playerObject?.Properties.Floats.TryGetValue(
(uint)PropertyFloat.LastPkAttackTimestamp, out double wireTimestamp) == true
? (float)wireTimestamp
: null;
character.Character.MovementSkills.UpdateOwnPwdBitfield(bitfield);
character.Character.MovementSkills.UpdatePlayerKillerStatus(
pkStatus,
lastPkAttackTimestamp);
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
/// effective-skill-zeroing gate (pseudocode doc §5).
/// </summary>
private static void RecomputeStamina(
LocalPlayerState.VitalKind kind,
LiveCharacterSessionBindings character)
{
if (kind != LocalPlayerState.VitalKind.Stamina) return;
if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina)
is not LocalPlayerState.VitalSnapshot stamina)
{
return;
}
character.Character.MovementSkills.UpdateStamina((int)stamina.Current);
character.OnMovementStatsUpdated?.Invoke();
}
private void ConstructionCheckpoint() =>
_constructionCheckpoint?.Invoke(++_constructionStep);
private bool IsAccepting() => Volatile.Read(ref _accepting) != 0;
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
{
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
return;
chat.OnChannelBroadcast(
message.RoomId,
message.SenderName,
message.Message,
TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType));
}
private static void Validate(
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(social);
ArgumentNullException.ThrowIfNull(entities.Spawned);
ArgumentNullException.ThrowIfNull(entities.Deleted);
ArgumentNullException.ThrowIfNull(entities.PickedUp);
ArgumentNullException.ThrowIfNull(entities.MotionUpdated);
ArgumentNullException.ThrowIfNull(entities.PositionUpdated);
ArgumentNullException.ThrowIfNull(entities.VectorUpdated);
ArgumentNullException.ThrowIfNull(entities.StateUpdated);
ArgumentNullException.ThrowIfNull(entities.ParentUpdated);
ArgumentNullException.ThrowIfNull(entities.TeleportStarted);
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects);
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
ArgumentNullException.ThrowIfNull(character.Combat);
ArgumentNullException.ThrowIfNull(character.Character);
ArgumentNullException.ThrowIfNull(social.Chat);
ArgumentNullException.ThrowIfNull(social.TurbineChat);
}
}
internal sealed class LiveSessionSubscriptionSet : IDisposable
{
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
public void Dispose()
{
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int index = subscriptions.Length - 1; index >= 0; index--)
{
try
{
subscriptions[index].Dispose();
}
catch (Exception error)
{
(errors ??= []).Add(error);
}
}
if (errors is not null)
throw new AggregateException(
"one or more live-session subscriptions failed to detach",
errors);
}
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Live-session subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}