refactor(net): own live session routing
This commit is contained in:
parent
707e606e35
commit
961bdd07b7
12 changed files with 1645 additions and 543 deletions
326
src/AcDream.App/Net/LiveSessionCommandRouter.cs
Normal file
326
src/AcDream.App/Net/LiveSessionCommandRouter.cs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
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<string>? Log = null);
|
||||
|
||||
/// <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 : ICommandBus, IDisposable
|
||||
{
|
||||
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);
|
||||
|
||||
_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));
|
||||
_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();
|
||||
}
|
||||
|
||||
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,
|
||||
targetOrChannel: command.TargetName);
|
||||
return;
|
||||
}
|
||||
|
||||
TurbineResolution? turbine = TurbineChatRouting.Resolve(
|
||||
command.Channel,
|
||||
bindings.TurbineChat);
|
||||
if (turbine is not null)
|
||||
{
|
||||
uint cookie = bindings.TurbineChat.NextContextId();
|
||||
uint senderGuid = bindings.PlayerGuid();
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
|
||||
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
|
||||
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}");
|
||||
SendIfActive(() => bindings.SendTurbineChat(
|
||||
turbine.Value.RoomId,
|
||||
turbine.Value.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid,
|
||||
command.Text,
|
||||
cookie));
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel);
|
||||
if (legacy is null)
|
||||
{
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: SendChatCmd kind={command.Channel} dropped " +
|
||||
$"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)");
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
|
||||
$"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}");
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendChannel(legacy.Value.ChannelId, command.Text)))
|
||||
return;
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Channel,
|
||||
command.Text,
|
||||
targetOrChannel: legacy.Value.DisplayName);
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct TurbineResolution(
|
||||
uint RoomId,
|
||||
uint ChatType,
|
||||
string DisplayName);
|
||||
|
||||
internal static class TurbineChatRouting
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the server-assigned Turbine room for one UI channel. This is
|
||||
/// the existing holtburger <c>resolve_turbine_channel</c> mapping
|
||||
/// (<c>references/holtburger/.../client/commands.rs</c>, lines 64-98),
|
||||
/// moved intact from GameWindow with its runtime-room gate preserved.
|
||||
/// </summary>
|
||||
public static TurbineResolution? Resolve(
|
||||
ChatChannelKind kind,
|
||||
TurbineChatState state)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
if (!state.Enabled)
|
||||
return null;
|
||||
|
||||
(uint Room, uint ChatType, string Name) = kind switch
|
||||
{
|
||||
ChatChannelKind.Allegiance =>
|
||||
(state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"),
|
||||
ChatChannelKind.General =>
|
||||
(state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"),
|
||||
ChatChannelKind.Trade =>
|
||||
(state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"),
|
||||
ChatChannelKind.Lfg =>
|
||||
(state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"),
|
||||
ChatChannelKind.Roleplay =>
|
||||
(state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"),
|
||||
ChatChannelKind.Society =>
|
||||
(state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"),
|
||||
ChatChannelKind.Olthoi =>
|
||||
(state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"),
|
||||
_ => (0u, 0u, string.Empty),
|
||||
};
|
||||
|
||||
return Room == 0u
|
||||
? null
|
||||
: new TurbineResolution(Room, ChatType, Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the retail-facing channel label from the inbound Turbine chat
|
||||
/// type, retaining the numeric-room fallback for unknown extensions.
|
||||
/// </summary>
|
||||
public static string DisplayName(uint roomId, uint chatType) =>
|
||||
(TurbineChat.ChatType)chatType switch
|
||||
{
|
||||
TurbineChat.ChatType.Allegiance => "Allegiance",
|
||||
TurbineChat.ChatType.General => "General",
|
||||
TurbineChat.ChatType.Trade => "Trade",
|
||||
TurbineChat.ChatType.Lfg => "LFG",
|
||||
TurbineChat.ChatType.Roleplay => "Roleplay",
|
||||
TurbineChat.ChatType.Society => "Society",
|
||||
TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||||
TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||||
TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||||
TurbineChat.ChatType.Olthoi => "Olthoi",
|
||||
_ => $"Room 0x{roomId:X8}",
|
||||
};
|
||||
}
|
||||
341
src/AcDream.App/Net/LiveSessionEventRouter.cs
Normal file
341
src/AcDream.App/Net/LiveSessionEventRouter.cs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal 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);
|
||||
|
||||
internal sealed record LiveEnvironmentSessionSink(
|
||||
Action<uint> EnvironChanged,
|
||||
Action<double> ServerTimeUpdated);
|
||||
|
||||
internal sealed record LiveInventorySessionBindings(
|
||||
ClientObjectTable Objects,
|
||||
LocalPlayerState LocalPlayer,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
|
||||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers);
|
||||
|
||||
internal sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
Spellbook Spellbook,
|
||||
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
|
||||
Action<int, int>? OnSkillsUpdated,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
||||
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
|
||||
Action<uint, uint>? OnCharacterOptions,
|
||||
Func<double>? ClientTime);
|
||||
|
||||
internal 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>
|
||||
internal sealed class LiveSessionEventRouter : IDisposable
|
||||
{
|
||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||
private readonly Action<int>? _constructionCheckpoint;
|
||||
private int _constructionStep;
|
||||
private int _accepting = 1;
|
||||
|
||||
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);
|
||||
_constructionCheckpoint = constructionCheckpoint;
|
||||
|
||||
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,
|
||||
inventory.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.Spellbook,
|
||||
social.Chat,
|
||||
inventory.LocalPlayer,
|
||||
social.TurbineChat,
|
||||
onSkillsUpdated: character.OnSkillsUpdated,
|
||||
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
|
||||
onShortcuts: inventory.OnShortcuts,
|
||||
playerGuid: inventory.PlayerGuid,
|
||||
onUseDone: inventory.OnUseDone,
|
||||
itemMana: inventory.ItemMana,
|
||||
onConfirmationRequest: character.OnConfirmationRequest,
|
||||
onConfirmationDone: character.OnConfirmationDone,
|
||||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: inventory.OnDesiredComponents,
|
||||
onCharacterOptions: character.OnCharacterOptions,
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
accepting: IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_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 =>
|
||||
inventory.LocalPlayer.OnVitalUpdate(
|
||||
vital.VitalId,
|
||||
vital.Ranks,
|
||||
vital.Start,
|
||||
vital.Xp,
|
||||
vital.Current));
|
||||
Subscribe<PrivateUpdateVital.ParsedCurrent>(
|
||||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||
}
|
||||
catch (Exception constructionError)
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
try
|
||||
{
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"live-session event routing failed and cleanup also failed",
|
||||
constructionError,
|
||||
cleanupError);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Accepting => IsAccepting();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
_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);
|
||||
try
|
||||
{
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
}
|
||||
catch
|
||||
{
|
||||
detach(handler);
|
||||
throw;
|
||||
}
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
||||
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,
|
||||
TurbineChatRouting.DisplayName(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.LocalPlayer);
|
||||
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(character.Combat);
|
||||
ArgumentNullException.ThrowIfNull(character.Spellbook);
|
||||
ArgumentNullException.ThrowIfNull(social.Chat);
|
||||
ArgumentNullException.ThrowIfNull(social.TurbineChat);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||
{
|
||||
private List<IDisposable>? _subscriptions = [];
|
||||
|
||||
public void Add(IDisposable subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
List<IDisposable>? subscriptions = _subscriptions;
|
||||
if (subscriptions is null)
|
||||
{
|
||||
subscription.Dispose();
|
||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||
}
|
||||
|
||||
subscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
|
||||
if (subscriptions is null)
|
||||
return;
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int index = subscriptions.Count - 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 ActionSubscription(Action unsubscribe) : IDisposable
|
||||
{
|
||||
private Action? _unsubscribe = unsubscribe;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue