629 lines
26 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|