refactor(net): own live session composition
Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
18d4b999de
commit
557eb7ef6b
22 changed files with 1430 additions and 236 deletions
|
|
@ -21,7 +21,7 @@ internal sealed record LiveSessionCommandBindings(
|
|||
/// itself is the published bus, so a retained reference becomes inert before
|
||||
/// the displaced transport is disposed.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable
|
||||
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
|
|
|
|||
|
|
@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings(
|
|||
/// 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
|
||||
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||
{
|
||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||
private readonly Action<int>? _constructionCheckpoint;
|
||||
private readonly WorldSession _session;
|
||||
private readonly LiveEntitySessionSink _entities;
|
||||
private readonly LiveEnvironmentSessionSink _environment;
|
||||
private readonly LiveInventorySessionBindings _inventory;
|
||||
private readonly LiveCharacterSessionBindings _character;
|
||||
private readonly LiveSocialSessionBindings _social;
|
||||
private int _constructionStep;
|
||||
private int _accepting = 1;
|
||||
private int _accepting;
|
||||
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
|
||||
|
||||
public LiveSessionEventRouter(
|
||||
WorldSession session,
|
||||
|
|
@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
Validate(entities, environment, inventory, character, social);
|
||||
_session = session;
|
||||
_entities = entities;
|
||||
_environment = environment;
|
||||
_inventory = inventory;
|
||||
_character = character;
|
||||
_social = social;
|
||||
_constructionCheckpoint = constructionCheckpoint;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
|
||||
throw new InvalidOperationException(
|
||||
"Live-session event routing can only attach once.");
|
||||
|
||||
Interlocked.Exchange(ref _accepting, 1);
|
||||
WorldSession session = _session;
|
||||
LiveEntitySessionSink entities = _entities;
|
||||
LiveEnvironmentSessionSink environment = _environment;
|
||||
LiveInventorySessionBindings inventory = _inventory;
|
||||
LiveCharacterSessionBindings character = _character;
|
||||
LiveSocialSessionBindings social = _social;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||
}
|
||||
catch (Exception constructionError)
|
||||
catch
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
};
|
||||
|
||||
attach(handler);
|
||||
try
|
||||
{
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
}
|
||||
catch
|
||||
{
|
||||
detach(handler);
|
||||
throw;
|
||||
}
|
||||
// Add assumes cleanup ownership before it can call an external detach.
|
||||
// If the set is already closing, it retains/retries that exact edge;
|
||||
// calling detach again here would replay a successful removal.
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
|
@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : IDisposable
|
|||
|
||||
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||
{
|
||||
private List<IDisposable>? _subscriptions = [];
|
||||
private readonly object _gate = new();
|
||||
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||
private bool _disposeRequested;
|
||||
|
||||
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);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
|
||||
public void Add(Action unsubscribe)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||
AddRetained(new RetryableSubscription(unsubscribe));
|
||||
}
|
||||
|
||||
private void AddRetained(RetryableSubscription retained)
|
||||
{
|
||||
bool disposeNow;
|
||||
lock (_gate)
|
||||
{
|
||||
disposeNow = _disposeRequested;
|
||||
_subscriptions.Add(retained);
|
||||
}
|
||||
|
||||
if (!disposeNow)
|
||||
return;
|
||||
retained.Dispose();
|
||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
|
||||
if (subscriptions is null)
|
||||
return;
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int index = subscriptions.Count - 1; index >= 0; index--)
|
||||
for (int index = subscriptions.Length - 1; index >= 0; index--)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable
|
|||
errors);
|
||||
}
|
||||
|
||||
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
|
||||
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||
{
|
||||
private Action? _unsubscribe = unsubscribe;
|
||||
private readonly object _gate = new();
|
||||
private Action? _dispose = dispose;
|
||||
private bool _executing;
|
||||
private int _executingThreadId;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
|
||||
public void Dispose()
|
||||
{
|
||||
Action? operation;
|
||||
int threadId = Environment.CurrentManagedThreadId;
|
||||
lock (_gate)
|
||||
{
|
||||
while (_executing)
|
||||
{
|
||||
if (_executingThreadId == threadId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live-session subscription cleanup cannot complete reentrantly.");
|
||||
}
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
|
||||
operation = _dispose;
|
||||
if (operation is null)
|
||||
return;
|
||||
_executing = true;
|
||||
_executingThreadId = threadId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
}
|
||||
catch
|
||||
{
|
||||
CompleteAttempt(succeeded: false);
|
||||
throw;
|
||||
}
|
||||
|
||||
CompleteAttempt(succeeded: true);
|
||||
}
|
||||
|
||||
private void CompleteAttempt(bool succeeded)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (succeeded)
|
||||
_dispose = null;
|
||||
_executing = false;
|
||||
_executingThreadId = 0;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
using System.Runtime.ExceptionServices;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal interface ILiveSessionEventRouting : IDisposable
|
||||
{
|
||||
void Attach();
|
||||
}
|
||||
|
||||
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
|
||||
{
|
||||
void Activate();
|
||||
}
|
||||
|
||||
internal sealed record LiveSessionRoutingFactories(
|
||||
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
|
||||
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
|
||||
|
||||
internal sealed record LiveSessionSelectionBindings(
|
||||
Action<uint> SetPlayerIdentity,
|
||||
Action<uint> SetVitalsIdentity,
|
||||
Action<uint> SetChatIdentity,
|
||||
Action<uint> MarkPersistent,
|
||||
Action<uint> SetVanishProbeIdentity,
|
||||
Action ClearCombat);
|
||||
|
||||
internal sealed record LiveSessionEnteredWorldBindings(
|
||||
Action<string> SetActiveCharacter,
|
||||
Action RestoreLayout,
|
||||
Action SyncToolbar,
|
||||
Action<string> LoadCharacterSettings,
|
||||
Action ArmPlayerModeAutoEntry);
|
||||
|
||||
internal sealed record LiveSessionHostBindings(
|
||||
LiveSessionRoutingFactories Routing,
|
||||
LiveSessionResetBindings Reset,
|
||||
LiveSessionSelectionBindings Selection,
|
||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected);
|
||||
|
||||
/// <summary>
|
||||
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
|
||||
/// It owns callback ordering and per-generation route factories, but never
|
||||
/// mirrors session, generation, identity, routing, or command state.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionHost
|
||||
{
|
||||
private sealed class PendingRouteRollback(
|
||||
ILiveSessionCommandRouting? commands,
|
||||
ILiveSessionEventRouting? events)
|
||||
{
|
||||
private ILiveSessionCommandRouting? _commands = commands;
|
||||
private ILiveSessionEventRouting? _events = events;
|
||||
|
||||
public bool IsComplete => _commands is null && _events is null;
|
||||
|
||||
public void Drain()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
TryDispose(ref _commands, ref failures);
|
||||
TryDispose(ref _events, ref failures);
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Live-session route rollback did not converge.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDispose<TOwner>(
|
||||
ref TOwner? owner,
|
||||
ref List<Exception>? failures)
|
||||
where TOwner : class, IDisposable
|
||||
{
|
||||
if (owner is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
owner.Dispose();
|
||||
owner = null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly LiveSessionController _controller;
|
||||
private readonly LiveSessionRoutingFactories _routing;
|
||||
private readonly LiveSessionSelectionBindings _selection;
|
||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||
private readonly LiveSessionResetPlan _resetPlan;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
||||
public LiveSessionHost(
|
||||
LiveSessionController controller,
|
||||
LiveSessionHostBindings bindings)
|
||||
{
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
|
||||
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||
_enteredWorld = bindings.EnteredWorld
|
||||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
Validate(_selection, _enteredWorld);
|
||||
|
||||
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
|
||||
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
|
||||
Bind: BindSession,
|
||||
Reset: ResetSessionState,
|
||||
Connecting: bindings.Connecting,
|
||||
Connected: bindings.Connected,
|
||||
Selected: ApplySelection,
|
||||
Entered: ApplyEnteredWorld));
|
||||
}
|
||||
|
||||
public WorldSession? CurrentSession => _controller.CurrentSession;
|
||||
public ICommandBus Commands => _controller.Commands;
|
||||
public bool IsInWorld => _controller.IsInWorld;
|
||||
|
||||
public LiveSessionStartResult Start(RuntimeOptions options) =>
|
||||
_controller.Start(options, _lifecycle);
|
||||
|
||||
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
|
||||
_controller.Reconnect(options, _lifecycle);
|
||||
|
||||
private LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
|
||||
ILiveSessionEventRouting? events = null;
|
||||
ILiveSessionCommandRouting? commands = null;
|
||||
try
|
||||
{
|
||||
events = _routing.CreateEvents(session)
|
||||
?? throw new InvalidOperationException(
|
||||
"The live-session event factory returned null.");
|
||||
events.Attach();
|
||||
commands = _routing.CreateCommands(session)
|
||||
?? throw new InvalidOperationException(
|
||||
"The live-session command factory returned null.");
|
||||
return new LiveSessionBinding(
|
||||
session,
|
||||
commands,
|
||||
activateCommands: commands.Activate,
|
||||
deactivateCommands: commands.Dispose,
|
||||
detachEvents: events.Dispose);
|
||||
}
|
||||
catch (Exception creationError)
|
||||
{
|
||||
RethrowWithRetryableRollback(creationError, commands, events);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetSessionState()
|
||||
{
|
||||
// An incompletely detached route can still deliver callbacks into the
|
||||
// state below. Treat physical route convergence as the same hard
|
||||
// barrier used by normal LiveSessionBinding teardown.
|
||||
DrainPendingRouteRollback();
|
||||
_resetPlan.Execute();
|
||||
}
|
||||
|
||||
private void ApplySelection(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
uint id = selection.CharacterId;
|
||||
_selection.SetPlayerIdentity(id);
|
||||
_selection.SetVitalsIdentity(id);
|
||||
_selection.SetChatIdentity(id);
|
||||
_selection.MarkPersistent(id);
|
||||
_selection.SetVanishProbeIdentity(id);
|
||||
_selection.ClearCombat();
|
||||
}
|
||||
|
||||
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
string name = selection.CharacterName;
|
||||
_enteredWorld.SetActiveCharacter(name);
|
||||
_enteredWorld.RestoreLayout();
|
||||
_enteredWorld.SyncToolbar();
|
||||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
Exception creationError,
|
||||
ILiveSessionCommandRouting? commands,
|
||||
ILiveSessionEventRouting? events)
|
||||
{
|
||||
_pendingRouteRollback = new PendingRouteRollback(commands, events);
|
||||
try
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
}
|
||||
catch (AggregateException cleanupError)
|
||||
{
|
||||
var failures = new List<Exception> { creationError };
|
||||
failures.AddRange(cleanupError.InnerExceptions);
|
||||
throw new AggregateException(
|
||||
"Live-session route construction and rollback both failed.",
|
||||
failures);
|
||||
}
|
||||
|
||||
ExceptionDispatchInfo.Capture(creationError).Throw();
|
||||
}
|
||||
|
||||
private void DrainPendingRouteRollback()
|
||||
{
|
||||
if (_pendingRouteRollback is not { } rollback)
|
||||
return;
|
||||
rollback.Drain();
|
||||
if (rollback.IsComplete)
|
||||
_pendingRouteRollback = null;
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
LiveSessionSelectionBindings selection,
|
||||
LiveSessionEnteredWorldBindings entered)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
|
||||
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
|
||||
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
|
||||
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
|
||||
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
|
||||
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
|
||||
}
|
||||
}
|
||||
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Exact unsigned formula used by retail <c>SkillFormula::Calculate @
|
||||
/// 0x00591960</c>. DAT reader fields are signed storage views, so their bits
|
||||
/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words.
|
||||
/// </summary>
|
||||
internal static class RetailSkillFormula
|
||||
{
|
||||
public static bool TryCalculate(
|
||||
SkillFormula formula,
|
||||
uint attribute1,
|
||||
uint attribute2,
|
||||
out uint result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formula);
|
||||
|
||||
uint divisor = unchecked((uint)formula.Divisor);
|
||||
if (divisor == 0u)
|
||||
{
|
||||
result = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint x = unchecked((uint)formula.Attribute1Multiplier);
|
||||
uint y = unchecked((uint)formula.Attribute2Multiplier);
|
||||
uint w = unchecked((uint)formula.AdditiveBonus);
|
||||
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
|
||||
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Named live-session resolver for the attribute contribution to one skill.
|
||||
/// Table lookup and missing-property policy stay separate from retail math.
|
||||
/// </summary>
|
||||
internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
|
||||
{
|
||||
public uint Resolve(
|
||||
uint skillId,
|
||||
IReadOnlyDictionary<uint, uint> attributeCurrents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributeCurrents);
|
||||
|
||||
if (skillTable?.Skills is null
|
||||
|| !skillTable.Skills.TryGetValue(
|
||||
(DatReaderWriter.Enums.SkillId)skillId,
|
||||
out var skillBase))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
SkillFormula formula = skillBase.Formula;
|
||||
attributeCurrents.TryGetValue(
|
||||
(uint)formula.Attribute1,
|
||||
out uint attribute1);
|
||||
attributeCurrents.TryGetValue(
|
||||
(uint)formula.Attribute2,
|
||||
out uint attribute2);
|
||||
return RetailSkillFormula.TryCalculate(
|
||||
formula,
|
||||
attribute1,
|
||||
attribute2,
|
||||
out uint result)
|
||||
? result
|
||||
: 0u;
|
||||
}
|
||||
}
|
||||
|
|
@ -380,10 +380,6 @@ public sealed class GameWindow : IDisposable
|
|||
// DevToolsEnabled reads through typed RuntimeOptions.
|
||||
private bool DevToolsEnabled => _options.DevTools;
|
||||
|
||||
// Slice 3: the reset transaction remains cached by the host, while the
|
||||
// lifecycle controller owns each exact event/command/session generation.
|
||||
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
|
||||
|
||||
// Phase G.1-G.2 world lighting/time state.
|
||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||
new AcDream.Core.World.WorldTimeService(
|
||||
|
|
@ -485,7 +481,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
||||
// by ApplyLiveSessionEnteredWorld; ticked from
|
||||
// by LiveSessionHost's entered-world transition; ticked from
|
||||
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
||||
// other path that pre-empts the chase camera). Skipped entirely
|
||||
// offline (orbit camera stays the foreground). The class internally
|
||||
|
|
@ -529,8 +525,9 @@ public sealed class GameWindow : IDisposable
|
|||
// Slice 3: the controller is the sole App owner. Outbound feature
|
||||
// callbacks resolve this borrowed handle at call time and never cache it.
|
||||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
|
||||
private AcDream.Core.Net.WorldSession? LiveSession =>
|
||||
_liveSessionController?.CurrentSession;
|
||||
_liveSessionHost?.CurrentSession;
|
||||
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
||||
private int _liveCenterX => _liveWorldOrigin.CenterX;
|
||||
private int _liveCenterY => _liveWorldOrigin.CenterY;
|
||||
|
|
@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable
|
|||
try
|
||||
{
|
||||
// _activeToonKey is updated by
|
||||
// ApplyLiveSessionEnteredWorld
|
||||
// LiveSessionHost entered-world transition
|
||||
// so saving character settings always
|
||||
// writes under the chosen character's
|
||||
// name (or "default" pre-login).
|
||||
|
|
@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable
|
|||
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
||||
.DragItemOnPlayerOpensSecureTrade) != 0,
|
||||
toast: text => _debugVm?.AddToast(text),
|
||||
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
|
||||
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
|
||||
playerOnGround: GetDebugPlayerOnGround,
|
||||
inNonCombatMode: () => Combat.CurrentMode
|
||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||
|
|
@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable
|
|||
playerGuid: () => _playerServerGuid,
|
||||
activeToonName: () => _activeToonKey,
|
||||
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
||||
canSendRaise: () => _liveSessionController?.IsInWorld == true,
|
||||
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
|
||||
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
|
||||
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
|
||||
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
|
||||
|
|
@ -1555,7 +1552,7 @@ public sealed class GameWindow : IDisposable
|
|||
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
|
||||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
||||
canSend: () => _liveSessionController?.IsInWorld == true);
|
||||
canSend: () => _liveSessionHost?.IsInWorld == true);
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
|
|
@ -1653,7 +1650,7 @@ public sealed class GameWindow : IDisposable
|
|||
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
|
||||
Chat: new AcDream.App.UI.ChatRuntimeBindings(
|
||||
retailChatVm,
|
||||
() => _liveSessionController?.Commands
|
||||
() => _liveSessionHost?.Commands
|
||||
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||
radarSnapshotProvider.BuildSnapshot,
|
||||
|
|
@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
||||
_playerModeAutoEntry),
|
||||
cameraFrame);
|
||||
_liveSessionHost = CreateLiveSessionHost();
|
||||
AcDream.App.Net.LiveSessionStartResult liveStart =
|
||||
_liveSessionController.Start(
|
||||
_options,
|
||||
CreateLiveSessionLifecycleHost());
|
||||
_liveSessionHost.Start(_options);
|
||||
switch (liveStart.Status)
|
||||
{
|
||||
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
|
||||
|
|
@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
|
||||
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
|
||||
Bind: CreateLiveSessionBinding,
|
||||
Reset: () =>
|
||||
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
|
||||
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
|
||||
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
|
||||
Routing: new(
|
||||
CreateLiveSessionEventRouter,
|
||||
session => new AcDream.App.Net.LiveSessionCommandRouter(
|
||||
CreateLiveSessionCommandBindings(session))),
|
||||
Reset: CreateLiveSessionResetBindings(),
|
||||
Selection: new(
|
||||
SetPlayerIdentity: id => _playerServerGuid = id,
|
||||
SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id),
|
||||
SetChatIdentity: Chat.SetLocalPlayerGuid,
|
||||
MarkPersistent: _worldState.MarkPersistent,
|
||||
SetVanishProbeIdentity: id =>
|
||||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
|
||||
ClearCombat: Combat.Clear),
|
||||
EnteredWorld: new(
|
||||
SetActiveCharacter: name => _activeToonKey = name,
|
||||
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
|
||||
SyncToolbar: SyncToolbarWindowButtons,
|
||||
LoadCharacterSettings: LoadLiveSessionCharacterSettings,
|
||||
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
|
||||
Connecting: (host, port, user) =>
|
||||
Chat.OnSystemMessage(
|
||||
$"connecting to {host}:{port} as {user}",
|
||||
|
|
@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable
|
|||
Connected: () =>
|
||||
Chat.OnSystemMessage(
|
||||
"connected — character list received",
|
||||
chatType: 1),
|
||||
Selected: ApplyLiveSessionSelection,
|
||||
Entered: ApplyLiveSessionEnteredWorld));
|
||||
chatType: 1)));
|
||||
|
||||
private void ApplyLiveSessionSelection(
|
||||
AcDream.App.Net.LiveSessionCharacterSelection selection)
|
||||
private void LoadLiveSessionCharacterSettings(string characterName)
|
||||
{
|
||||
_playerServerGuid = selection.CharacterId;
|
||||
_vitalsVm?.SetLocalPlayerGuid(selection.CharacterId);
|
||||
Chat.SetLocalPlayerGuid(selection.CharacterId);
|
||||
_worldState.MarkPersistent(selection.CharacterId);
|
||||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId;
|
||||
Combat.Clear();
|
||||
}
|
||||
|
||||
private void ApplyLiveSessionEnteredWorld(
|
||||
AcDream.App.Net.LiveSessionCharacterSelection selection)
|
||||
{
|
||||
_activeToonKey = selection.CharacterName;
|
||||
_retailUiRuntime?.RestoreLayout();
|
||||
SyncToolbarWindowButtons();
|
||||
if (_settingsStore is not null && _settingsVm is not null)
|
||||
{
|
||||
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
var toonBag = _settingsStore.LoadCharacter(characterName);
|
||||
_settingsVm.LoadCharacterContext(toonBag);
|
||||
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
|
||||
Console.WriteLine(
|
||||
$"settings: loaded character[{characterName}] preferences");
|
||||
}
|
||||
_playerModeAutoEntry?.Arm();
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
|
||||
AcDream.App.Net.LiveSessionResetManifest.Create(new()
|
||||
private AcDream.App.Net.LiveSessionResetBindings
|
||||
CreateLiveSessionResetBindings() => new()
|
||||
{
|
||||
MouseCapture = ResetSessionMouseCapture,
|
||||
PlayerPresentation = ResetSessionPlayerPresentation,
|
||||
|
|
@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable
|
|||
AnimationHookFrames = () => _animationHookFrames?.Clear(),
|
||||
LivePresentation = () => _liveEntityPresentation?.Clear(),
|
||||
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
|
||||
});
|
||||
};
|
||||
|
||||
private void ResetSessionMouseCapture()
|
||||
=> _gameplayInputFrame?.ResetSession();
|
||||
|
|
@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the exact inbound and outbound owners for one live session.
|
||||
/// Registration completes before Connect; outbound commands remain inert
|
||||
/// until EnterWorld succeeds.
|
||||
/// </summary>
|
||||
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
|
||||
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
|
||||
AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
|
|
@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine);
|
||||
}
|
||||
|
||||
AcDream.App.Net.LiveSessionEventRouter? events = null;
|
||||
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
|
||||
try
|
||||
{
|
||||
events = new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
|
||||
commands = new AcDream.App.Net.LiveSessionCommandRouter(
|
||||
CreateLiveSessionCommandBindings(session));
|
||||
return new AcDream.App.Net.LiveSessionBinding(
|
||||
session,
|
||||
commands,
|
||||
activateCommands: commands.Activate,
|
||||
deactivateCommands: commands.Dispose,
|
||||
detachEvents: events.Dispose);
|
||||
}
|
||||
catch
|
||||
{
|
||||
commands?.Dispose();
|
||||
events?.Dispose();
|
||||
throw;
|
||||
}
|
||||
return new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
OnEnvironChanged,
|
||||
ticks =>
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}),
|
||||
CreateLiveInventorySessionBindings(),
|
||||
CreateLiveCharacterSessionBindings(skillTable),
|
||||
CreateLiveSocialSessionBindings());
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveInventorySessionBindings
|
||||
|
|
@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private AcDream.App.Net.LiveCharacterSessionBindings
|
||||
CreateLiveCharacterSessionBindings(
|
||||
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
|
||||
DatReaderWriter.DBObjs.SkillTable? skillTable)
|
||||
{
|
||||
var skillCreditResolver =
|
||||
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
|
||||
return 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;
|
||||
},
|
||||
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
|
||||
|
|
@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable
|
|||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
ClientTime: ClientTimerNow);
|
||||
}
|
||||
|
||||
private AcDream.App.Net.LiveSocialSessionBindings
|
||||
CreateLiveSocialSessionBindings() => new(
|
||||
|
|
@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable
|
|||
// optional SettingsPanel and its visibility/input operations.
|
||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
|
||||
// L.0: settings.json store + active toon key. The store is held as
|
||||
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
|
||||
// a field so LiveSessionHost can re-load the chosen toon's
|
||||
// bag once we know its name (post-EnterWorld). Toon key starts as
|
||||
// "default" and gets swapped to the actual character name on the
|
||||
// first EnterWorld.
|
||||
|
|
@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable
|
|||
_persistedGameplay = _settingsStore.LoadGameplay();
|
||||
_persistedChat = _settingsStore.LoadChat();
|
||||
// _activeToonKey is "default" pre-EnterWorld; the post-login
|
||||
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
|
||||
// LiveSessionHost swaps to the chosen toon's
|
||||
// name and re-loads via SettingsVM.LoadCharacterContext.
|
||||
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
|
||||
|
|
@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable
|
|||
private void ToggleLiveCombatMode()
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
||||
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||
return;
|
||||
|
||||
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
|
||||
|
|
@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable
|
|||
private void UseItemByGuid(uint guid)
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||
if (_liveSessionController?.IsInWorld != true || session is null)
|
||||
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||
return;
|
||||
uint sequence = session.NextGameActionSequence();
|
||||
session.SendGameAction(
|
||||
|
|
@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
if (ReferenceEquals(_liveSessionController, controller))
|
||||
{
|
||||
_liveSessionHost = null;
|
||||
_liveSessionController = null;
|
||||
}
|
||||
}),
|
||||
]),
|
||||
// Frame composition borrows equipped, effect, audio, and render
|
||||
|
|
|
|||
|
|
@ -7,36 +7,48 @@ namespace AcDream.Core.Net;
|
|||
/// </summary>
|
||||
internal sealed class SubscriptionSet : IDisposable
|
||||
{
|
||||
private List<IDisposable>? _subscriptions = [];
|
||||
private readonly object _gate = new();
|
||||
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||
private bool _disposeRequested;
|
||||
|
||||
public void Add(IDisposable subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
List<IDisposable>? subscriptions = _subscriptions;
|
||||
if (subscriptions is null)
|
||||
{
|
||||
subscription.Dispose();
|
||||
throw new ObjectDisposedException(nameof(SubscriptionSet));
|
||||
}
|
||||
|
||||
subscriptions.Add(subscription);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
public void Add(Action unsubscribe)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||
Add(new ActionSubscription(unsubscribe));
|
||||
AddRetained(new RetryableSubscription(unsubscribe));
|
||||
}
|
||||
|
||||
private void AddRetained(RetryableSubscription retained)
|
||||
{
|
||||
bool disposeNow;
|
||||
lock (_gate)
|
||||
{
|
||||
disposeNow = _disposeRequested;
|
||||
_subscriptions.Add(retained);
|
||||
}
|
||||
|
||||
if (!disposeNow)
|
||||
return;
|
||||
retained.Dispose();
|
||||
throw new ObjectDisposedException(nameof(SubscriptionSet));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<IDisposable>? subscriptions =
|
||||
Interlocked.Exchange(ref _subscriptions, null);
|
||||
if (subscriptions is null)
|
||||
return;
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int i = subscriptions.Count - 1; i >= 0; i--)
|
||||
for (int i = subscriptions.Length - 1; i >= 0; i--)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable
|
|||
throw new AggregateException("one or more subscriptions failed to detach", errors);
|
||||
}
|
||||
|
||||
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
|
||||
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||
{
|
||||
private Action? _unsubscribe = unsubscribe;
|
||||
private readonly object _gate = new();
|
||||
private Action? _dispose = dispose;
|
||||
private bool _executing;
|
||||
private int _executingThreadId;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
|
||||
public void Dispose()
|
||||
{
|
||||
Action? operation;
|
||||
int threadId = Environment.CurrentManagedThreadId;
|
||||
lock (_gate)
|
||||
{
|
||||
while (_executing)
|
||||
{
|
||||
if (_executingThreadId == threadId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Subscription cleanup cannot complete reentrantly.");
|
||||
}
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
|
||||
operation = _dispose;
|
||||
if (operation is null)
|
||||
return;
|
||||
_executing = true;
|
||||
_executingThreadId = threadId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
}
|
||||
catch
|
||||
{
|
||||
CompleteAttempt(succeeded: false);
|
||||
throw;
|
||||
}
|
||||
|
||||
CompleteAttempt(succeeded: true);
|
||||
}
|
||||
|
||||
private void CompleteAttempt(bool succeeded)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (succeeded)
|
||||
_dispose = null;
|
||||
_executing = false;
|
||||
_executingThreadId = 0;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue