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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
private bool DevToolsEnabled => _options.DevTools;
|
private bool DevToolsEnabled => _options.DevTools;
|
||||||
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
|
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
|
||||||
|
|
||||||
// Phase I.3 — real ICommandBus for live sessions. Constructed when
|
// Slice 3: exact session-generation owners. The command router is itself
|
||||||
// the live session spins up (so SendChatCmd handlers can close over
|
// an ICommandBus and becomes inert before the displaced socket closes.
|
||||||
// _liveSession + Chat). Null when offline; PanelContext then falls
|
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
|
||||||
// back to NullCommandBus.Instance.
|
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
|
||||||
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
|
|
||||||
|
|
||||||
// Phase I.7 — bridges CombatState's typed events into ChatLog as
|
|
||||||
// retail-faithful "You hit ..." / "... evaded your attack." lines.
|
|
||||||
// Disposable; lives for the duration of the live session.
|
|
||||||
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
|
|
||||||
|
|
||||||
// Phase G.1-G.2 world lighting/time state.
|
// Phase G.1-G.2 world lighting/time state.
|
||||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||||
|
|
@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
||||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||||
retailChatVm,
|
retailChatVm,
|
||||||
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||||
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||||
radarSnapshotProvider.BuildSnapshot,
|
radarSnapshotProvider.BuildSnapshot,
|
||||||
|
|
@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||||||
if (_liveSession is null)
|
if (_liveSession is null)
|
||||||
{
|
{
|
||||||
_commandBus = null;
|
try
|
||||||
_combatChatTranslator?.Dispose();
|
{
|
||||||
_combatChatTranslator = null;
|
DisposeLiveSessionRouting();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_liveSessionController.Dispose();
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
|
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
|
||||||
{
|
{
|
||||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||||
_commandBus = null;
|
try
|
||||||
_combatChatTranslator?.Dispose();
|
{
|
||||||
_combatChatTranslator = null;
|
DisposeLiveSessionRouting();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_liveSessionController.Dispose();
|
_liveSessionController.Dispose();
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
_liveSession = null;
|
_liveSession = null;
|
||||||
|
}
|
||||||
ClearInboundEntityState();
|
ClearInboundEntityState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
Combat.Clear();
|
Combat.Clear();
|
||||||
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
|
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
|
||||||
|
_liveSessionCommands?.Activate();
|
||||||
|
|
||||||
_activeToonKey = chosen.Name;
|
_activeToonKey = chosen.Name;
|
||||||
_retailUiRuntime?.RestoreLayout();
|
_retailUiRuntime?.RestoreLayout();
|
||||||
|
|
@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"live: session failed: {ex.Message}");
|
Console.WriteLine($"live: session failed: {ex.Message}");
|
||||||
_commandBus = null;
|
try
|
||||||
_combatChatTranslator?.Dispose();
|
{
|
||||||
_combatChatTranslator = null;
|
DisposeLiveSessionRouting();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_liveSessionController?.Dispose();
|
_liveSessionController?.Dispose();
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
_liveSession = null;
|
_liveSession = null;
|
||||||
|
}
|
||||||
ClearInboundEntityState();
|
ClearInboundEntityState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
/// Composes the exact inbound and outbound owners for one live session.
|
||||||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
/// Registration completes before Connect; outbound commands remain inert
|
||||||
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
|
/// until EnterWorld succeeds.
|
||||||
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
|
|
||||||
/// is constructed and BEFORE any network I/O.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||||
{
|
{
|
||||||
_liveSession = session;
|
_liveSession = session;
|
||||||
// D.5.4: wire non-lifecycle object quality/property updates. The live
|
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
|
||||||
// handlers below apply Create/Delete to both projections only after
|
throw new InvalidOperationException("live-session routing is already bound");
|
||||||
// their canonical timestamp decision.
|
|
||||||
AcDream.Core.Net.ObjectTableWiring.Wire(
|
|
||||||
session, Objects, () => _playerServerGuid, LocalPlayer);
|
|
||||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
|
||||||
_liveSession.EntitySpawned += spawn =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
spawn,
|
|
||||||
static (window, value) => window.OnLiveEntitySpawned(value));
|
|
||||||
_liveSession.EntityDeleted += deletion =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
deletion,
|
|
||||||
static (window, value) => window.OnLiveEntityDeleted(value));
|
|
||||||
_liveSession.EntityPickedUp += pickup =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
pickup,
|
|
||||||
static (window, value) => window.OnLiveEntityPickedUp(value));
|
|
||||||
_liveSession.MotionUpdated += motion =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
motion,
|
|
||||||
static (window, value) => window.OnLiveMotionUpdated(value));
|
|
||||||
_liveSession.PositionUpdated += position =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
position,
|
|
||||||
static (window, value) => window.OnLivePositionUpdated(value));
|
|
||||||
_liveSession.VectorUpdated += vector =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
vector,
|
|
||||||
static (window, value) => window.OnLiveVectorUpdated(value));
|
|
||||||
_liveSession.StateUpdated += state =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
state,
|
|
||||||
static (window, value) => window.OnLiveStateUpdated(value));
|
|
||||||
_liveSession.ParentUpdated += parent =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
parent,
|
|
||||||
static (window, value) => window.OnLiveParentUpdated(value));
|
|
||||||
_liveSession.TeleportStarted += teleport =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
teleport,
|
|
||||||
static (window, value) => window.OnTeleportStarted(value));
|
|
||||||
_liveSession.AppearanceUpdated += appearance =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
appearance,
|
|
||||||
static (window, value) => window.OnLiveAppearanceUpdated(value));
|
|
||||||
|
|
||||||
// Phase 6c — PlayScript (0xF754) arrives from the server as
|
|
||||||
// a (guid, scriptId) pair. Resolve the guid's current world
|
|
||||||
// position and feed the PhysicsScript runner; it schedules
|
|
||||||
// the script's hooks (particle spawns, sound cues, light
|
|
||||||
// toggles) at their StartTime offsets. This is the channel
|
|
||||||
// retail uses for spell casts, combat flinches, emote
|
|
||||||
// gestures, AND — per Agent #5 research — lightning
|
|
||||||
// flashes during stormy weather.
|
|
||||||
_liveSession.PlayPhysicsScriptReceived += script =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
script,
|
|
||||||
static (window, value) => window.OnPlayScriptReceived(value));
|
|
||||||
_liveSession.PlayPhysicsScriptTypeReceived += message =>
|
|
||||||
_inboundEntityEvents.Run(
|
|
||||||
this,
|
|
||||||
message,
|
|
||||||
static (window, value) => window._entityEffects?.HandleTyped(value));
|
|
||||||
|
|
||||||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
|
||||||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
|
||||||
// sound types (0x65..0x7B) play a one-shot audio cue.
|
|
||||||
// Lightning flashes arrive as a PAIRED PlayScript (the
|
|
||||||
// visual) + AdminEnvirons ThunderXSound (the audio) — both
|
|
||||||
// are handled here and in OnPlayScriptReceived respectively.
|
|
||||||
_liveSession.EnvironChanged += OnEnvironChanged;
|
|
||||||
|
|
||||||
// Phase G.1: keep the client's day/night clock in sync with
|
|
||||||
// server time. Fires once from ConnectRequest (initial seed)
|
|
||||||
// and repeatedly on TimeSync-flagged packets.
|
|
||||||
// Phase 3a: also re-roll the active DayGroup if the Dereth-day
|
|
||||||
// index changed — retail rolls one weather preset per server
|
|
||||||
// day (r12 §11), deterministic from the day index so retail
|
|
||||||
// and acdream converge without a wire message.
|
|
||||||
_liveSession.ServerTimeUpdated += ticks =>
|
|
||||||
{
|
|
||||||
WorldTime.SyncFromServer(ticks);
|
|
||||||
RefreshSkyForCurrentDay();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase F.1-H.1: wire every parsed GameEvent into the right
|
|
||||||
// Core state class (chat, combat, spellbook, items). After
|
|
||||||
// this one call, server-sent ChannelBroadcast / damage
|
|
||||||
// notifications / spell learns / wield events all update
|
|
||||||
// the corresponding client-side state without further glue.
|
|
||||||
// K-fix13 (2026-04-26): cache portal.dat's SkillTable so the
|
|
||||||
// skill-formula resolver can apply the AttributeFormula
|
|
||||||
// contribution. Without this, our wire-derived "skill"
|
|
||||||
// value was missing the dominant attribute-derived term
|
|
||||||
// and jumps undershot retail by ~30 % at typical
|
|
||||||
// attribute levels.
|
|
||||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||||
if (_characterSheetProvider is not null && _dats is not null)
|
if (_characterSheetProvider is not null && _dats is not null)
|
||||||
{
|
{
|
||||||
_characterSheetProvider.SkillTable = skillTable;
|
_characterSheetProvider.SkillTable = skillTable;
|
||||||
_characterSheetProvider.ExperienceTable =
|
_characterSheetProvider.ExperienceTable =
|
||||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||||
_dats, Console.WriteLine);
|
_dats,
|
||||||
|
Console.WriteLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
||||||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
||||||
TurbineChat,
|
try
|
||||||
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
|
|
||||||
{
|
{
|
||||||
// ACE GetFormula (AttributeFormula.cs:55-): when
|
events = new AcDream.App.Net.LiveSessionEventRouter(
|
||||||
// formula.X (Attribute1Multiplier) is 0, the formula
|
session,
|
||||||
// is "no attribute contribution" and the function
|
CreateLiveEntitySessionSink(),
|
||||||
// returns 0. Otherwise:
|
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||||
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
|
OnEnvironChanged,
|
||||||
if (skillTable?.Skills is null) return 0u;
|
ticks =>
|
||||||
if (!skillTable.Skills.TryGetValue(
|
|
||||||
(DatReaderWriter.Enums.SkillId)skillId, out var skillBase))
|
|
||||||
return 0u;
|
|
||||||
var f = skillBase.Formula;
|
|
||||||
if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u;
|
|
||||||
attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1);
|
|
||||||
attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2);
|
|
||||||
long num = (long)a1 * f.Attribute1Multiplier
|
|
||||||
+ (long)a2 * f.Attribute2Multiplier;
|
|
||||||
long bonus = num / f.Divisor + f.AdditiveBonus;
|
|
||||||
return bonus < 0 ? 0u : (uint)bonus;
|
|
||||||
},
|
|
||||||
onSkillsUpdated: (runSkill, jumpSkill) =>
|
|
||||||
{
|
{
|
||||||
// K-fix7 (2026-04-26): cache the latest server-sent
|
WorldTime.SyncFromServer(ticks);
|
||||||
// Run / Jump skill values so the next
|
RefreshSkyForCurrentDay();
|
||||||
// EnterPlayerModeNow can hand them to the new
|
}),
|
||||||
// PlayerMovementController. Push immediately too,
|
CreateLiveInventorySessionBindings(),
|
||||||
// so a PD that arrives WHILE player mode is active
|
CreateLiveCharacterSessionBindings(skillTable),
|
||||||
// (re-equip / log-in mid-session) updates the live
|
CreateLiveSocialSessionBindings());
|
||||||
// controller. -1 from the wiring means "skill not
|
|
||||||
// present in this PD" — keep the previous cached
|
commands = new AcDream.App.Net.LiveSessionCommandRouter(
|
||||||
// value rather than overwriting with -1.
|
CreateLiveSessionCommandBindings(session));
|
||||||
if (runSkill >= 0) _lastSeenRunSkill = runSkill;
|
_liveSessionEvents = events;
|
||||||
if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill;
|
_liveSessionCommands = commands;
|
||||||
if (_playerController is not null
|
|
||||||
&& _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
|
|
||||||
{
|
|
||||||
_playerController.SetCharacterSkills(
|
|
||||||
_lastSeenRunSkill, _lastSeenJumpSkill);
|
|
||||||
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
|
|
||||||
}
|
}
|
||||||
},
|
catch
|
||||||
onShortcuts: list => Shortcuts = list,
|
{
|
||||||
playerGuid: () => _playerServerGuid,
|
commands?.Dispose();
|
||||||
onUseDone: error =>
|
events?.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
|
||||||
|
Spawned: spawn => _inboundEntityEvents.Run(
|
||||||
|
this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)),
|
||||||
|
Deleted: deletion => _inboundEntityEvents.Run(
|
||||||
|
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
|
||||||
|
PickedUp: pickup => _inboundEntityEvents.Run(
|
||||||
|
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
|
||||||
|
MotionUpdated: motion => _inboundEntityEvents.Run(
|
||||||
|
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
|
||||||
|
PositionUpdated: position => _inboundEntityEvents.Run(
|
||||||
|
this, position, static (window, value) => window.OnLivePositionUpdated(value)),
|
||||||
|
VectorUpdated: vector => _inboundEntityEvents.Run(
|
||||||
|
this, vector, static (window, value) => window.OnLiveVectorUpdated(value)),
|
||||||
|
StateUpdated: state => _inboundEntityEvents.Run(
|
||||||
|
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
|
||||||
|
ParentUpdated: parent => _inboundEntityEvents.Run(
|
||||||
|
this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
|
||||||
|
TeleportStarted: teleport => _inboundEntityEvents.Run(
|
||||||
|
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
|
||||||
|
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
|
||||||
|
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
|
||||||
|
PlayPhysicsScript: script => _inboundEntityEvents.Run(
|
||||||
|
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
|
||||||
|
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
|
||||||
|
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
|
||||||
|
|
||||||
|
private AcDream.App.Net.LiveInventorySessionBindings
|
||||||
|
CreateLiveInventorySessionBindings() => new(
|
||||||
|
Objects,
|
||||||
|
LocalPlayer,
|
||||||
|
PlayerGuid: () => _playerServerGuid,
|
||||||
|
OnShortcuts: list => Shortcuts = list,
|
||||||
|
OnUseDone: error =>
|
||||||
{
|
{
|
||||||
_externalContainers.ApplyUseDone(error);
|
_externalContainers.ApplyUseDone(error);
|
||||||
_itemInteractionController?.CompleteUse(error);
|
_itemInteractionController?.CompleteUse(error);
|
||||||
},
|
},
|
||||||
itemMana: ItemMana,
|
ItemMana,
|
||||||
onConfirmationRequest: request =>
|
OnDesiredComponents: components => DesiredComponents = components,
|
||||||
|
ExternalContainers: _externalContainers);
|
||||||
|
|
||||||
|
private AcDream.App.Net.LiveCharacterSessionBindings
|
||||||
|
CreateLiveCharacterSessionBindings(
|
||||||
|
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
|
||||||
|
Combat,
|
||||||
|
SpellBook,
|
||||||
|
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
|
||||||
|
{
|
||||||
|
// ACE AttributeFormula.GetFormula
|
||||||
|
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
|
||||||
|
// Attribute1Multiplier == 0 means no attribute contribution.
|
||||||
|
// Otherwise the retail data formula is
|
||||||
|
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
|
||||||
|
// Guard a zero divisor because malformed/custom DAT input must not
|
||||||
|
// take down the live-session dispatcher.
|
||||||
|
if (skillTable?.Skills is null)
|
||||||
|
return 0u;
|
||||||
|
if (!skillTable.Skills.TryGetValue(
|
||||||
|
(DatReaderWriter.Enums.SkillId)skillId,
|
||||||
|
out var skillBase))
|
||||||
|
return 0u;
|
||||||
|
|
||||||
|
var formula = skillBase.Formula;
|
||||||
|
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
|
||||||
|
return 0u;
|
||||||
|
|
||||||
|
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
|
||||||
|
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
|
||||||
|
long numerator =
|
||||||
|
(long)attribute1 * formula.Attribute1Multiplier +
|
||||||
|
(long)attribute2 * formula.Attribute2Multiplier;
|
||||||
|
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
|
||||||
|
return bonus < 0 ? 0u : (uint)bonus;
|
||||||
|
},
|
||||||
|
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||||
|
{
|
||||||
|
if (runSkill >= 0)
|
||||||
|
_lastSeenRunSkill = runSkill;
|
||||||
|
if (jumpSkill >= 0)
|
||||||
|
_lastSeenJumpSkill = jumpSkill;
|
||||||
|
if (_playerController is not null
|
||||||
|
&& _lastSeenRunSkill >= 0
|
||||||
|
&& _lastSeenJumpSkill >= 0)
|
||||||
|
{
|
||||||
|
_playerController.SetCharacterSkills(
|
||||||
|
_lastSeenRunSkill,
|
||||||
|
_lastSeenJumpSkill);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"player: applied server skills run={_lastSeenRunSkill} " +
|
||||||
|
$"jump={_lastSeenJumpSkill}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OnConfirmationRequest: request =>
|
||||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||||
onConfirmationDone: done =>
|
OnConfirmationDone: done =>
|
||||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||||
friends: Friends,
|
OnCharacterOptions: (options1, _) =>
|
||||||
squelch: Squelch,
|
|
||||||
onDesiredComponents: components => DesiredComponents = components,
|
|
||||||
onCharacterOptions: (options1, _) =>
|
|
||||||
_characterOptions1 =
|
_characterOptions1 =
|
||||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||||
options1,
|
options1,
|
||||||
clientTime: ClientTimerNow,
|
ClientTime: ClientTimerNow);
|
||||||
externalContainers: _externalContainers);
|
|
||||||
|
|
||||||
// Phase I.7: subscribe to CombatState events and emit
|
private AcDream.App.Net.LiveSocialSessionBindings
|
||||||
// retail-faithful "You hit X for Y damage" chat lines into
|
CreateLiveSocialSessionBindings() => new(
|
||||||
// the unified ChatLog. The translator owns the wording
|
Chat,
|
||||||
// (templates ported from holtburger chat.rs:221-308); the
|
TurbineChat,
|
||||||
// panel renders combat entries via TextColored.
|
Friends,
|
||||||
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
|
Squelch);
|
||||||
|
|
||||||
// Phase H.1: feed inbound HearSpeech into the chat log.
|
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
|
||||||
_liveSession.SpeechHeard += speech =>
|
AcDream.Core.Net.WorldSession session) => new(
|
||||||
Chat.OnLocalSpeech(
|
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
|
||||||
sender: speech.SenderName,
|
TeleportToLifestone: session.SendTeleportToLifestone,
|
||||||
text: speech.Text,
|
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
||||||
senderGuid: speech.SenderGuid,
|
TeleportToPkArena: session.SendTeleportToPkArena,
|
||||||
isRanged: speech.IsRanged);
|
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
||||||
|
TeleportToHouse: session.SendTeleportToHouse,
|
||||||
// Phase I.6: feed inbound TurbineChat events into the chat log.
|
TeleportToMansion: session.SendTeleportToMansion,
|
||||||
// The Response variant is fire-and-forget (server-side ack);
|
QueryAge: session.SendQueryAge,
|
||||||
// EventSendToRoom is a real chat message broadcast to a room.
|
QueryBirth: session.SendQueryBirth,
|
||||||
// Phase J: ACE's GameMessageSystemChat (used for the login
|
|
||||||
// banner "Welcome to Asheron's Call ... type @acehelp" and
|
|
||||||
// for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage,
|
|
||||||
// parsed in I.5 but never wired. Surface it as a System
|
|
||||||
// chat line so the welcome banner appears + future server
|
|
||||||
// pushes (announcements, command responses) show.
|
|
||||||
_liveSession.ServerMessageReceived += sm =>
|
|
||||||
Chat.OnSystemMessage(sm.Message, sm.ChatType);
|
|
||||||
|
|
||||||
// Phase I.5 + J: emotes already had ChatLog adapters; wire
|
|
||||||
// their session events here so they actually reach chat.
|
|
||||||
_liveSession.EmoteHeard += emote =>
|
|
||||||
Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
|
||||||
_liveSession.SoulEmoteHeard += emote =>
|
|
||||||
Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
|
||||||
_liveSession.PlayerKilledReceived += pk =>
|
|
||||||
Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid);
|
|
||||||
|
|
||||||
_liveSession.TurbineChatReceived += parsed =>
|
|
||||||
{
|
|
||||||
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
|
|
||||||
{
|
|
||||||
// Pass the friendly channel name out-of-band via
|
|
||||||
// ChatLog.OnChannelBroadcast's channelName param so
|
|
||||||
// ChatVM.FormatEntry can render the retail-style
|
|
||||||
// "[Trade] +Acdream says, \"hello\"" without us
|
|
||||||
// mangling the payload text.
|
|
||||||
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
|
|
||||||
Chat.OnChannelBroadcast(
|
|
||||||
channelId: ev.RoomId,
|
|
||||||
sender: ev.SenderName,
|
|
||||||
text: ev.Message,
|
|
||||||
channelName: label);
|
|
||||||
}
|
|
||||||
// Response (server ack of an outbound RequestSendToRoomById)
|
|
||||||
// and Unknown payloads are intentionally not surfaced —
|
|
||||||
// the inbound EventSendToRoom for our own message acts as
|
|
||||||
// the canonical echo.
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
|
|
||||||
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
|
||||||
// plus a local echo into ChatLog so the player sees their own
|
|
||||||
// message immediately. Closes over _liveSession + Chat so this
|
|
||||||
// wiring only exists for the lifetime of the live session.
|
|
||||||
// Step 2: capture the non-null `session` parameter rather than
|
|
||||||
// the nullable _liveSession field. Semantically identical (the
|
|
||||||
// field WAS set to `session` at the top of WireLiveSessionEvents)
|
|
||||||
// but the compiler can prove non-null for the lambda body.
|
|
||||||
var liveSession = session;
|
|
||||||
var chat = Chat;
|
|
||||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
|
||||||
var turbineChat = TurbineChat;
|
|
||||||
uint playerGuid = _playerServerGuid;
|
|
||||||
var clientCommandController = new AcDream.App.UI.ClientCommandController(
|
|
||||||
new AcDream.App.UI.ClientCommandController.Bindings(
|
|
||||||
TeleportToLifestone: liveSession.SendTeleportToLifestone,
|
|
||||||
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
|
|
||||||
TeleportToPkArena: liveSession.SendTeleportToPkArena,
|
|
||||||
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
|
|
||||||
TeleportToHouse: liveSession.SendTeleportToHouse,
|
|
||||||
TeleportToMansion: liveSession.SendTeleportToMansion,
|
|
||||||
QueryAge: liveSession.SendQueryAge,
|
|
||||||
QueryBirth: liveSession.SendQueryBirth,
|
|
||||||
ToggleFrameRate: ToggleRetailFrameRate,
|
ToggleFrameRate: ToggleRetailFrameRate,
|
||||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||||
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
|
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
|
||||||
ShowWeenieError: code => chat.OnWeenieError(code, null),
|
ShowWeenieError: code => Chat.OnWeenieError(code, null),
|
||||||
PlayerPublicWeenieBitfield: () =>
|
PlayerPublicWeenieBitfield: () =>
|
||||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||||
ClientVersion: () =>
|
ClientVersion: () =>
|
||||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||||
?? "unknown",
|
?? "unknown",
|
||||||
CurrentPosition: () => _playerController?.CellPosition,
|
CurrentPosition: () => _playerController?.CellPosition,
|
||||||
LastOutsideCorpsePosition: () =>
|
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
|
||||||
LocalPlayer.GetPosition(0x0Eu),
|
|
||||||
ShowConfirmation: (message, completed) =>
|
ShowConfirmation: (message, completed) =>
|
||||||
_retailUiRuntime?.ShowConfirmation(message, completed),
|
_retailUiRuntime?.ShowConfirmation(message, completed),
|
||||||
Suicide: liveSession.SendSuicide,
|
Suicide: session.SendSuicide,
|
||||||
ClearChat: _ => chat.Clear(),
|
ClearChat: _ => Chat.Clear(),
|
||||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||||
IsAway: () =>
|
IsAway: () =>
|
||||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||||
SetAway: away => SetRetailAway(liveSession, away),
|
SetAway: away => SetRetailAway(session, away),
|
||||||
SetAwayMessage: liveSession.SendSetAfkMessage,
|
SetAwayMessage: session.SendSetAfkMessage,
|
||||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||||
DisplayConsent: liveSession.SendDisplayConsent,
|
DisplayConsent: session.SendDisplayConsent,
|
||||||
ClearConsent: liveSession.SendClearConsent,
|
ClearConsent: session.SendClearConsent,
|
||||||
RemoveConsent: liveSession.SendRemoveConsent,
|
RemoveConsent: session.SendRemoveConsent,
|
||||||
SendEmote: liveSession.SendEmote,
|
SendEmote: session.SendEmote,
|
||||||
Friends: Friends,
|
Friends,
|
||||||
AddFriend: liveSession.SendAddFriend,
|
AddFriend: session.SendAddFriend,
|
||||||
RemoveFriend: liveSession.SendRemoveFriend,
|
RemoveFriend: session.SendRemoveFriend,
|
||||||
ClearFriends: liveSession.SendClearFriends,
|
ClearFriends: session.SendClearFriends,
|
||||||
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
||||||
Squelch: Squelch,
|
Squelch,
|
||||||
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
||||||
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
||||||
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
||||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||||
ClearDesiredComponents: () =>
|
ClearDesiredComponents: () =>
|
||||||
{
|
{
|
||||||
liveSession.SendClearDesiredComponents();
|
session.SendClearDesiredComponents();
|
||||||
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
|
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
|
||||||
},
|
},
|
||||||
HasOpenVendor: () => false,
|
HasOpenVendor: () => false,
|
||||||
FillComponentBuyList: (_, _) => { }));
|
FillComponentBuyList: (_, _) => { }),
|
||||||
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
Chat,
|
||||||
clientCommandController.Execute);
|
TurbineChat,
|
||||||
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
PlayerGuid: () => _playerServerGuid,
|
||||||
|
SendTalk: session.SendTalk,
|
||||||
|
SendTell: session.SendTell,
|
||||||
|
SendChannel: session.SendChannel,
|
||||||
|
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
|
||||||
|
session.SendTurbineChatTo(
|
||||||
|
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||||
|
Log: Console.WriteLine);
|
||||||
|
|
||||||
|
private void DisposeLiveSessionRouting()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(cmd.Text))
|
AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
|
||||||
liveSession.SendTalk(cmd.Text);
|
AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
|
||||||
});
|
_liveSessionCommands = null;
|
||||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
_liveSessionEvents = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
commands?.Dispose();
|
||||||
switch (cmd.Channel)
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
case AcDream.UI.Abstractions.ChatChannelKind.Say:
|
events?.Dispose();
|
||||||
// Phase J: drop optimistic /say echo. ACE's
|
}
|
||||||
// HandleActionTalk broadcasts a HearSpeech back
|
|
||||||
// to the sender too, and ChatLog.OnLocalSpeech
|
|
||||||
// detects own-guid match to render it as
|
|
||||||
// "You say, ...". Optimistic-echoing here
|
|
||||||
// doubled the line. ALSO: don't echo "@xxx"
|
|
||||||
// server-side admin commands — ACE consumes
|
|
||||||
// them silently and replies via SystemChat.
|
|
||||||
liveSession.SendTalk(cmd.Text);
|
|
||||||
break;
|
|
||||||
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
|
|
||||||
if (string.IsNullOrEmpty(cmd.TargetName)) return;
|
|
||||||
liveSession.SendTell(cmd.TargetName, cmd.Text);
|
|
||||||
chat.OnSelfSent(
|
|
||||||
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
|
|
||||||
targetOrChannel: cmd.TargetName);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Phase I.6: try TurbineChat first for the global
|
|
||||||
// community channels (General/Trade/LFG/Roleplay/
|
|
||||||
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
|
|
||||||
// Allegiance is double-routed: try TurbineChat first
|
|
||||||
// (when the player has a Turbine allegiance room) and
|
|
||||||
// fall back to the legacy 0x0147 ChatChannel.
|
|
||||||
//
|
|
||||||
// We do NOT optimistic-echo channels: ACE's
|
|
||||||
// TurbineChatHandler broadcasts EventSendToRoom back
|
|
||||||
// to the sender too, so we always get the canonical
|
|
||||||
// echo from the server. Optimistic-echoing here
|
|
||||||
// double-prints the message (one as "[Trade] hello"
|
|
||||||
// from us, one as "[Trade] +Acdream says, \"hello\""
|
|
||||||
// from the server). Trust the server.
|
|
||||||
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
|
|
||||||
if (turbine is not null)
|
|
||||||
{
|
|
||||||
uint cookie = turbineChat.NextContextId();
|
|
||||||
// Use the live player guid if it's been captured;
|
|
||||||
// otherwise 0 (server treats unknown sender_id
|
|
||||||
// gracefully — the cookie is what we care about).
|
|
||||||
uint senderGuid = _playerServerGuid != 0u
|
|
||||||
? _playerServerGuid
|
|
||||||
: playerGuid;
|
|
||||||
Console.WriteLine(
|
|
||||||
$"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={cmd.Text.Length}");
|
|
||||||
liveSession.SendTurbineChatTo(
|
|
||||||
roomId: turbine.Value.RoomId,
|
|
||||||
chatType: turbine.Value.ChatType,
|
|
||||||
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
|
|
||||||
senderGuid: senderGuid,
|
|
||||||
text: cmd.Text,
|
|
||||||
cookie: cookie);
|
|
||||||
// No optimistic echo: server EventSendToRoom
|
|
||||||
// broadcast comes back with sender="+Acdream"
|
|
||||||
// and is rendered by ChatVM as
|
|
||||||
// "[Trade] +Acdream says, \"hello\"".
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
|
|
||||||
if (resolved is null)
|
|
||||||
{
|
|
||||||
// Diagnostic: the user picked a channel kind that
|
|
||||||
// (a) isn't a Turbine channel TurbineChatState
|
|
||||||
// knows about and (b) has no legacy ChatChannel
|
|
||||||
// mapping. Most common cause: TurbineChat hasn't
|
|
||||||
// been enabled yet (server didn't send 0x0295)
|
|
||||||
// and the kind is General/Trade/LFG/etc.
|
|
||||||
Console.WriteLine(
|
|
||||||
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
|
|
||||||
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Console.WriteLine(
|
|
||||||
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
|
|
||||||
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
|
|
||||||
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
|
||||||
// Legacy channels (Fellowship / Allegiance / Patron /
|
|
||||||
// Monarch / Vassals / CoVassals) — keep the optimistic
|
|
||||||
// echo because legacy ChatChannel does NOT always
|
|
||||||
// broadcast back to the sender. ChannelName is the
|
|
||||||
// friendly display name so ChatVM renders it as
|
|
||||||
// "[Fellowship] +Acdream says, \"hello\"".
|
|
||||||
chat.OnSelfSent(
|
|
||||||
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
|
|
||||||
targetOrChannel: resolved.Value.DisplayName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
|
|
||||||
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
|
|
||||||
_liveSession.VitalUpdated += v =>
|
|
||||||
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
|
|
||||||
_liveSession.VitalCurrentUpdated += v =>
|
|
||||||
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
|
|
||||||
/// mesh refs and register it in IGameState. Called from WorldSession events
|
|
||||||
/// on the main thread (Tick runs in the Silk.NET Update callback).
|
|
||||||
/// </summary>
|
|
||||||
private enum LiveProjectionPurpose
|
private enum LiveProjectionPurpose
|
||||||
{
|
{
|
||||||
LogicalRegistration,
|
LogicalRegistration,
|
||||||
|
|
@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
// is up so panel-emitted SendChatCmd actually flows server-ward.
|
// is up so panel-emitted SendChatCmd actually flows server-ward.
|
||||||
// Fall back to NullCommandBus for offline / pre-connect renders.
|
// Fall back to NullCommandBus for offline / pre-connect renders.
|
||||||
AcDream.UI.Abstractions.ICommandBus bus =
|
AcDream.UI.Abstractions.ICommandBus bus =
|
||||||
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||||
AcDream.UI.Abstractions.NullCommandBus.Instance;
|
AcDream.UI.Abstractions.NullCommandBus.Instance;
|
||||||
var ctx = new AcDream.UI.Abstractions.PanelContext(
|
var ctx = new AcDream.UI.Abstractions.PanelContext(
|
||||||
(float)deltaSeconds,
|
(float)deltaSeconds,
|
||||||
|
|
@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_magicCatalog = null;
|
_magicCatalog = null;
|
||||||
}),
|
}),
|
||||||
new("streamer", () => _streamer?.Dispose()),
|
new("streamer", () => _streamer?.Dispose()),
|
||||||
new("combat chat", () => _combatChatTranslator?.Dispose()),
|
new("live session routing", DisposeLiveSessionRouting),
|
||||||
new("equipped children", () => _equippedChildRenderer?.Dispose()),
|
new("equipped children", () => _equippedChildRenderer?.Dispose()),
|
||||||
new("live session", () =>
|
new("live session", () =>
|
||||||
{
|
{
|
||||||
|
|
@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_window = null;
|
_window = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
|
|
||||||
/// to a runtime Turbine room. Returned by
|
|
||||||
/// <see cref="ResolveTurbineForKind"/> when the player has access
|
|
||||||
/// to that Turbine channel; null otherwise.
|
|
||||||
/// </summary>
|
|
||||||
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
|
|
||||||
/// runtime Turbine room id + chat-type. Returns null when
|
|
||||||
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
|
|
||||||
/// or the channel has no assigned room (e.g. player not in a society).
|
|
||||||
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
|
|
||||||
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
|
|
||||||
/// </summary>
|
|
||||||
private static TurbineResolution? ResolveTurbineForKind(
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind kind,
|
|
||||||
AcDream.Core.Chat.TurbineChatState state)
|
|
||||||
{
|
|
||||||
if (!state.Enabled) return null;
|
|
||||||
|
|
||||||
var (room, chatType, name) = kind switch
|
|
||||||
{
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
|
|
||||||
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.General =>
|
|
||||||
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
|
|
||||||
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
|
|
||||||
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
|
|
||||||
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Society =>
|
|
||||||
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
|
|
||||||
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
|
|
||||||
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
|
|
||||||
_ => (0u, 0u, string.Empty),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (room == 0u) return null;
|
|
||||||
return new TurbineResolution(room, chatType, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pick a human-readable label for a Turbine room broadcast. Uses
|
|
||||||
/// the chat-type when known (semantic name), falls back to the
|
|
||||||
/// numeric room id for unknown rooms.
|
|
||||||
/// </summary>
|
|
||||||
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
|
|
||||||
{
|
|
||||||
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
|
|
||||||
{
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
|
||||||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
|
|
||||||
_ => $"Room 0x{roomId:X8}",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
|
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
|
||||||
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
|
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,19 @@ public static class CombatStateWiring
|
||||||
{
|
{
|
||||||
public const uint CombatModePropertyId = 40u;
|
public const uint CombatModePropertyId = 40u;
|
||||||
|
|
||||||
public static IDisposable Wire(WorldSession session, CombatState combat)
|
public static IDisposable Wire(
|
||||||
|
WorldSession session,
|
||||||
|
CombatState combat,
|
||||||
|
Func<bool>? accepting = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(session);
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
ArgumentNullException.ThrowIfNull(combat);
|
ArgumentNullException.ThrowIfNull(combat);
|
||||||
|
|
||||||
Action<WorldSession.PlayerIntPropertyUpdate> handler = update =>
|
Action<WorldSession.PlayerIntPropertyUpdate> handler = update =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
ApplyPlayerIntProperty(combat, update.Property, update.Value);
|
ApplyPlayerIntProperty(combat, update.Property, update.Value);
|
||||||
|
};
|
||||||
session.PlayerIntPropertyUpdated += handler;
|
session.PlayerIntPropertyUpdated += handler;
|
||||||
return new EventSubscription(session, handler);
|
return new EventSubscription(session, handler);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ public static class GameEventWiring
|
||||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
||||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||||
Func<double>? clientTime = null,
|
Func<double>? clientTime = null,
|
||||||
ExternalContainerState? externalContainers = null)
|
ExternalContainerState? externalContainers = null,
|
||||||
|
Func<bool>? accepting = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
@ -89,7 +90,7 @@ public static class GameEventWiring
|
||||||
ArgumentNullException.ThrowIfNull(spellbook);
|
ArgumentNullException.ThrowIfNull(spellbook);
|
||||||
ArgumentNullException.ThrowIfNull(chat);
|
ArgumentNullException.ThrowIfNull(chat);
|
||||||
clientTime ??= static () => 0d;
|
clientTime ??= static () => 0d;
|
||||||
var registrar = new OwnedGameEventRegistrar(dispatcher);
|
var registrar = new OwnedGameEventRegistrar(dispatcher, accepting);
|
||||||
using var construction = new RegistrationBuildScope(registrar);
|
using var construction = new RegistrationBuildScope(registrar);
|
||||||
|
|
||||||
// ── Chat ──────────────────────────────────────────────────
|
// ── Chat ──────────────────────────────────────────────────
|
||||||
|
|
@ -659,14 +660,23 @@ public static class GameEventWiring
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class OwnedGameEventRegistrar(
|
private sealed class OwnedGameEventRegistrar(
|
||||||
GameEventDispatcher dispatcher) : IDisposable
|
GameEventDispatcher dispatcher,
|
||||||
|
Func<bool>? accepting) : IDisposable
|
||||||
{
|
{
|
||||||
private readonly SubscriptionSet _subscriptions = new();
|
private readonly SubscriptionSet _subscriptions = new();
|
||||||
|
|
||||||
public void Register(
|
public void Register(
|
||||||
GameEventType type,
|
GameEventType type,
|
||||||
GameEventDispatcher.EventHandler handler) =>
|
GameEventDispatcher.EventHandler handler)
|
||||||
_subscriptions.Add(dispatcher.RegisterOwned(type, handler));
|
{
|
||||||
|
GameEventDispatcher.EventHandler registered = accepting is null
|
||||||
|
? handler
|
||||||
|
: envelope =>
|
||||||
|
{
|
||||||
|
if (accepting()) handler(envelope);
|
||||||
|
};
|
||||||
|
_subscriptions.Add(dispatcher.RegisterOwned(type, registered));
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => _subscriptions.Dispose();
|
public void Dispose() => _subscriptions.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ public static class ObjectTableWiring
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
ClientObjectTable table,
|
ClientObjectTable table,
|
||||||
Func<uint>? playerGuid = null,
|
Func<uint>? playerGuid = null,
|
||||||
LocalPlayerState? localPlayer = null)
|
LocalPlayerState? localPlayer = null,
|
||||||
|
Func<bool>? accepting = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(session);
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
ArgumentNullException.ThrowIfNull(table);
|
ArgumentNullException.ThrowIfNull(table);
|
||||||
|
|
@ -37,7 +38,10 @@ public static class ObjectTableWiring
|
||||||
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
||||||
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
|
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
|
||||||
Action<WorldSession.ObjectIntPropertyUpdate> objectIntUpdated = u =>
|
Action<WorldSession.ObjectIntPropertyUpdate> objectIntUpdated = u =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||||
|
};
|
||||||
session.ObjectIntPropertyUpdated += objectIntUpdated;
|
session.ObjectIntPropertyUpdated += objectIntUpdated;
|
||||||
subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated);
|
subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated);
|
||||||
|
|
||||||
|
|
@ -49,6 +53,7 @@ public static class ObjectTableWiring
|
||||||
// than creating a phantom — the next PD / CreateObject seeds it.
|
// than creating a phantom — the next PD / CreateObject seeds it.
|
||||||
Action<WorldSession.PlayerIntPropertyUpdate> playerIntUpdated = u =>
|
Action<WorldSession.PlayerIntPropertyUpdate> playerIntUpdated = u =>
|
||||||
{
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
if (playerGuid is not null)
|
if (playerGuid is not null)
|
||||||
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
|
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
|
||||||
};
|
};
|
||||||
|
|
@ -61,20 +66,30 @@ public static class ObjectTableWiring
|
||||||
// Apply the authoritative 0x02CF update to both in this one wiring owner so they
|
// Apply the authoritative 0x02CF update to both in this one wiring owner so they
|
||||||
// cannot drift after the login PlayerDescription snapshot.
|
// cannot drift after the login PlayerDescription snapshot.
|
||||||
Action<WorldSession.PlayerInt64PropertyUpdate> playerInt64Updated = u =>
|
Action<WorldSession.PlayerInt64PropertyUpdate> playerInt64Updated = u =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
ApplyPlayerInt64PropertyUpdate(
|
ApplyPlayerInt64PropertyUpdate(
|
||||||
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
||||||
|
};
|
||||||
session.PlayerInt64PropertyUpdated += playerInt64Updated;
|
session.PlayerInt64PropertyUpdated += playerInt64Updated;
|
||||||
subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated);
|
subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated);
|
||||||
|
|
||||||
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
||||||
Action<WorldSession.StackSizeUpdate> stackSizeUpdated = u =>
|
Action<WorldSession.StackSizeUpdate> stackSizeUpdated = u =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
||||||
|
};
|
||||||
session.StackSizeUpdated += stackSizeUpdated;
|
session.StackSizeUpdated += stackSizeUpdated;
|
||||||
subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated);
|
subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated);
|
||||||
|
|
||||||
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
|
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
|
||||||
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
|
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
|
||||||
Action<uint> inventoryObjectRemoved = guid => table.Remove(guid);
|
Action<uint> inventoryObjectRemoved = guid =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() == false) return;
|
||||||
|
table.Remove(guid);
|
||||||
|
};
|
||||||
session.InventoryObjectRemoved += inventoryObjectRemoved;
|
session.InventoryObjectRemoved += inventoryObjectRemoved;
|
||||||
subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved);
|
subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,34 @@ public sealed class CombatChatTranslator : IDisposable
|
||||||
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public CombatChatTranslator(CombatState combat, ChatLog chat)
|
public CombatChatTranslator(
|
||||||
|
CombatState combat,
|
||||||
|
ChatLog chat,
|
||||||
|
Func<bool>? accepting = null)
|
||||||
{
|
{
|
||||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||||
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||||
|
|
||||||
_onDealt = HandleDamageDealt;
|
_onDealt = value =>
|
||||||
_onTaken = HandleDamageTaken;
|
{
|
||||||
_onMissed = HandleMissedOutgoing;
|
if (accepting?.Invoke() != false) HandleDamageDealt(value);
|
||||||
_onEvaded = HandleEvadedIncoming;
|
};
|
||||||
_onKill = HandleKillLanded;
|
_onTaken = value =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() != false) HandleDamageTaken(value);
|
||||||
|
};
|
||||||
|
_onMissed = value =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() != false) HandleMissedOutgoing(value);
|
||||||
|
};
|
||||||
|
_onEvaded = value =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() != false) HandleEvadedIncoming(value);
|
||||||
|
};
|
||||||
|
_onKill = (name, guid) =>
|
||||||
|
{
|
||||||
|
if (accepting?.Invoke() != false) HandleKillLanded(name, guid);
|
||||||
|
};
|
||||||
|
|
||||||
_combat.DamageDealtAccepted += _onDealt;
|
_combat.DamageDealtAccepted += _onDealt;
|
||||||
_combat.DamageTaken += _onTaken;
|
_combat.DamageTaken += _onTaken;
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,11 @@ public sealed class LiveCommandBus : ICommandBus
|
||||||
$"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping.");
|
$"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Release every registered handler. Session-scoped owners call this
|
||||||
|
/// during teardown so a retained bus cannot keep an obsolete transport or
|
||||||
|
/// host object graph alive.
|
||||||
|
/// </summary>
|
||||||
|
public void Clear() => _handlers.Clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
301
tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs
Normal file
301
tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using AcDream.App.Net;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.Core.Social;
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
|
public sealed class LiveSessionCommandRouterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InactiveAndDisposedRouter_CannotReachTransport()
|
||||||
|
{
|
||||||
|
var sent = new List<string>();
|
||||||
|
var router = NewRouter(sendTalk: sent.Add);
|
||||||
|
|
||||||
|
router.Publish(new SendServerCommandCmd("@before"));
|
||||||
|
router.Activate();
|
||||||
|
router.Activate();
|
||||||
|
router.Publish(new SendServerCommandCmd("@active"));
|
||||||
|
router.Dispose();
|
||||||
|
router.Dispose();
|
||||||
|
router.Publish(new SendServerCommandCmd("@after"));
|
||||||
|
|
||||||
|
Assert.Equal(["@active"], sent);
|
||||||
|
Assert.False(router.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy()
|
||||||
|
{
|
||||||
|
var tells = new List<(string Target, string Text)>();
|
||||||
|
var channels = new List<(uint Id, string Text)>();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
var router = NewRouter(
|
||||||
|
chat: chat,
|
||||||
|
sendTell: (target, text) => tells.Add((target, text)),
|
||||||
|
sendChannel: (id, text) => channels.Add((id, text)));
|
||||||
|
router.Activate();
|
||||||
|
|
||||||
|
router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello"));
|
||||||
|
router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group"));
|
||||||
|
|
||||||
|
Assert.Equal([("Friend", "hello")], tells);
|
||||||
|
Assert.Equal([(0x00000800u, "group")], channels);
|
||||||
|
Assert.Collection(
|
||||||
|
chat.Snapshot(),
|
||||||
|
entry =>
|
||||||
|
{
|
||||||
|
Assert.Equal(ChatKind.Tell, entry.Kind);
|
||||||
|
Assert.Equal("Friend", entry.Sender);
|
||||||
|
},
|
||||||
|
entry =>
|
||||||
|
{
|
||||||
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
||||||
|
Assert.Equal("Fellowship", entry.ChannelName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho()
|
||||||
|
{
|
||||||
|
var turbine = new TurbineChatState();
|
||||||
|
turbine.OnChannelsReceived(
|
||||||
|
allegianceRoom: 0u,
|
||||||
|
generalRoom: 0x70000001u,
|
||||||
|
tradeRoom: 0u,
|
||||||
|
lfgRoom: 0u,
|
||||||
|
roleplayRoom: 0u,
|
||||||
|
olthoiRoom: 0u,
|
||||||
|
societyRoom: 0u,
|
||||||
|
societyCelestialHandRoom: 0u,
|
||||||
|
societyEldrytchWebRoom: 0u,
|
||||||
|
societyRadiantBloodRoom: 0u);
|
||||||
|
var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
var router = NewRouter(
|
||||||
|
chat: chat,
|
||||||
|
turbine: turbine,
|
||||||
|
playerGuid: () => 0x50000001u,
|
||||||
|
sendTurbine: (room, type, dispatch, sender, text, cookie) =>
|
||||||
|
sent.Add((room, type, dispatch, sender, text, cookie)));
|
||||||
|
router.Activate();
|
||||||
|
|
||||||
|
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
|
||||||
|
|
||||||
|
Assert.Collection(sent, message =>
|
||||||
|
{
|
||||||
|
Assert.Equal(0x70000001u, message.Room);
|
||||||
|
Assert.Equal(0x02u, message.Type);
|
||||||
|
Assert.Equal(0x02u, message.Dispatch);
|
||||||
|
Assert.Equal(0x50000001u, message.Sender);
|
||||||
|
Assert.Equal("world", message.Text);
|
||||||
|
Assert.Equal(1u, message.Cookie);
|
||||||
|
});
|
||||||
|
Assert.Equal(0, chat.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActivateAfterDispose_IsRejected()
|
||||||
|
{
|
||||||
|
var router = NewRouter();
|
||||||
|
router.Dispose();
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(router.Activate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert()
|
||||||
|
{
|
||||||
|
using var sendEntered = new ManualResetEventSlim();
|
||||||
|
using var releaseSend = new ManualResetEventSlim();
|
||||||
|
var sent = new List<string>();
|
||||||
|
var router = NewRouter(sendTalk: text =>
|
||||||
|
{
|
||||||
|
sendEntered.Set();
|
||||||
|
releaseSend.Wait();
|
||||||
|
sent.Add(text);
|
||||||
|
});
|
||||||
|
router.Activate();
|
||||||
|
|
||||||
|
Task publish = Task.Run(() =>
|
||||||
|
router.Publish(new SendServerCommandCmd("@active")));
|
||||||
|
Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
using var disposeStarted = new ManualResetEventSlim();
|
||||||
|
Task dispose = Task.Run(() =>
|
||||||
|
{
|
||||||
|
disposeStarted.Set();
|
||||||
|
router.Dispose();
|
||||||
|
});
|
||||||
|
Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
Task firstCompletion = await Task.WhenAny(
|
||||||
|
dispose,
|
||||||
|
Task.Delay(TimeSpan.FromMilliseconds(100)));
|
||||||
|
Assert.NotSame(dispose, firstCompletion);
|
||||||
|
|
||||||
|
releaseSend.Set();
|
||||||
|
await Task.WhenAll(publish, dispose);
|
||||||
|
router.Publish(new SendServerCommandCmd("@after"));
|
||||||
|
|
||||||
|
Assert.Equal(["@active"], sent);
|
||||||
|
Assert.False(router.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho()
|
||||||
|
{
|
||||||
|
var turbine = new TurbineChatState();
|
||||||
|
turbine.OnChannelsReceived(
|
||||||
|
0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u);
|
||||||
|
var sent = new List<string>();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
LiveSessionCommandRouter? router = null;
|
||||||
|
router = NewRouter(
|
||||||
|
chat: chat,
|
||||||
|
turbine: turbine,
|
||||||
|
sendTurbine: (_, _, _, _, text, _) => sent.Add(text),
|
||||||
|
log: _ => router!.Dispose());
|
||||||
|
router.Activate();
|
||||||
|
|
||||||
|
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
|
||||||
|
|
||||||
|
Assert.Empty(sent);
|
||||||
|
Assert.Equal(0, chat.Count);
|
||||||
|
Assert.False(router.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetainedDisposedRouter_ReleasesCapturedTransportGraph()
|
||||||
|
{
|
||||||
|
(LiveSessionCommandRouter router, WeakReference<object> transport) =
|
||||||
|
CreateRouterWithCapturedTransport();
|
||||||
|
|
||||||
|
router.Dispose();
|
||||||
|
ForceFullCollection();
|
||||||
|
|
||||||
|
Assert.False(transport.TryGetTarget(out _));
|
||||||
|
GC.KeepAlive(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert()
|
||||||
|
{
|
||||||
|
(LiveSessionCommandRouter router, WeakReference<object> transport, Action<bool> callback) =
|
||||||
|
CreateRouterWithDelayedConfirmation();
|
||||||
|
|
||||||
|
router.Dispose();
|
||||||
|
ForceFullCollection();
|
||||||
|
|
||||||
|
Assert.False(transport.TryGetTarget(out _));
|
||||||
|
callback(true);
|
||||||
|
Assert.False(router.IsActive);
|
||||||
|
GC.KeepAlive(callback);
|
||||||
|
GC.KeepAlive(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveSessionCommandRouter NewRouter(
|
||||||
|
ChatLog? chat = null,
|
||||||
|
TurbineChatState? turbine = null,
|
||||||
|
Func<uint>? playerGuid = null,
|
||||||
|
Action<string>? sendTalk = null,
|
||||||
|
Action<string, string>? sendTell = null,
|
||||||
|
Action<uint, string>? sendChannel = null,
|
||||||
|
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
|
||||||
|
Action<string>? log = null,
|
||||||
|
ClientCommandController.Bindings? clientBindings = null) => new(
|
||||||
|
new LiveSessionCommandBindings(
|
||||||
|
clientBindings ?? NewClientBindings(),
|
||||||
|
chat ?? new ChatLog(),
|
||||||
|
turbine ?? new TurbineChatState(),
|
||||||
|
playerGuid ?? (() => 0u),
|
||||||
|
sendTalk ?? (_ => { }),
|
||||||
|
sendTell ?? ((_, _) => { }),
|
||||||
|
sendChannel ?? ((_, _) => { }),
|
||||||
|
sendTurbine ?? ((_, _, _, _, _, _) => { }),
|
||||||
|
log));
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private static (LiveSessionCommandRouter, WeakReference<object>)
|
||||||
|
CreateRouterWithCapturedTransport()
|
||||||
|
{
|
||||||
|
object transport = new object();
|
||||||
|
var weak = new WeakReference<object>(transport);
|
||||||
|
var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport));
|
||||||
|
return (router, weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private static (LiveSessionCommandRouter, WeakReference<object>, Action<bool>)
|
||||||
|
CreateRouterWithDelayedConfirmation()
|
||||||
|
{
|
||||||
|
object transport = new object();
|
||||||
|
var weak = new WeakReference<object>(transport);
|
||||||
|
Action<bool>? callback = null;
|
||||||
|
ClientCommandController.Bindings bindings = NewClientBindings() with
|
||||||
|
{
|
||||||
|
ShowConfirmation = (_, completion) => callback = completion,
|
||||||
|
Suicide = () => GC.KeepAlive(transport),
|
||||||
|
};
|
||||||
|
var router = NewRouter(clientBindings: bindings);
|
||||||
|
router.Activate();
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty));
|
||||||
|
return (router, weak, Assert.IsType<Action<bool>>(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private static void ForceFullCollection()
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientCommandController.Bindings NewClientBindings() => new(
|
||||||
|
TeleportToLifestone: () => { },
|
||||||
|
TeleportToMarketplace: () => { },
|
||||||
|
TeleportToPkArena: () => { },
|
||||||
|
TeleportToPkLiteArena: () => { },
|
||||||
|
TeleportToHouse: () => { },
|
||||||
|
TeleportToMansion: () => { },
|
||||||
|
QueryAge: () => { },
|
||||||
|
QueryBirth: () => { },
|
||||||
|
ToggleFrameRate: () => { },
|
||||||
|
ToggleUiLock: () => { },
|
||||||
|
ShowSystemMessage: _ => { },
|
||||||
|
ShowWeenieError: _ => { },
|
||||||
|
PlayerPublicWeenieBitfield: () => null,
|
||||||
|
ClientVersion: () => "test",
|
||||||
|
CurrentPosition: () => null,
|
||||||
|
LastOutsideCorpsePosition: () => null,
|
||||||
|
ShowConfirmation: (_, _) => { },
|
||||||
|
Suicide: () => { },
|
||||||
|
ClearChat: _ => { },
|
||||||
|
SaveUi: _ => { },
|
||||||
|
LoadUi: _ => { },
|
||||||
|
SaveAutoUi: () => { },
|
||||||
|
LoadAutoUi: () => { },
|
||||||
|
IsAway: () => false,
|
||||||
|
SetAway: _ => { },
|
||||||
|
SetAwayMessage: _ => { },
|
||||||
|
AcceptLootPermits: () => false,
|
||||||
|
SetAcceptLootPermits: _ => { },
|
||||||
|
DisplayConsent: () => { },
|
||||||
|
ClearConsent: () => { },
|
||||||
|
RemoveConsent: _ => { },
|
||||||
|
SendEmote: _ => { },
|
||||||
|
Friends: new FriendsState(),
|
||||||
|
AddFriend: _ => { },
|
||||||
|
RemoveFriend: _ => { },
|
||||||
|
ClearFriends: () => { },
|
||||||
|
RequestLegacyFriends: () => { },
|
||||||
|
Squelch: new SquelchState(),
|
||||||
|
ModifyCharacterSquelch: (_, _, _, _) => { },
|
||||||
|
ModifyAccountSquelch: (_, _) => { },
|
||||||
|
ModifyGlobalSquelch: (_, _) => { },
|
||||||
|
LastTeller: () => null,
|
||||||
|
ClearDesiredComponents: () => { },
|
||||||
|
HasOpenVendor: () => false,
|
||||||
|
FillComponentBuyList: (_, _) => { });
|
||||||
|
}
|
||||||
291
tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs
Normal file
291
tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.App.Net;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
using AcDream.Core.Social;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
||||||
|
public sealed class LiveSessionEventRouterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
var counters = new Counters();
|
||||||
|
var combat = new CombatState();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
var router = NewRouter(session, counters, combat: combat, chat: chat);
|
||||||
|
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||||
|
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
||||||
|
Action<double> copiedTime = EventDelegate<Action<double>>(
|
||||||
|
session,
|
||||||
|
nameof(session.ServerTimeUpdated));
|
||||||
|
Action<uint> copiedTeleport = EventDelegate<Action<uint>>(
|
||||||
|
session,
|
||||||
|
nameof(session.TeleportStarted));
|
||||||
|
Action<WorldSession.PlayerIntPropertyUpdate> copiedPlayerInt =
|
||||||
|
EventDelegate<Action<WorldSession.PlayerIntPropertyUpdate>>(
|
||||||
|
session,
|
||||||
|
nameof(session.PlayerIntPropertyUpdated));
|
||||||
|
Action<CombatState.DamageDealt> copiedDamage =
|
||||||
|
EventDelegate<CombatState, Action<CombatState.DamageDealt>>(
|
||||||
|
combat,
|
||||||
|
nameof(combat.DamageDealtAccepted));
|
||||||
|
copiedTime(123d);
|
||||||
|
copiedTeleport(0x50000001u);
|
||||||
|
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
||||||
|
CombatStateWiring.CombatModePropertyId,
|
||||||
|
(int)CombatMode.Melee));
|
||||||
|
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f));
|
||||||
|
Assert.Equal(1, counters.ServerTime);
|
||||||
|
Assert.Equal(1, counters.Teleport);
|
||||||
|
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
||||||
|
Assert.Equal(1, chat.Count);
|
||||||
|
|
||||||
|
router.Dispose();
|
||||||
|
router.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
|
||||||
|
copiedTime(456d);
|
||||||
|
copiedTeleport(0x50000002u);
|
||||||
|
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
||||||
|
CombatStateWiring.CombatModePropertyId,
|
||||||
|
(int)CombatMode.Magic));
|
||||||
|
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f));
|
||||||
|
Assert.Equal(1, counters.ServerTime);
|
||||||
|
Assert.Equal(1, counters.Teleport);
|
||||||
|
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
||||||
|
Assert.Equal(1, chat.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
var first = new Counters();
|
||||||
|
var second = new Counters();
|
||||||
|
var routerA = NewRouter(session, first);
|
||||||
|
var routerB = NewRouter(session, second);
|
||||||
|
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 2);
|
||||||
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
||||||
|
Assert.Equal(1, first.ServerTime);
|
||||||
|
Assert.Equal(1, second.ServerTime);
|
||||||
|
|
||||||
|
routerA.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||||
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(2d);
|
||||||
|
Assert.Equal(1, first.ServerTime);
|
||||||
|
Assert.Equal(2, second.ServerTime);
|
||||||
|
|
||||||
|
routerB.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
var first = new Counters();
|
||||||
|
var second = new Counters();
|
||||||
|
var routerA = NewRouter(session, first);
|
||||||
|
var routerB = NewRouter(session, second);
|
||||||
|
|
||||||
|
routerB.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||||
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
||||||
|
Assert.Equal(1, first.ServerTime);
|
||||||
|
Assert.Equal(0, second.ServerTime);
|
||||||
|
|
||||||
|
routerA.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThrowingSink_DoesNotCorruptLaterTeardown()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
var router = NewRouter(
|
||||||
|
session,
|
||||||
|
new Counters { ThrowOnServerTime = true });
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
EventDelegate<Action<double>>(
|
||||||
|
session,
|
||||||
|
nameof(session.ServerTimeUpdated))(1d));
|
||||||
|
|
||||||
|
router.Dispose();
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConstructionFailure_UnwindsEveryPriorRegistration()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => NewRouter(
|
||||||
|
session,
|
||||||
|
new Counters(),
|
||||||
|
step =>
|
||||||
|
{
|
||||||
|
if (step == 20)
|
||||||
|
throw new InvalidOperationException("injected construction failure");
|
||||||
|
}));
|
||||||
|
|
||||||
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||||
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveSessionEventRouter NewRouter(
|
||||||
|
WorldSession session,
|
||||||
|
Counters counters,
|
||||||
|
Action<int>? constructionCheckpoint = null,
|
||||||
|
CombatState? combat = null,
|
||||||
|
ChatLog? chat = null) => new(
|
||||||
|
session,
|
||||||
|
new LiveEntitySessionSink(
|
||||||
|
Spawned: _ => { },
|
||||||
|
Deleted: _ => { },
|
||||||
|
PickedUp: _ => { },
|
||||||
|
MotionUpdated: _ => { },
|
||||||
|
PositionUpdated: _ => { },
|
||||||
|
VectorUpdated: _ => { },
|
||||||
|
StateUpdated: _ => { },
|
||||||
|
ParentUpdated: _ => { },
|
||||||
|
TeleportStarted: _ => counters.Teleport++,
|
||||||
|
AppearanceUpdated: _ => { },
|
||||||
|
PlayPhysicsScript: _ => { },
|
||||||
|
PlayPhysicsScriptType: _ => { }),
|
||||||
|
new LiveEnvironmentSessionSink(
|
||||||
|
EnvironChanged: _ => { },
|
||||||
|
ServerTimeUpdated: _ =>
|
||||||
|
{
|
||||||
|
if (counters.ThrowOnServerTime)
|
||||||
|
throw new InvalidOperationException("injected sink failure");
|
||||||
|
counters.ServerTime++;
|
||||||
|
}),
|
||||||
|
NewInventoryBindings(),
|
||||||
|
NewCharacterBindings(combat),
|
||||||
|
NewSocialBindings(chat),
|
||||||
|
constructionCheckpoint);
|
||||||
|
|
||||||
|
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
||||||
|
new ClientObjectTable(),
|
||||||
|
new LocalPlayerState(),
|
||||||
|
PlayerGuid: () => 0x50000001u,
|
||||||
|
OnShortcuts: null,
|
||||||
|
OnUseDone: null,
|
||||||
|
ItemMana: new ItemManaState(),
|
||||||
|
OnDesiredComponents: null,
|
||||||
|
ExternalContainers: new ExternalContainerState());
|
||||||
|
|
||||||
|
private static LiveCharacterSessionBindings NewCharacterBindings(
|
||||||
|
CombatState? combat = null) => new(
|
||||||
|
combat ?? new CombatState(),
|
||||||
|
new Spellbook(),
|
||||||
|
ResolveSkillFormulaBonus: null,
|
||||||
|
OnSkillsUpdated: null,
|
||||||
|
OnConfirmationRequest: null,
|
||||||
|
OnConfirmationDone: null,
|
||||||
|
OnCharacterOptions: null,
|
||||||
|
ClientTime: () => 0d);
|
||||||
|
|
||||||
|
private static LiveSocialSessionBindings NewSocialBindings(
|
||||||
|
ChatLog? chat = null) => new(
|
||||||
|
chat ?? new ChatLog(),
|
||||||
|
new TurbineChatState(),
|
||||||
|
new FriendsState(),
|
||||||
|
new SquelchState());
|
||||||
|
|
||||||
|
private static WorldSession NewSession() =>
|
||||||
|
new(new IPEndPoint(IPAddress.Loopback, 9));
|
||||||
|
|
||||||
|
private static void AssertSessionHandlerCounts(
|
||||||
|
WorldSession session,
|
||||||
|
int multiplier)
|
||||||
|
{
|
||||||
|
string[] directEvents =
|
||||||
|
[
|
||||||
|
nameof(session.EntitySpawned),
|
||||||
|
nameof(session.EntityDeleted),
|
||||||
|
nameof(session.EntityPickedUp),
|
||||||
|
nameof(session.MotionUpdated),
|
||||||
|
nameof(session.PositionUpdated),
|
||||||
|
nameof(session.VectorUpdated),
|
||||||
|
nameof(session.StateUpdated),
|
||||||
|
nameof(session.ParentUpdated),
|
||||||
|
nameof(session.TeleportStarted),
|
||||||
|
nameof(session.AppearanceUpdated),
|
||||||
|
nameof(session.PlayPhysicsScriptReceived),
|
||||||
|
nameof(session.PlayPhysicsScriptTypeReceived),
|
||||||
|
nameof(session.EnvironChanged),
|
||||||
|
nameof(session.ServerTimeUpdated),
|
||||||
|
nameof(session.SpeechHeard),
|
||||||
|
nameof(session.ServerMessageReceived),
|
||||||
|
nameof(session.EmoteHeard),
|
||||||
|
nameof(session.SoulEmoteHeard),
|
||||||
|
nameof(session.PlayerKilledReceived),
|
||||||
|
nameof(session.TurbineChatReceived),
|
||||||
|
nameof(session.VitalUpdated),
|
||||||
|
nameof(session.VitalCurrentUpdated),
|
||||||
|
];
|
||||||
|
foreach (string eventName in directEvents)
|
||||||
|
Assert.Equal(multiplier, HandlerCount(session, eventName));
|
||||||
|
|
||||||
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
|
||||||
|
Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||||
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
|
||||||
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated)));
|
||||||
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int HandlerCount(WorldSession session, string eventName) =>
|
||||||
|
(EventField(session, eventName).GetValue(session) as MulticastDelegate)?
|
||||||
|
.GetInvocationList()
|
||||||
|
.Length ?? 0;
|
||||||
|
|
||||||
|
private static TDelegate EventDelegate<TDelegate>(
|
||||||
|
WorldSession session,
|
||||||
|
string eventName)
|
||||||
|
where TDelegate : Delegate =>
|
||||||
|
Assert.IsType<TDelegate>(EventField(session, eventName).GetValue(session));
|
||||||
|
|
||||||
|
private static TDelegate EventDelegate<TOwner, TDelegate>(
|
||||||
|
TOwner owner,
|
||||||
|
string eventName)
|
||||||
|
where TOwner : class
|
||||||
|
where TDelegate : Delegate =>
|
||||||
|
Assert.IsType<TDelegate>(
|
||||||
|
typeof(TOwner).GetField(
|
||||||
|
eventName,
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner));
|
||||||
|
|
||||||
|
private static FieldInfo EventField(WorldSession session, string eventName) =>
|
||||||
|
typeof(WorldSession).GetField(
|
||||||
|
eventName,
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException($"event field {eventName} not found");
|
||||||
|
|
||||||
|
private sealed class Counters
|
||||||
|
{
|
||||||
|
public int ServerTime;
|
||||||
|
public int Teleport;
|
||||||
|
public bool ThrowOnServerTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Buffers.Binary;
|
||||||
using AcDream.Core.Chat;
|
using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
|
@ -115,6 +116,35 @@ public sealed class WorldSessionWiringOwnershipTests
|
||||||
Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount);
|
Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameEventWiring_AcceptanceGateSilencesCopiedOrInFlightDispatch()
|
||||||
|
{
|
||||||
|
var dispatcher = new GameEventDispatcher();
|
||||||
|
var combat = new CombatState();
|
||||||
|
bool accepting = true;
|
||||||
|
using IDisposable registration = GameEventWiring.WireAll(
|
||||||
|
dispatcher,
|
||||||
|
new ClientObjectTable(),
|
||||||
|
combat,
|
||||||
|
new Spellbook(),
|
||||||
|
new ChatLog(),
|
||||||
|
accepting: () => accepting);
|
||||||
|
byte[] first = new byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(first, 0x50000001u);
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(first.AsSpan(4), 0.75f);
|
||||||
|
byte[] stale = new byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(stale, 0x50000001u);
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(stale.AsSpan(4), 0.25f);
|
||||||
|
|
||||||
|
dispatcher.Dispatch(new GameEventEnvelope(
|
||||||
|
0u, 0u, GameEventType.UpdateHealth, first));
|
||||||
|
accepting = false;
|
||||||
|
dispatcher.Dispatch(new GameEventEnvelope(
|
||||||
|
0u, 0u, GameEventType.UpdateHealth, stale));
|
||||||
|
|
||||||
|
Assert.Equal(0.75f, combat.GetHealthPercent(0x50000001u));
|
||||||
|
}
|
||||||
|
|
||||||
private static WorldSession NewSession() =>
|
private static WorldSession NewSession() =>
|
||||||
new(new IPEndPoint(IPAddress.Loopback, 9));
|
new(new IPEndPoint(IPAddress.Loopback, 9));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,20 @@ public sealed class LiveCommandBusTests
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => bus.Register<FakeCmd>(_ => { }));
|
Assert.Throws<InvalidOperationException>(() => bus.Register<FakeCmd>(_ => { }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_ReleasesHandlersAndMakesExistingBusInert()
|
||||||
|
{
|
||||||
|
var bus = new LiveCommandBus();
|
||||||
|
int calls = 0;
|
||||||
|
bus.Register<FakeCmd>(_ => calls++);
|
||||||
|
bus.Publish(new FakeCmd(1));
|
||||||
|
|
||||||
|
bus.Clear();
|
||||||
|
bus.Publish(new FakeCmd(2));
|
||||||
|
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ChannelResolverTests
|
public sealed class ChannelResolverTests
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue