refactor(net): cut GameWindow over to session owner

This commit is contained in:
Erik 2026-07-21 12:37:22 +02:00
parent 783ef1d6db
commit 6a5d9e2e6a
5 changed files with 363 additions and 268 deletions

View file

@ -216,7 +216,6 @@ public sealed class LiveSessionController : IDisposable
private SessionScope? _retiredScope; private SessionScope? _retiredScope;
private ILiveSessionLifecycleHost? _pendingInitialResetHost; private ILiveSessionLifecycleHost? _pendingInitialResetHost;
private PendingOperation? _pendingOperation; private PendingOperation? _pendingOperation;
private WorldSession? _legacySession;
private int _operationDepth; private int _operationDepth;
private bool _inWorld; private bool _inWorld;
private bool _disposeRequested; private bool _disposeRequested;
@ -236,12 +235,9 @@ public sealed class LiveSessionController : IDisposable
public WorldSession? CurrentSession public WorldSession? CurrentSession
{ {
get { lock (_gate) return _scope?.Session ?? _legacySession; } get { lock (_gate) return _scope?.Session; }
} }
/// <summary>Temporary Slice-3 compatibility alias; removed at GameWindow cutover.</summary>
public WorldSession? Session => CurrentSession;
public ICommandBus Commands public ICommandBus Commands
{ {
get get
@ -319,11 +315,6 @@ public sealed class LiveSessionController : IDisposable
{ {
if (_disposed) if (_disposed)
return; return;
if (_legacySession is not null && _scope is null)
{
_legacySession.Tick();
return;
}
if (!_inWorld || _scope is null || _operationDepth != 0) if (!_inWorld || _scope is null || _operationDepth != 0)
return; return;
@ -629,11 +620,6 @@ public sealed class LiveSessionController : IDisposable
private void DisposeCore() private void DisposeCore()
{ {
StopCore(); StopCore();
if (_legacySession is { } legacy)
{
_operations.DisposeSession(legacy);
_legacySession = null;
}
_disposed = true; _disposed = true;
} }
@ -649,40 +635,4 @@ public sealed class LiveSessionController : IDisposable
throw new ObjectDisposedException(nameof(LiveSessionController)); throw new ObjectDisposedException(nameof(LiveSessionController));
} }
// Temporary compatibility surface for Slice 3 Commit E. The complete
// controller above is exercised independently before GameWindow cuts over.
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> wireEvents)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(wireEvents);
lock (_gate)
{
ThrowIfDisposing();
if (!options.LiveMode)
return null;
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
{
Console.WriteLine(
"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
return null;
}
try
{
IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort);
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
_legacySession = _operations.CreateSession(endpoint);
wireEvents(_legacySession);
return _legacySession;
}
catch (Exception error)
{
Console.WriteLine($"live: session setup failed: {error.Message}");
if (_legacySession is not null)
_operations.DisposeSession(_legacySession);
_legacySession = null;
return null;
}
}
}
} }

View file

@ -0,0 +1,65 @@
using AcDream.Core.Net;
namespace AcDream.App.Net;
internal sealed record LiveSessionLifecycleBindings(
Func<WorldSession, LiveSessionBinding> Bind,
Action Reset,
Action<string, int, string> Connecting,
Action Connected,
Action<LiveSessionCharacterSelection> Selected,
Action<LiveSessionCharacterSelection> Entered);
/// <summary>
/// Focused adapter between the session lifetime owner and App composition.
/// It tracks only the exact borrowed session attached to the host; domain and
/// presentation state remain behind the supplied lifecycle callbacks.
/// </summary>
internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
{
private readonly LiveSessionLifecycleBindings _bindings;
private WorldSession? _boundSession;
public LiveSessionLifecycleHost(LiveSessionLifecycleBindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
ArgumentNullException.ThrowIfNull(bindings.Bind);
ArgumentNullException.ThrowIfNull(bindings.Reset);
ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected);
ArgumentNullException.ThrowIfNull(bindings.Selected);
ArgumentNullException.ThrowIfNull(bindings.Entered);
}
public LiveSessionBinding BindSession(WorldSession session)
{
ArgumentNullException.ThrowIfNull(session);
if (_boundSession is not null)
throw new InvalidOperationException("A live session is already attached to this host.");
LiveSessionBinding binding = _bindings.Bind(session);
_boundSession = session;
return binding;
}
public void ResetSessionState() => _bindings.Reset();
public void ReportConnecting(string host, int port, string user) =>
_bindings.Connecting(host, port, user);
public void ReportConnected() => _bindings.Connected();
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
_bindings.Selected(selection);
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
_bindings.Entered(selection);
public void DetachSession(WorldSession session)
{
if (!ReferenceEquals(_boundSession, session))
throw new InvalidOperationException(
"The live-session controller attempted to detach a session that is not bound.");
_boundSession = null;
}
}

View file

@ -813,10 +813,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool DevToolsEnabled => _options.DevTools; private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// Slice 3: exact session-generation owners. The command router is itself // Slice 3: the reset transaction remains cached by the host, while the
// an ICommandBus and becomes inert before the displaced socket closes. // lifecycle controller owns each exact event/command/session generation.
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan; private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state. // Phase G.1-G.2 world lighting/time state.
@ -954,7 +952,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool _autoRunActive; private bool _autoRunActive;
// Phase K.2 — auto-enter player mode after a successful login. Armed // Phase K.2 — auto-enter player mode after a successful login. Armed
// by the EnterWorld branch in BeginLiveSessionAsync; ticked from // by ApplyLiveSessionEnteredWorld; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any // OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely // other path that pre-empts the chase camera). Skipped entirely
// offline (orbit camera stays the foreground). The class internally // offline (orbit camera stays the foreground). The class internally
@ -1004,12 +1002,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase 4.7: optional live connection to an ACE server. Enabled only when // Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with // ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline. // the offline rendering pipeline.
// Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle // Slice 3: the controller is the sole App owner. Outbound feature
// (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in // callbacks resolve this borrowed handle at call time and never cache it.
// LiveSessionController. _liveSession remains as a convenience handle
// for the ~60 outbound SendXxx call sites; it tracks Controller.Session.
private AcDream.App.Net.LiveSessionController? _liveSessionController; private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.Core.Net.WorldSession? _liveSession; private AcDream.Core.Net.WorldSession? LiveSession =>
_liveSessionController?.CurrentSession;
private int _liveCenterX; private int _liveCenterX;
private int _liveCenterY; private int _liveCenterY;
// #192 (2026-07-09): true once the player's first canonical Position has // #192 (2026-07-09): true once the player's first canonical Position has
@ -1184,13 +1181,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_localPlayerProjection.Project(controller, movement, hidden), _localPlayerProjection.Project(controller, movement, hidden),
sendPreNetwork: (controller, movement, hidden) => sendPreNetwork: (controller, movement, hidden) =>
_localPlayerOutbound.SendPreNetworkActions( _localPlayerOutbound.SendPreNetworkActions(
_liveSession, LiveSession,
controller, controller,
movement, movement,
hidden), hidden),
sendPostNetwork: (controller, hidden) => sendPostNetwork: (controller, hidden) =>
_localPlayerOutbound.SendPostNetworkPosition( _localPlayerOutbound.SendPostNetworkPosition(
_liveSession, LiveSession,
controller, controller,
hidden), hidden),
objectClockDisposition: () => objectClockDisposition: () =>
@ -1324,9 +1321,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// when the entity isn't ready (the third predicate // when the entity isn't ready (the third predicate
// guarantees readiness before this fires). // guarantees readiness before this fires).
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
isLiveInWorld: () => _liveSession is not null isLiveInWorld: () => _liveSessionController?.IsInWorld == true,
&& _liveSession.CurrentState ==
AcDream.Core.Net.WorldSession.State.InWorld,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true, isPlayerControllerReady: () => true,
// Retail SmartBox::UseTime (0x00455410) completes the player // Retail SmartBox::UseTime (0x00455410) completes the player
@ -1781,7 +1776,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
try try
{ {
// _activeToonKey is updated by // _activeToonKey is updated by
// BeginLiveSessionAsync after EnterWorld // ApplyLiveSessionEnteredWorld
// so saving character settings always // so saving character settings always
// writes under the chosen character's // writes under the chosen character's
// name (or "default" pre-login). // name (or "default" pre-login).
@ -2015,7 +2010,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
CanStartLiveCombatAttack, CanStartLiveCombatAttack,
SendLiveCombatAttack, SendLiveCombatAttack,
prepareAttackRequest: PreparePlayerForAttackRequest, prepareAttackRequest: PreparePlayerForAttackRequest,
sendCancelAttack: () => _liveSession?.SendCancelAttack(), sendCancelAttack: () => LiveSession?.SendCancelAttack(),
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
playerReadyForAttack: IsPlayerReadyForCombatAttack, playerReadyForAttack: IsPlayerReadyForCombatAttack,
@ -2028,7 +2023,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers, _externalContainers,
Objects, Objects,
guid => _liveSession?.SendNoLongerViewingContents(guid)); guid => LiveSession?.SendNoLongerViewingContents(guid));
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController( _itemInteractionController = new AcDream.App.UI.ItemInteractionController(
@ -2041,25 +2036,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// WorldSession directly here made keyboard R approach a corpse // WorldSession directly here made keyboard R approach a corpse
// without completing the same open transaction as double-click. // without completing the same open transaction as double-click.
sendUse: SendUse, sendUse: SendUse,
sendExamine: g => _liveSession?.SendAppraise(g), sendExamine: g => LiveSession?.SendAppraise(g),
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item), sendDrop: item => LiveSession?.SendDropItem(item),
sendGive: (target, item, amount) => sendGive: (target, item, amount) =>
_liveSession?.SendGiveObject(target, item, amount), LiveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () => dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1 (_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0, .DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text), toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSession is not null readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
playerOnGround: GetDebugPlayerOnGround, playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat, == AcDream.Core.Combat.CombatMode.NonCombat,
combatState: Combat, combatState: Combat,
sendChangeCombatMode: mode => sendChangeCombatMode: mode =>
_liveSession?.SendChangeCombatMode(mode), LiveSession?.SendChangeCombatMode(mode),
isComponentPack: magicCatalog.IsComponentPack, isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp, placeInBackpack: SendPickUp,
backpackContainerId: () => backpackContainerId: () =>
@ -2071,14 +2065,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// the retained controller remains alive. // the retained controller remains alive.
groundObjectId: () => _externalContainers.CurrentContainerId, groundObjectId: () => _externalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) => sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount), LiveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u, selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity, stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) => sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement), LiveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) => sendSplitToContainer: (item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer( LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount), item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
@ -2101,12 +2095,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey, activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSession is not null canSendRaise: () => _liveSessionController?.IsInWorld == true,
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost), sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost), sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits));
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create( _magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog, magicCatalog,
SpellBook, SpellBook,
@ -2115,16 +2108,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
localPlayerId: () => _playerServerGuid, localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling // SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList. // delivered by CharacterList.
accountName: () => _liveSession?.Characters?.AccountName accountName: () => LiveSession?.Characters?.AccountName
?? _options.LiveUser ?? _options.LiveUser
?? string.Empty, ?? string.Empty,
stopCompletely: PreparePlayerForAttackRequest, stopCompletely: PreparePlayerForAttackRequest,
sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId), sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId), sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(), incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSession is not null canSend: () => _liveSessionController?.IsInWorld == true);
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select. // Feed Silk input to the UiRoot tree so windows drag / close / select.
@ -2223,8 +2215,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm), Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings( Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm, retailChatVm,
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) () => _liveSessionController?.Commands
AcDream.UI.Abstractions.NullCommandBus.Instance), ?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings( Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot, radarSnapshotProvider.BuildSnapshot,
_selection, _selection,
@ -2252,13 +2244,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory), guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid, UseItemByGuid,
(tab, position, spellId) => (tab, position, spellId) =>
_liveSession?.SendAddSpellFavorite(spellId, position, tab), LiveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) => (tab, spellId) =>
_liveSession?.SendRemoveSpellFavorite(spellId, tab), LiveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => _liveSession?.SendSpellbookFilter(filters), filters => LiveSession?.SendSpellbookFilter(filters),
spellId => _liveSession?.SendRemoveSpell(spellId), spellId => LiveSession?.SendRemoveSpell(spellId),
(componentId, amount) => (componentId, amount) =>
_liveSession?.SendSetDesiredComponentLevel(componentId, amount), LiveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow), ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings( JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default), () => _playerController?.JumpCharge ?? default),
@ -2284,10 +2276,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => LocalPlayer.GetAttribute( () => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null, is { } strength ? (int?)strength.Current : null,
() => _liveSession?.LinkStatus () => LiveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected, ?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow, ClientTimerNow,
() => _liveSession?.RequestLinkStatusPing(), () => LiveSession?.RequestLinkStatusPing(),
() => _window?.Close()), () => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects, Objects,
@ -2299,8 +2291,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
ItemMana, ItemMana,
ToggleLiveCombatMode, ToggleLiveCombatMode,
_itemInteractionController, _itemInteractionController,
entry => _liveSession?.SendAddShortcut(entry), entry => LiveSession?.SendAddShortcut(entry),
index => _liveSession?.SendRemoveShortcut(index), index => LiveSession?.SendRemoveShortcut(index),
_selection, _selection,
handler => Combat.HealthChanged += handler, handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler, handler => Combat.HealthChanged -= handler,
@ -2309,11 +2301,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Combat.GetHealthPercent, Combat.GetHealthPercent,
Combat.HasHealth, Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0), guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => _liveSession?.SendQueryHealth(guid), guid => LiveSession?.SendQueryHealth(guid),
guid => _liveSession?.SendQueryItemMana(guid), guid => LiveSession?.SendQueryItemMana(guid),
() => _playerServerGuid, () => _playerServerGuid,
(item, container, placement) => (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement)), LiveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider), Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings( Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects, Objects,
@ -2323,14 +2315,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => LocalPlayer.GetAttribute( () => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null, is { } strength ? (int?)strength.Current : null,
guid => _liveSession?.SendUse(guid), guid => LiveSession?.SendUse(guid),
(item, container, placement) => (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement), LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) => (item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer( LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount), item, container, placement, amount),
(source, target, amount) => (source, target, amount) =>
_liveSession?.SendStackableMerge(source, target, amount), LiveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController, _itemInteractionController,
_selection), _selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings( ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
@ -2342,11 +2334,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
iconComposer.GetDragIcon(type, icon, under, over, effects), iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController, _itemInteractionController,
_selection, _selection,
guid => _liveSession?.SendUse(guid), guid => LiveSession?.SendUse(guid),
(item, container, placement) => (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement), LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) => (item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer( LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount), item, container, placement, amount),
IsWithinExternalContainerUseRange), IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings( Cursor: new AcDream.App.UI.RetailUiCursorBindings(
@ -2354,7 +2346,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
retailCursorManager), retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings( Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) => (type, context, accepted) =>
_liveSession?.SendConfirmationResponse(type, context, accepted)), LiveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity, StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry, Plugins: _uiRegistry,
Persistence: persistence, Persistence: persistence,
@ -2704,7 +2696,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_worldSelectionQuery, _worldSelectionQuery,
itemInteractions, itemInteractions,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
() => _liveSession), () => LiveSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink( new AcDream.App.Interaction.PlayerInteractionMovementSink(
() => _playerController), () => _playerController),
text => _debugVm?.AddToast(text)); text => _debugVm?.AddToast(text));
@ -2854,107 +2846,63 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged. // gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveCenterX = centerX; _liveCenterX = centerX;
_liveCenterY = centerY; _liveCenterY = centerY;
TryStartLiveSession();
}
private void TryStartLiveSession()
{
ClearInboundEntityState();
// Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
// The controller owns DNS resolution + WorldSession instantiation + the
// wireEvents callback; this method keeps the Connect → CharacterList →
// EnterWorld → post-setup dance because those touch GameWindow state.
_liveSessionController = new AcDream.App.Net.LiveSessionController(); _liveSessionController = new AcDream.App.Net.LiveSessionController();
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); AcDream.App.Net.LiveSessionStartResult liveStart =
if (_liveSession is null) _liveSessionController.Start(
_options,
CreateLiveSessionLifecycleHost());
switch (liveStart.Status)
{ {
try case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
{ Console.WriteLine(
DisposeLiveSessionRouting(); "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
} break;
finally case AcDream.App.Net.LiveSessionStartStatus.Failed:
{ Console.WriteLine($"live: session failed: {liveStart.Error}");
_liveSessionController.Dispose(); break;
_liveSessionController = null;
}
return;
}
var user = _options.LiveUser!;
var pass = _options.LivePass!;
var host = _options.LiveHost;
var port = _options.LivePort;
try
{
Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
_liveSession.Connect(user, pass);
Chat.OnSystemMessage("connected — character list received", chatType: 1);
if (_liveSession.Characters is null
|| !AcDream.Core.Net.Messages.CharacterList.TrySelectFirstAvailable(
_liveSession.Characters,
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
{
Console.WriteLine("live: no available characters on account; disconnecting");
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
return;
}
var chosen = selection.Character;
_playerServerGuid = chosen.Id;
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
Chat.SetLocalPlayerGuid(chosen.Id);
_worldState.MarkPersistent(chosen.Id);
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
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();
SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
}
_playerModeAutoEntry?.Arm();
Console.WriteLine($"live: in world — CreateObject stream active " +
$"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
}
catch (Exception ex)
{
Console.WriteLine($"live: session failed: {ex.Message}");
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController?.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
} }
} }
private void ClearInboundEntityState() private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
Bind: CreateLiveSessionBinding,
Reset: () =>
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
Chat.OnSystemMessage(
"connected — character list received",
chatType: 1),
Selected: ApplyLiveSessionSelection,
Entered: ApplyLiveSessionEnteredWorld));
private void ApplyLiveSessionSelection(
AcDream.App.Net.LiveSessionCharacterSelection selection)
{ {
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(); _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);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
}
_playerModeAutoEntry?.Arm();
} }
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() => private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
@ -3087,12 +3035,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// Registration completes before Connect; outbound commands remain inert /// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds. /// until EnterWorld succeeds.
/// </summary> /// </summary>
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
AcDream.Core.Net.WorldSession session)
{ {
_liveSession = session;
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
throw new InvalidOperationException("live-session routing is already bound");
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u); var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null) if (_characterSheetProvider is not null && _dats is not null)
{ {
@ -3123,8 +3068,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
commands = new AcDream.App.Net.LiveSessionCommandRouter( commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session)); CreateLiveSessionCommandBindings(session));
_liveSessionEvents = events; return new AcDream.App.Net.LiveSessionBinding(
_liveSessionCommands = commands; session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
} }
catch catch
{ {
@ -3311,22 +3260,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
roomId, chatType, dispatchType, senderGuid, text, cookie), roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine); 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();
}
}
private enum LiveProjectionPurpose private enum LiveProjectionPurpose
{ {
LogicalRegistration, LogicalRegistration,
@ -4936,8 +4869,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
uint guid, uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps) AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
{ {
if (guid != _playerServerGuid || _liveSession is null) return; AcDream.Core.Net.WorldSession? session = LiveSession;
_liveSession.PublishAcceptedLocalPhysicsTimestamps( if (guid != _playerServerGuid || session is null) return;
session.PublishAcceptedLocalPhysicsTimestamps(
timestamps.Instance, timestamps.Instance,
timestamps.ServerControlledMove, timestamps.ServerControlledMove,
timestamps.Teleport, timestamps.Teleport,
@ -6460,7 +6394,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// </summary> /// </summary>
private void SendImmediateLocalPositionEvent() private void SendImmediateLocalPositionEvent()
{ {
if (_liveSession is null AcDream.Core.Net.WorldSession? session = LiveSession;
if (session is null
|| _playerController is null || _playerController is null
|| !_playerController.CanSendPositionEvent) || !_playerController.CanSendPositionEvent)
{ {
@ -6477,18 +6412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return; return;
} }
uint sequence = _liveSession.NextGameActionSequence(); uint sequence = session.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
gameActionSequence: sequence, gameActionSequence: sequence,
cellId: cellId, cellId: cellId,
position: position, position: position,
rotation: rotation, rotation: rotation,
instanceSequence: _liveSession.InstanceSequence, instanceSequence: session.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence, serverControlSequence: session.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence, teleportSequence: session.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence, forcePositionSequence: session.ForcePositionSequence,
lastContact: 1); lastContact: 1);
_liveSession.SendGameAction(body); session.SendGameAction(body);
_playerController.NotePositionSent( _playerController.NotePositionSent(
new AcDream.Core.Physics.Position(cellId, position, rotation), new AcDream.Core.Physics.Position(cellId, position, rotation),
_playerController.ContactPlane, _playerController.ContactPlane,
@ -9409,8 +9344,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the // The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
// draw site) is unchanged — the pre-entry screen still shows sky // draw site) is unchanged — the pre-entry screen still shows sky
// only. // only.
bool liveInWorld = _liveSession is not null bool liveInWorld = _liveSessionController?.IsInWorld == true;
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld;
// #192: liveInWorld alone used to be sufficient here — but InWorld fires // #192: liveInWorld alone used to be sufficient here — but InWorld fires
// right after the login handshake, before the player's own spawn // right after the login handshake, before the player's own spawn
// CreateObject (and hence the real center) has arrived. Streaming that // CreateObject (and hence the real center) has arrived. Streaming that
@ -9456,8 +9390,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
} }
else if (_liveSession is not null else if (liveInWorld)
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
{ {
// Live, not yet in player mode: the login auto-entry hold, or a live // Live, not yet in player mode: the login auto-entry hold, or a live
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it // fly-camera spectator. Follow the PLAYER's server-known landblock; if it
@ -9657,7 +9590,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_playerController.State = AcDream.App.Input.PlayerState.InWorld; _playerController.State = AcDream.App.Input.PlayerState.InWorld;
// holtburger client/messages.rs:434 — re-send LoginComplete after // holtburger client/messages.rs:434 — re-send LoginComplete after
// each portal transition. // each portal transition.
_liveSession?.SendGameAction( LiveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_worldReveal?.Complete(); _worldReveal?.Complete();
ResetTeleportTransitState(); ResetTeleportTransitState();
@ -9682,7 +9615,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase K.2 — auto-enter player mode at login. The guard // Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks // returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld // are no-ops. Skipped offline (no active session → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in // predicate stays false). Cancelled by manual fly-toggle in
// OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel. // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel.
_playerModeAutoEntry?.TryEnter(); _playerModeAutoEntry?.TryEnter();
@ -11038,8 +10971,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// is up so panel-emitted SendChatCmd actually flows server-ward. // is up so panel-emitted SendChatCmd actually flows server-ward.
// Fall back to NullCommandBus for offline / pre-connect renders. // Fall back to NullCommandBus for offline / pre-connect renders.
AcDream.UI.Abstractions.ICommandBus bus = AcDream.UI.Abstractions.ICommandBus bus =
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) _liveSessionController?.Commands
AcDream.UI.Abstractions.NullCommandBus.Instance; ?? AcDream.UI.Abstractions.NullCommandBus.Instance;
var ctx = new AcDream.UI.Abstractions.PanelContext( var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds, (float)deltaSeconds,
bus); bus);
@ -12519,7 +12452,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as // L.0: settings.json store + active toon key. The store is held as
// a field so BeginLiveSessionAsync can re-load the chosen toon's // a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as // bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the // "default" and gets swapped to the actual character name on the
// first EnterWorld. // first EnterWorld.
@ -12654,7 +12587,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_persistedGameplay = _settingsStore.LoadGameplay(); _persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat(); _persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login // _activeToonKey is "default" pre-EnterWorld; the post-login
// branch in BeginLiveSessionAsync swaps to the chosen toon's // ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext. // name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
@ -13153,8 +13086,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void ToggleLiveCombatMode() private void ToggleLiveCombatMode()
{ {
if (_liveSession is null AcDream.Core.Net.WorldSession? session = LiveSession;
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) if (_liveSessionController?.IsInWorld != true || session is null)
return; return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment = IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
@ -13165,7 +13098,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Combat.CurrentMode, Combat.CurrentMode,
defaultMode); defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest(); _itemInteractionController?.NotifyExplicitCombatModeRequest();
_liveSession.SendChangeCombatMode(nextMode); session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode); Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}"; string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}"); Console.WriteLine($"combat: {text}");
@ -13185,8 +13118,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool CanStartLiveCombatAttack() private bool CanStartLiveCombatAttack()
{ {
if (_liveSession is null if (_liveSessionController?.IsInWorld != true)
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return false; return false;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
@ -13214,16 +13146,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (!CanStartLiveCombatAttack()) if (!CanStartLiveCombatAttack())
return false; return false;
AcDream.Core.Net.WorldSession? session = LiveSession;
if (session is null)
return false;
uint target = _selection.SelectedObjectId!.Value; uint target = _selection.SelectedObjectId!.Value;
power = Math.Clamp(power, 0f, 1f); power = Math.Clamp(power, 0f, 1f);
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile) if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{ {
_liveSession!.SendMissileAttack(target, height, power); session.SendMissileAttack(target, height, power);
Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
} }
else else
{ {
_liveSession!.SendMeleeAttack(target, height, power); session.SendMeleeAttack(target, height, power);
Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
} }
return true; return true;
@ -13252,7 +13188,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
=> _localPlayerOutbound.TrySendMovement( => _localPlayerOutbound.TrySendMovement(
_liveSession, LiveSession,
_playerController, _playerController,
result); result);
@ -13291,11 +13227,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// sends directly without speculative TurnToObject/MoveToObject. // sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid) private void UseItemByGuid(uint guid)
{ {
if (_liveSession is null AcDream.Core.Net.WorldSession? session = LiveSession;
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) if (_liveSessionController?.IsInWorld != true || session is null)
return; return;
uint sequence = _liveSession.NextGameActionSequence(); uint sequence = session.NextGameActionSequence();
_liveSession.SendGameAction( session.SendGameAction(
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid)); AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}"); Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
} }
@ -13337,8 +13273,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void TogglePlayerMode() private void TogglePlayerMode()
{ {
// Phase B.2 guard: only active when a live session is in-world. // Phase B.2 guard: only active when a live session is in-world.
if (_liveSession is null if (_liveSessionController?.IsInWorld != true)
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return; return;
// Manual toggle pre-empts the K.2 auto-entry trigger regardless // Manual toggle pre-empts the K.2 auto-entry trigger regardless
@ -14223,14 +14158,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_magicCatalog = null; _magicCatalog = null;
}), }),
new("streamer", () => _streamer?.Dispose()), new("streamer", () => _streamer?.Dispose()),
new("live session routing", DisposeLiveSessionRouting),
new("equipped children", () => _equippedChildRenderer?.Dispose()),
new("live session", () => new("live session", () =>
{ {
_liveSessionController?.Dispose(); _liveSessionController?.Dispose();
_liveSessionController = null; _liveSessionController = null;
_liveSession = null;
}), }),
new("equipped children", () => _equippedChildRenderer?.Dispose()),
]), ]),
// Retained tombstones are retried while every callback owner is alive. // Retained tombstones are retried while every callback owner is alive.
new ResourceShutdownStage("live entities", new ResourceShutdownStage("live entities",

View file

@ -0,0 +1,37 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Net;
using AcDream.Core.Net;
namespace AcDream.App.Tests.Net;
public sealed class GameWindowLiveSessionOwnershipTests
{
private const BindingFlags PrivateInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
[Fact]
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
{
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
Assert.Contains(
fields,
field => field.Name == "_liveSessionController"
&& field.FieldType == typeof(LiveSessionController));
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
}
[Theory]
[InlineData("TryStartLiveSession")]
[InlineData("ClearInboundEntityState")]
[InlineData("WireLiveSessionEvents")]
[InlineData("DisposeLiveSessionRouting")]
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
}
}

View file

@ -0,0 +1,110 @@
using System.Net;
using AcDream.App.Net;
using AcDream.Core.Net;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionLifecycleHostTests
{
[Fact]
public void HostRoutesLifecycleAndReleasesOnlyTheExactBoundSession()
{
var calls = new List<string>();
using var sessionA = CreateSession(9000);
using var sessionB = CreateSession(9001);
var host = CreateHost(calls);
LiveSessionBinding binding = host.BindSession(sessionA);
host.ResetSessionState();
host.ReportConnecting("host", 9000, "user");
host.ReportConnected();
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
host.ApplySelectedCharacter(selection);
binding.ActivateCommands();
host.ApplyEnteredWorld(selection);
Assert.Throws<InvalidOperationException>(() => host.DetachSession(sessionB));
binding.Dispose();
host.DetachSession(sessionA);
LiveSessionBinding replacement = host.BindSession(sessionB);
Assert.Equal(
[
"bind", "reset", "connecting:host:9000:user",
"connected", "selected:toon", "activate", "entered:toon",
"deactivate", "detach-events", "bind",
],
calls);
replacement.Dispose();
host.DetachSession(sessionB);
}
[Fact]
public void FailedBindingFactoryDoesNotClaimTheHost()
{
var calls = new List<string>();
using var session = CreateSession(9000);
bool fail = true;
LiveSessionLifecycleHost host = CreateHost(calls, _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("bind failure");
}
return CreateBinding(session, calls);
});
Assert.Throws<InvalidOperationException>(() => host.BindSession(session));
LiveSessionBinding retry = host.BindSession(session);
retry.Dispose();
host.DetachSession(session);
}
private static LiveSessionLifecycleHost CreateHost(
List<string> calls,
Func<WorldSession, LiveSessionBinding>? bind = null) =>
new(new LiveSessionLifecycleBindings(
Bind: bind ?? (session => CreateBinding(session, calls)),
Reset: () => calls.Add("reset"),
Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"),
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
private static LiveSessionBinding CreateBinding(
WorldSession session,
List<string> calls)
{
calls.Add("bind");
return new LiveSessionBinding(
session,
NullCommandBus.Instance,
activateCommands: () => calls.Add("activate"),
deactivateCommands: () => calls.Add("deactivate"),
detachEvents: () => calls.Add("detach-events"));
}
private static WorldSession CreateSession(int port) =>
new(
new IPEndPoint(IPAddress.Loopback, port),
new TestTransport());
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return null;
}
public void Dispose() { }
}
}