diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 9204f8b4..13acfa40 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -216,7 +216,6 @@ public sealed class LiveSessionController : IDisposable private SessionScope? _retiredScope; private ILiveSessionLifecycleHost? _pendingInitialResetHost; private PendingOperation? _pendingOperation; - private WorldSession? _legacySession; private int _operationDepth; private bool _inWorld; private bool _disposeRequested; @@ -236,12 +235,9 @@ public sealed class LiveSessionController : IDisposable public WorldSession? CurrentSession { - get { lock (_gate) return _scope?.Session ?? _legacySession; } + get { lock (_gate) return _scope?.Session; } } - /// Temporary Slice-3 compatibility alias; removed at GameWindow cutover. - public WorldSession? Session => CurrentSession; - public ICommandBus Commands { get @@ -319,11 +315,6 @@ public sealed class LiveSessionController : IDisposable { if (_disposed) return; - if (_legacySession is not null && _scope is null) - { - _legacySession.Tick(); - return; - } if (!_inWorld || _scope is null || _operationDepth != 0) return; @@ -629,11 +620,6 @@ public sealed class LiveSessionController : IDisposable private void DisposeCore() { StopCore(); - if (_legacySession is { } legacy) - { - _operations.DisposeSession(legacy); - _legacySession = null; - } _disposed = true; } @@ -649,40 +635,4 @@ public sealed class LiveSessionController : IDisposable 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 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; - } - } - } } diff --git a/src/AcDream.App/Net/LiveSessionLifecycleHost.cs b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs new file mode 100644 index 00000000..87596b75 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs @@ -0,0 +1,65 @@ +using AcDream.Core.Net; + +namespace AcDream.App.Net; + +internal sealed record LiveSessionLifecycleBindings( + Func Bind, + Action Reset, + Action Connecting, + Action Connected, + Action Selected, + Action Entered); + +/// +/// 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. +/// +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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f6d9bcf7..57f81411 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -813,10 +813,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool DevToolsEnabled => _options.DevTools; private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; - // Slice 3: exact session-generation owners. The command router is itself - // an ICommandBus and becomes inert before the displaced socket closes. - private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; - private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; + // 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. @@ -954,7 +952,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool _autoRunActive; // 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 // other path that pre-empts the chase camera). Skipped entirely // 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 // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. - // Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle - // (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in - // LiveSessionController. _liveSession remains as a convenience handle - // for the ~60 outbound SendXxx call sites; it tracks Controller.Session. + // 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.Core.Net.WorldSession? _liveSession; + private AcDream.Core.Net.WorldSession? LiveSession => + _liveSessionController?.CurrentSession; private int _liveCenterX; private int _liveCenterY; // #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), sendPreNetwork: (controller, movement, hidden) => _localPlayerOutbound.SendPreNetworkActions( - _liveSession, + LiveSession, controller, movement, hidden), sendPostNetwork: (controller, hidden) => _localPlayerOutbound.SendPostNetworkPosition( - _liveSession, + LiveSession, controller, hidden), objectClockDisposition: () => @@ -1324,9 +1321,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // when the entity isn't ready (the third predicate // guarantees readiness before this fires). _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( - isLiveInWorld: () => _liveSession is not null - && _liveSession.CurrentState == - AcDream.Core.Net.WorldSession.State.InWorld, + isLiveInWorld: () => _liveSessionController?.IsInWorld == true, isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), isPlayerControllerReady: () => true, // Retail SmartBox::UseTime (0x00455410) completes the player @@ -1781,7 +1776,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext try { // _activeToonKey is updated by - // BeginLiveSessionAsync after EnterWorld + // ApplyLiveSessionEnteredWorld // so saving character settings always // writes under the chosen character's // name (or "default" pre-login). @@ -2015,7 +2010,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext CanStartLiveCombatAttack, SendLiveCombatAttack, prepareAttackRequest: PreparePlayerForAttackRequest, - sendCancelAttack: () => _liveSession?.SendCancelAttack(), + sendCancelAttack: () => LiveSession?.SendCancelAttack(), isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, playerReadyForAttack: IsPlayerReadyForCombatAttack, @@ -2028,7 +2023,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainers, Objects, - guid => _liveSession?.SendNoLongerViewingContents(guid)); + guid => LiveSession?.SendNoLongerViewingContents(guid)); AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; _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 // without completing the same open transaction as double-click. sendUse: SendUse, - sendExamine: g => _liveSession?.SendAppraise(g), - sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - sendDrop: item => _liveSession?.SendDropItem(item), + sendExamine: g => LiveSession?.SendAppraise(g), + sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target), + sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask), + sendDrop: item => LiveSession?.SendDropItem(item), sendGive: (target, item, amount) => - _liveSession?.SendGiveObject(target, item, amount), + LiveSession?.SendGiveObject(target, item, amount), dragOnPlayerOpensSecureTrade: () => (_characterOptions1 & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 .DragItemOnPlayerOpensSecureTrade) != 0, toast: text => _debugVm?.AddToast(text), - readyForInventoryRequest: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true, playerOnGround: GetDebugPlayerOnGround, inNonCombatMode: () => Combat.CurrentMode == AcDream.Core.Combat.CombatMode.NonCombat, combatState: Combat, sendChangeCombatMode: mode => - _liveSession?.SendChangeCombatMode(mode), + LiveSession?.SendChangeCombatMode(mode), isComponentPack: magicCatalog.IsComponentPack, placeInBackpack: SendPickUp, backpackContainerId: () => @@ -2071,14 +2065,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // the retained controller remains alive. groundObjectId: () => _externalContainers.CurrentContainerId, sendSplitToWorld: (item, amount) => - _liveSession?.SendStackableSplitTo3D(item, amount), + LiveSession?.SendStackableSplitTo3D(item, amount), selectedObjectId: () => _selection.SelectedObjectId ?? 0u, stackSplitQuantity: _stackSplitQuantity, systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), sendSplitToContainer: (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); @@ -2101,12 +2095,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext playerGuid: () => _playerServerGuid, activeToonName: () => _activeToonKey, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, - canSendRaise: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, - sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost), - sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost), - sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), - sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits)); + canSendRaise: () => _liveSessionController?.IsInWorld == true, + sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), + sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost), + sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost), + sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits)); _magicRuntime = AcDream.App.Spells.MagicRuntime.Create( magicCatalog, SpellBook, @@ -2115,16 +2108,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext localPlayerId: () => _playerServerGuid, // SpellFormula::Randomize hashes the canonical account spelling // delivered by CharacterList. - accountName: () => _liveSession?.Characters?.AccountName + accountName: () => LiveSession?.Characters?.AccountName ?? _options.LiveUser ?? string.Empty, stopCompletely: PreparePlayerForAttackRequest, - sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId), - sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId), + sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId), + sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), incrementBusy: () => _itemInteractionController.IncrementBusyCount(), - canSend: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld); + canSend: () => _liveSessionController?.IsInWorld == true); _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; // 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), Chat: new AcDream.App.UI.ChatRuntimeBindings( retailChatVm, - () => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) - AcDream.UI.Abstractions.NullCommandBus.Instance), + () => _liveSessionController?.Commands + ?? AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, _selection, @@ -2252,13 +2244,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext guid, AcDream.Core.Selection.SelectionChangeSource.Inventory), UseItemByGuid, (tab, position, spellId) => - _liveSession?.SendAddSpellFavorite(spellId, position, tab), + LiveSession?.SendAddSpellFavorite(spellId, position, tab), (tab, spellId) => - _liveSession?.SendRemoveSpellFavorite(spellId, tab), - filters => _liveSession?.SendSpellbookFilter(filters), - spellId => _liveSession?.SendRemoveSpell(spellId), + LiveSession?.SendRemoveSpellFavorite(spellId, tab), + filters => LiveSession?.SendSpellbookFilter(filters), + spellId => LiveSession?.SendRemoveSpell(spellId), (componentId, amount) => - _liveSession?.SendSetDesiredComponentLevel(componentId, amount), + LiveSession?.SendSetDesiredComponentLevel(componentId, amount), ClientTimerNow), JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings( () => _playerController?.JumpCharge ?? default), @@ -2284,10 +2276,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => LocalPlayer.GetAttribute( AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } strength ? (int?)strength.Current : null, - () => _liveSession?.LinkStatus + () => LiveSession?.LinkStatus ?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected, ClientTimerNow, - () => _liveSession?.RequestLinkStatusPing(), + () => LiveSession?.RequestLinkStatusPing(), () => _window?.Close()), Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Objects, @@ -2299,8 +2291,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext ItemMana, ToggleLiveCombatMode, _itemInteractionController, - entry => _liveSession?.SendAddShortcut(entry), - index => _liveSession?.SendRemoveShortcut(index), + entry => LiveSession?.SendAddShortcut(entry), + index => LiveSession?.SendRemoveShortcut(index), _selection, handler => Combat.HealthChanged += handler, handler => Combat.HealthChanged -= handler, @@ -2309,11 +2301,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Combat.GetHealthPercent, Combat.HasHealth, guid => (uint)(Objects.Get(guid)?.StackSize ?? 0), - guid => _liveSession?.SendQueryHealth(guid), - guid => _liveSession?.SendQueryItemMana(guid), + guid => LiveSession?.SendQueryHealth(guid), + guid => LiveSession?.SendQueryItemMana(guid), () => _playerServerGuid, (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement)), + LiveSession?.SendPutItemInContainer(item, container, placement)), Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider), Inventory: new AcDream.App.UI.InventoryRuntimeBindings( Objects, @@ -2323,14 +2315,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => LocalPlayer.GetAttribute( AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } strength ? (int?)strength.Current : null, - guid => _liveSession?.SendUse(guid), + guid => LiveSession?.SendUse(guid), (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), (source, target, amount) => - _liveSession?.SendStackableMerge(source, target, amount), + LiveSession?.SendStackableMerge(source, target, amount), _itemInteractionController, _selection), ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings( @@ -2342,11 +2334,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext iconComposer.GetDragIcon(type, icon, under, over, effects), _itemInteractionController, _selection, - guid => _liveSession?.SendUse(guid), + guid => LiveSession?.SendUse(guid), (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), IsWithinExternalContainerUseRange), Cursor: new AcDream.App.UI.RetailUiCursorBindings( @@ -2354,7 +2346,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext retailCursorManager), Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings( (type, context, accepted) => - _liveSession?.SendConfirmationResponse(type, context, accepted)), + LiveSession?.SendConfirmationResponse(type, context, accepted)), StackSplitQuantity: _stackSplitQuantity, Plugins: _uiRegistry, Persistence: persistence, @@ -2704,7 +2696,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldSelectionQuery, itemInteractions, new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( - () => _liveSession), + () => LiveSession), new AcDream.App.Interaction.PlayerInteractionMovementSink( () => _playerController), 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. _liveCenterX = centerX; _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(); - _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); - if (_liveSession is null) + AcDream.App.Net.LiveSessionStartResult liveStart = + _liveSessionController.Start( + _options, + CreateLiveSessionLifecycleHost()); + switch (liveStart.Status) { - try - { - DisposeLiveSessionRouting(); - } - finally - { - _liveSessionController.Dispose(); - _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(); + case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials: + Console.WriteLine( + "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); + break; + case AcDream.App.Net.LiveSessionStartStatus.Failed: + Console.WriteLine($"live: session failed: {liveStart.Error}"); + break; } } - 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() => @@ -3087,12 +3035,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// Registration completes before Connect; outbound commands remain inert /// until EnterWorld succeeds. /// - 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(0x0E000004u); 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( CreateLiveSessionCommandBindings(session)); - _liveSessionEvents = events; - _liveSessionCommands = commands; + return new AcDream.App.Net.LiveSessionBinding( + session, + commands, + activateCommands: commands.Activate, + deactivateCommands: commands.Dispose, + detachEvents: events.Dispose); } catch { @@ -3311,22 +3260,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private void DisposeLiveSessionRouting() - { - AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands; - AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents; - _liveSessionCommands = null; - _liveSessionEvents = null; - try - { - commands?.Dispose(); - } - finally - { - events?.Dispose(); - } - } - private enum LiveProjectionPurpose { LogicalRegistration, @@ -4936,8 +4869,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext uint guid, AcDream.App.World.AcceptedPhysicsTimestamps timestamps) { - if (guid != _playerServerGuid || _liveSession is null) return; - _liveSession.PublishAcceptedLocalPhysicsTimestamps( + AcDream.Core.Net.WorldSession? session = LiveSession; + if (guid != _playerServerGuid || session is null) return; + session.PublishAcceptedLocalPhysicsTimestamps( timestamps.Instance, timestamps.ServerControlledMove, timestamps.Teleport, @@ -6460,7 +6394,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private void SendImmediateLocalPositionEvent() { - if (_liveSession is null + AcDream.Core.Net.WorldSession? session = LiveSession; + if (session is null || _playerController is null || !_playerController.CanSendPositionEvent) { @@ -6477,18 +6412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; } - uint sequence = _liveSession.NextGameActionSequence(); + uint sequence = session.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( gameActionSequence: sequence, cellId: cellId, position: position, rotation: rotation, - instanceSequence: _liveSession.InstanceSequence, - serverControlSequence: _liveSession.ServerControlSequence, - teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, lastContact: 1); - _liveSession.SendGameAction(body); + session.SendGameAction(body); _playerController.NotePositionSent( new AcDream.Core.Physics.Position(cellId, position, rotation), _playerController.ContactPlane, @@ -9409,8 +9344,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the // draw site) is unchanged — the pre-entry screen still shows sky // only. - bool liveInWorld = _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld; + bool liveInWorld = _liveSessionController?.IsInWorld == true; // #192: liveInWorld alone used to be sufficient here — but InWorld fires // right after the login handshake, before the player's own spawn // 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); observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } - else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) + else if (liveInWorld) { // 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 @@ -9657,7 +9590,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _playerController.State = AcDream.App.Input.PlayerState.InWorld; // holtburger client/messages.rs:434 — re-send LoginComplete after // each portal transition. - _liveSession?.SendGameAction( + LiveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); _worldReveal?.Complete(); ResetTeleportTransitState(); @@ -9682,7 +9615,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase K.2 — auto-enter player mode at login. The guard // 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 // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel. _playerModeAutoEntry?.TryEnter(); @@ -11038,8 +10971,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // is up so panel-emitted SendChatCmd actually flows server-ward. // Fall back to NullCommandBus for offline / pre-connect renders. AcDream.UI.Abstractions.ICommandBus bus = - _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) - AcDream.UI.Abstractions.NullCommandBus.Instance; + _liveSessionController?.Commands + ?? AcDream.UI.Abstractions.NullCommandBus.Instance; var ctx = new AcDream.UI.Abstractions.PanelContext( (float)deltaSeconds, 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.SettingsVM? _settingsVm; // 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 // "default" and gets swapped to the actual character name on the // first EnterWorld. @@ -12654,7 +12587,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _persistedGameplay = _settingsStore.LoadGameplay(); _persistedChat = _settingsStore.LoadChat(); // _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. _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); @@ -13153,8 +13086,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void ToggleLiveCombatMode() { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + AcDream.Core.Net.WorldSession? session = LiveSession; + if (_liveSessionController?.IsInWorld != true || session is null) return; IReadOnlyList orderedEquipment = @@ -13165,7 +13098,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Combat.CurrentMode, defaultMode); _itemInteractionController?.NotifyExplicitCombatModeRequest(); - _liveSession.SendChangeCombatMode(nextMode); + session.SendChangeCombatMode(nextMode); Combat.SetCombatMode(nextMode); string text = $"Combat mode {nextMode}"; Console.WriteLine($"combat: {text}"); @@ -13185,8 +13118,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool CanStartLiveCombatAttack() { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + if (_liveSessionController?.IsInWorld != true) return false; if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) @@ -13214,16 +13146,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (!CanStartLiveCombatAttack()) return false; + AcDream.Core.Net.WorldSession? session = LiveSession; + if (session is null) + return false; + uint target = _selection.SelectedObjectId!.Value; power = Math.Clamp(power, 0f, 1f); 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}"); } 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}"); } return true; @@ -13252,7 +13188,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) => _localPlayerOutbound.TrySendMovement( - _liveSession, + LiveSession, _playerController, result); @@ -13291,11 +13227,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + AcDream.Core.Net.WorldSession? session = LiveSession; + if (_liveSessionController?.IsInWorld != true || session is null) return; - uint sequence = _liveSession.NextGameActionSequence(); - _liveSession.SendGameAction( + uint sequence = session.NextGameActionSequence(); + session.SendGameAction( AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid)); 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() { // Phase B.2 guard: only active when a live session is in-world. - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + if (_liveSessionController?.IsInWorld != true) return; // Manual toggle pre-empts the K.2 auto-entry trigger regardless @@ -14223,14 +14158,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _magicCatalog = null; }), new("streamer", () => _streamer?.Dispose()), - new("live session routing", DisposeLiveSessionRouting), - new("equipped children", () => _equippedChildRenderer?.Dispose()), new("live session", () => { _liveSessionController?.Dispose(); _liveSessionController = null; - _liveSession = null; }), + new("equipped children", () => _equippedChildRenderer?.Dispose()), ]), // Retained tombstones are retried while every callback owner is alive. new ResourceShutdownStage("live entities", diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs new file mode 100644 index 00000000..8d65f140 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -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)); + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs new file mode 100644 index 00000000..a0ed5b26 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs @@ -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(); + 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(() => 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(); + 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(() => host.BindSession(session)); + LiveSessionBinding retry = host.BindSession(session); + + retry.Dispose(); + host.DetachSession(session); + } + + private static LiveSessionLifecycleHost CreateHost( + List calls, + Func? 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 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 datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +}