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 DumpMoveTruthEnabled => _options.DumpMoveTruth;
|
||||
|
||||
// Phase I.3 — real ICommandBus for live sessions. Constructed when
|
||||
// the live session spins up (so SendChatCmd handlers can close over
|
||||
// _liveSession + Chat). Null when offline; PanelContext then falls
|
||||
// back to NullCommandBus.Instance.
|
||||
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;
|
||||
// Slice 3: exact session-generation owners. The command router is itself
|
||||
// an ICommandBus and becomes inert before the displaced socket closes.
|
||||
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
|
||||
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
|
||||
|
||||
// Phase G.1-G.2 world lighting/time state.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||
|
|
@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||
retailChatVm,
|
||||
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||
radarSnapshotProvider.BuildSnapshot,
|
||||
|
|
@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||||
if (_liveSession is null)
|
||||
{
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
|
||||
{
|
||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
}
|
||||
ClearInboundEntityState();
|
||||
return;
|
||||
}
|
||||
|
|
@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||
Combat.Clear();
|
||||
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
|
||||
_liveSessionCommands?.Activate();
|
||||
|
||||
_activeToonKey = chosen.Name;
|
||||
_retailUiRuntime?.RestoreLayout();
|
||||
|
|
@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"live: session failed: {ex.Message}");
|
||||
_commandBus = null;
|
||||
_combatChatTranslator?.Dispose();
|
||||
_combatChatTranslator = null;
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
try
|
||||
{
|
||||
DisposeLiveSessionRouting();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
}
|
||||
ClearInboundEntityState();
|
||||
}
|
||||
}
|
||||
|
|
@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||||
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
|
||||
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
|
||||
/// is constructed and BEFORE any network I/O.
|
||||
/// Composes the exact inbound and outbound owners for one live session.
|
||||
/// Registration completes before Connect; outbound commands remain inert
|
||||
/// until EnterWorld succeeds.
|
||||
/// </summary>
|
||||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
_liveSession = session;
|
||||
// D.5.4: wire non-lifecycle object quality/property updates. The live
|
||||
// handlers below apply Create/Delete to both projections only after
|
||||
// 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));
|
||||
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
|
||||
throw new InvalidOperationException("live-session routing is already bound");
|
||||
|
||||
// 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));
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
if (_characterSheetProvider is not null && _dats is not null)
|
||||
{
|
||||
_characterSheetProvider.SkillTable = skillTable;
|
||||
_characterSheetProvider.ExperienceTable =
|
||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||
_dats,
|
||||
Console.WriteLine);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (_characterSheetProvider is not null && _dats is not null)
|
||||
{
|
||||
_characterSheetProvider.SkillTable = skillTable;
|
||||
_characterSheetProvider.ExperienceTable =
|
||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||
_dats, Console.WriteLine);
|
||||
}
|
||||
|
||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
||||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
||||
TurbineChat,
|
||||
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
|
||||
{
|
||||
// ACE GetFormula (AttributeFormula.cs:55-): when
|
||||
// formula.X (Attribute1Multiplier) is 0, the formula
|
||||
// is "no attribute contribution" and the function
|
||||
// returns 0. Otherwise:
|
||||
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
|
||||
if (skillTable?.Skills is null) return 0u;
|
||||
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
|
||||
// Run / Jump skill values so the next
|
||||
// EnterPlayerModeNow can hand them to the new
|
||||
// PlayerMovementController. Push immediately too,
|
||||
// so a PD that arrives WHILE player mode is active
|
||||
// (re-equip / log-in mid-session) updates the live
|
||||
// controller. -1 from the wiring means "skill not
|
||||
// present in this PD" — keep the previous cached
|
||||
// value rather than overwriting with -1.
|
||||
if (runSkill >= 0) _lastSeenRunSkill = runSkill;
|
||||
if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill;
|
||||
if (_playerController is not null
|
||||
&& _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
||||
try
|
||||
{
|
||||
events = new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
CreateLiveEntitySessionSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
_playerController.SetCharacterSkills(
|
||||
_lastSeenRunSkill, _lastSeenJumpSkill);
|
||||
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
|
||||
}
|
||||
},
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error =>
|
||||
{
|
||||
_externalContainers.ApplyUseDone(error);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||
onConfirmationDone: done =>
|
||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||
friends: Friends,
|
||||
squelch: Squelch,
|
||||
onDesiredComponents: components => DesiredComponents = components,
|
||||
onCharacterOptions: (options1, _) =>
|
||||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
clientTime: ClientTimerNow,
|
||||
externalContainers: _externalContainers);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
// the unified ChatLog. The translator owns the wording
|
||||
// (templates ported from holtburger chat.rs:221-308); the
|
||||
// panel renders combat entries via TextColored.
|
||||
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
|
||||
|
||||
// Phase H.1: feed inbound HearSpeech into the chat log.
|
||||
_liveSession.SpeechHeard += speech =>
|
||||
Chat.OnLocalSpeech(
|
||||
sender: speech.SenderName,
|
||||
text: speech.Text,
|
||||
senderGuid: speech.SenderGuid,
|
||||
isRanged: speech.IsRanged);
|
||||
|
||||
// Phase I.6: feed inbound TurbineChat events into the chat log.
|
||||
// The Response variant is fire-and-forget (server-side ack);
|
||||
// EventSendToRoom is a real chat message broadcast to a room.
|
||||
// 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,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () =>
|
||||
LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ShowConfirmation(message, completed),
|
||||
Suicide: liveSession.SendSuicide,
|
||||
ClearChat: _ => chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(liveSession, away),
|
||||
SetAwayMessage: liveSession.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: liveSession.SendDisplayConsent,
|
||||
ClearConsent: liveSession.SendClearConsent,
|
||||
RemoveConsent: liveSession.SendRemoveConsent,
|
||||
SendEmote: liveSession.SendEmote,
|
||||
Friends: Friends,
|
||||
AddFriend: liveSession.SendAddFriend,
|
||||
RemoveFriend: liveSession.SendRemoveFriend,
|
||||
ClearFriends: liveSession.SendClearFriends,
|
||||
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
|
||||
Squelch: Squelch,
|
||||
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
liveSession.SendClearDesiredComponents();
|
||||
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }));
|
||||
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
||||
clientCommandController.Execute);
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cmd.Text))
|
||||
liveSession.SendTalk(cmd.Text);
|
||||
});
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||||
switch (cmd.Channel)
|
||||
{
|
||||
case AcDream.UI.Abstractions.ChatChannelKind.Say:
|
||||
// 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);
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
|
||||
commands = new AcDream.App.Net.LiveSessionCommandRouter(
|
||||
CreateLiveSessionCommandBindings(session));
|
||||
_liveSessionEvents = events;
|
||||
_liveSessionCommands = commands;
|
||||
}
|
||||
catch
|
||||
{
|
||||
commands?.Dispose();
|
||||
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);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
ItemMana,
|
||||
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),
|
||||
OnConfirmationDone: done =>
|
||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||
OnCharacterOptions: (options1, _) =>
|
||||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
ClientTime: ClientTimerNow);
|
||||
|
||||
private AcDream.App.Net.LiveSocialSessionBindings
|
||||
CreateLiveSocialSessionBindings() => new(
|
||||
Chat,
|
||||
TurbineChat,
|
||||
Friends,
|
||||
Squelch);
|
||||
|
||||
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
|
||||
AcDream.Core.Net.WorldSession session) => new(
|
||||
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
|
||||
TeleportToLifestone: session.SendTeleportToLifestone,
|
||||
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
||||
TeleportToPkArena: session.SendTeleportToPkArena,
|
||||
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
||||
TeleportToHouse: session.SendTeleportToHouse,
|
||||
TeleportToMansion: session.SendTeleportToMansion,
|
||||
QueryAge: session.SendQueryAge,
|
||||
QueryBirth: session.SendQueryBirth,
|
||||
ToggleFrameRate: ToggleRetailFrameRate,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => Chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ShowConfirmation(message, completed),
|
||||
Suicide: session.SendSuicide,
|
||||
ClearChat: _ => Chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(session, away),
|
||||
SetAwayMessage: session.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: session.SendDisplayConsent,
|
||||
ClearConsent: session.SendClearConsent,
|
||||
RemoveConsent: session.SendRemoveConsent,
|
||||
SendEmote: session.SendEmote,
|
||||
Friends,
|
||||
AddFriend: session.SendAddFriend,
|
||||
RemoveFriend: session.SendRemoveFriend,
|
||||
ClearFriends: session.SendClearFriends,
|
||||
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
||||
Squelch,
|
||||
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
session.SendClearDesiredComponents();
|
||||
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }),
|
||||
Chat,
|
||||
TurbineChat,
|
||||
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()
|
||||
{
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
|
||||
_liveSessionCommands = null;
|
||||
_liveSessionEvents = null;
|
||||
try
|
||||
{
|
||||
commands?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
events?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
LogicalRegistration,
|
||||
|
|
@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// is up so panel-emitted SendChatCmd actually flows server-ward.
|
||||
// Fall back to NullCommandBus for offline / pre-connect renders.
|
||||
AcDream.UI.Abstractions.ICommandBus bus =
|
||||
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
|
||||
AcDream.UI.Abstractions.NullCommandBus.Instance;
|
||||
var ctx = new AcDream.UI.Abstractions.PanelContext(
|
||||
(float)deltaSeconds,
|
||||
|
|
@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_magicCatalog = null;
|
||||
}),
|
||||
new("streamer", () => _streamer?.Dispose()),
|
||||
new("combat chat", () => _combatChatTranslator?.Dispose()),
|
||||
new("live session routing", DisposeLiveSessionRouting),
|
||||
new("equipped children", () => _equippedChildRenderer?.Dispose()),
|
||||
new("live session", () =>
|
||||
{
|
||||
|
|
@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_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>
|
||||
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
|
||||
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ public static class CombatStateWiring
|
|||
{
|
||||
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(combat);
|
||||
|
||||
Action<WorldSession.PlayerIntPropertyUpdate> handler = update =>
|
||||
{
|
||||
if (accepting?.Invoke() == false) return;
|
||||
ApplyPlayerIntProperty(combat, update.Property, update.Value);
|
||||
};
|
||||
session.PlayerIntPropertyUpdated += handler;
|
||||
return new EventSubscription(session, handler);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@ public static class GameEventWiring
|
|||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||
Func<double>? clientTime = null,
|
||||
ExternalContainerState? externalContainers = null)
|
||||
ExternalContainerState? externalContainers = null,
|
||||
Func<bool>? accepting = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -89,7 +90,7 @@ public static class GameEventWiring
|
|||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
clientTime ??= static () => 0d;
|
||||
var registrar = new OwnedGameEventRegistrar(dispatcher);
|
||||
var registrar = new OwnedGameEventRegistrar(dispatcher, accepting);
|
||||
using var construction = new RegistrationBuildScope(registrar);
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────
|
||||
|
|
@ -659,14 +660,23 @@ public static class GameEventWiring
|
|||
}
|
||||
|
||||
private sealed class OwnedGameEventRegistrar(
|
||||
GameEventDispatcher dispatcher) : IDisposable
|
||||
GameEventDispatcher dispatcher,
|
||||
Func<bool>? accepting) : IDisposable
|
||||
{
|
||||
private readonly SubscriptionSet _subscriptions = new();
|
||||
|
||||
public void Register(
|
||||
GameEventType type,
|
||||
GameEventDispatcher.EventHandler handler) =>
|
||||
_subscriptions.Add(dispatcher.RegisterOwned(type, handler));
|
||||
GameEventDispatcher.EventHandler handler)
|
||||
{
|
||||
GameEventDispatcher.EventHandler registered = accepting is null
|
||||
? handler
|
||||
: envelope =>
|
||||
{
|
||||
if (accepting()) handler(envelope);
|
||||
};
|
||||
_subscriptions.Add(dispatcher.RegisterOwned(type, registered));
|
||||
}
|
||||
|
||||
public void Dispose() => _subscriptions.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ public static class ObjectTableWiring
|
|||
WorldSession session,
|
||||
ClientObjectTable table,
|
||||
Func<uint>? playerGuid = null,
|
||||
LocalPlayerState? localPlayer = null)
|
||||
LocalPlayerState? localPlayer = null,
|
||||
Func<bool>? accepting = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
|
|
@ -37,7 +38,10 @@ public static class ObjectTableWiring
|
|||
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
||||
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
|
||||
Action<WorldSession.ObjectIntPropertyUpdate> objectIntUpdated = u =>
|
||||
{
|
||||
if (accepting?.Invoke() == false) return;
|
||||
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||
};
|
||||
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.
|
||||
Action<WorldSession.PlayerIntPropertyUpdate> playerIntUpdated = u =>
|
||||
{
|
||||
if (accepting?.Invoke() == false) return;
|
||||
if (playerGuid is not null)
|
||||
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
|
||||
// cannot drift after the login PlayerDescription snapshot.
|
||||
Action<WorldSession.PlayerInt64PropertyUpdate> playerInt64Updated = u =>
|
||||
{
|
||||
if (accepting?.Invoke() == false) return;
|
||||
ApplyPlayerInt64PropertyUpdate(
|
||||
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
||||
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
||||
};
|
||||
session.PlayerInt64PropertyUpdated += playerInt64Updated;
|
||||
subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated);
|
||||
|
||||
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
||||
Action<WorldSession.StackSizeUpdate> stackSizeUpdated = u =>
|
||||
{
|
||||
if (accepting?.Invoke() == false) return;
|
||||
table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
||||
};
|
||||
session.StackSizeUpdated += stackSizeUpdated;
|
||||
subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated);
|
||||
|
||||
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
|
||||
// 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;
|
||||
subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,16 +51,34 @@ public sealed class CombatChatTranslator : IDisposable
|
|||
|
||||
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));
|
||||
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
|
||||
_onDealt = HandleDamageDealt;
|
||||
_onTaken = HandleDamageTaken;
|
||||
_onMissed = HandleMissedOutgoing;
|
||||
_onEvaded = HandleEvadedIncoming;
|
||||
_onKill = HandleKillLanded;
|
||||
_onDealt = value =>
|
||||
{
|
||||
if (accepting?.Invoke() != false) HandleDamageDealt(value);
|
||||
};
|
||||
_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.DamageTaken += _onTaken;
|
||||
|
|
|
|||
|
|
@ -61,4 +61,11 @@ public sealed class LiveCommandBus : ICommandBus
|
|||
$"[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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue