diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs new file mode 100644 index 0000000..eb714f0 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -0,0 +1,114 @@ +using System; +using System.Net; +using System.Net.Sockets; +using AcDream.Core.Net; + +namespace AcDream.App.Net; + +/// +/// Owns the network-side lifecycle of a live — +/// DNS resolution, endpoint construction, session instantiation, per-frame +/// Tick, and disposal. The post-construction work (event wiring, +/// Connect, character validation, EnterWorld, post-login UI +/// state setup) stays in GameWindow for now because it touches +/// renderer / chat / player-controller state that hasn't been extracted yet. +/// +/// +/// +/// Step 2 of the extraction sequence described in +/// docs/architecture/code-structure.md §4. Future expansions can +/// fold more of TryStartLiveSession into this controller as the +/// surrounding state (event handlers, command bus, settings VM) gets +/// extracted in later steps. +/// +/// +/// Behavior preservation contract: this class produces +/// byte-identical console output and event-wireup sequencing to the +/// pre-refactor inline code path. The DNS-resolution lines, the +/// "live: connecting to ..." line, and the wiring-vs-Connect ordering +/// all match the previous flow. +/// +/// +public sealed class LiveSessionController : IDisposable +{ + /// + /// Active session, or when offline / before + /// succeeded / after . + /// + public WorldSession? Session { get; private set; } + + /// + /// Resolves the endpoint, instantiates the , + /// hands it to for caller-side event + /// subscriptions, and returns the live session. The caller is + /// responsible for the subsequent Connect / + /// EnterWorld dance. + /// + public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents)); + + 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 + { + var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort); + Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + Session = new WorldSession(endpoint); + wireEvents(Session); + return Session; + } + catch (Exception ex) + { + Console.WriteLine($"live: session setup failed: {ex.Message}"); + Session?.Dispose(); + Session = null; + return null; + } + } + + /// + /// Drains the inbound network queue. Proxies to + /// ; no-op when + /// is . + /// + public void Tick() => Session?.Tick(); + + /// + /// Tears down the live session. Safe to call multiple times. + /// + public void Dispose() + { + Session?.Dispose(); + Session = null; + } + + /// + /// Resolve a host string (literal IP or DNS name) to an + /// . Pre-refactor logic preserved exactly: + /// try first, fall back to + /// , prefer IPv4 (ACE + retail use + /// IPv4 UDP exclusively), throw on empty resolution. + /// + private static IPEndPoint ResolveEndpoint(string host, int port) + { + IPAddress ip; + if (!IPAddress.TryParse(host, out ip!)) + { + var addrs = Dns.GetHostAddresses(host); + ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork) + ?? (addrs.Length > 0 + ? addrs[0] + : throw new Exception($"DNS resolved no addresses for '{host}'")); + Console.WriteLine($"live: resolved {host} → {ip}"); + } + return new IPEndPoint(ip, port); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6f5b9af..d1b0bef 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -738,6 +738,11 @@ public sealed class GameWindow : IDisposable // 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. + private AcDream.App.Net.LiveSessionController? _liveSessionController; private AcDream.Core.Net.WorldSession? _liveSession; private int _liveCenterX; private int _liveCenterY; @@ -1819,38 +1824,80 @@ public sealed class GameWindow : IDisposable private void TryStartLiveSession() { - if (!_options.LiveMode) return; - - var host = _options.LiveHost; - var port = _options.LivePort; - var user = _options.LiveUser; - var pass = _options.LivePass; - if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + // 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) { - Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); + _combatChatTranslator?.Dispose(); + _combatChatTranslator = null; + _liveSessionController = null; return; } + var user = _options.LiveUser!; + var pass = _options.LivePass!; + var host = _options.LiveHost; + var port = _options.LivePort; try { - // Resolve DNS names (e.g. play.coldeve.ac) as well as literal - // IP addresses. `IPAddress.Parse` throws on hostnames; fall - // back to `Dns.GetHostAddresses` and prefer IPv4 (ACE + retail - // use IPv4 UDP exclusively). - System.Net.IPAddress ip; - if (!System.Net.IPAddress.TryParse(host, out ip!)) + 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 || _liveSession.Characters.Characters.Count == 0) { - var addrs = System.Net.Dns.GetHostAddresses(host); - ip = System.Array.Find(addrs, - a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - ?? (addrs.Length > 0 ? addrs[0] : throw new System.Exception( - $"DNS resolved no addresses for '{host}'")); - Console.WriteLine($"live: resolved {host} → {ip}"); + Console.WriteLine("live: no characters on account; disconnecting"); + _liveSessionController.Dispose(); + _liveSessionController = null; + _liveSession = null; + return; } - var endpoint = new System.Net.IPEndPoint(ip, port); - Console.WriteLine($"live: connecting to {endpoint} as {user}"); - _liveSession = new AcDream.Core.Net.WorldSession(endpoint); - _liveSession.EntitySpawned += OnLiveEntitySpawned; + + var chosen = _liveSession.Characters.Characters[0]; + _playerServerGuid = chosen.Id; + _vitalsVm?.SetLocalPlayerGuid(chosen.Id); + Chat.SetLocalPlayerGuid(chosen.Id); + _worldState.MarkPersistent(chosen.Id); + Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); + _liveSession.EnterWorld(user, characterIndex: 0); + + _activeToonKey = chosen.Name; + 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}"); + _combatChatTranslator?.Dispose(); + _combatChatTranslator = null; + _liveSessionController?.Dispose(); + _liveSessionController = null; + _liveSession = null; + } + } + + /// + /// Step 2 helper: subscribes the live to all + /// the parsers / handlers / translators that GameWindow needs. + /// Called once by + /// immediately after the + /// is constructed and BEFORE any network I/O. + /// + private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) + { + _liveSession = session; + _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated; @@ -2011,7 +2058,11 @@ public sealed class GameWindow : IDisposable // plus a local echo into ChatLog so the player sees their own // message immediately. Closes over _liveSession + Chat so this // wiring only exists for the lifetime of the live session. - var liveSession = _liveSession; + // Step 2: capture the non-null `session` parameter rather than + // the nullable _liveSession field. Semantically identical (the + // field WAS set to `session` at the top of WireLiveSessionEvents) + // but the compiler can prove non-null for the lambda body. + var liveSession = session; var chat = Chat; _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); var turbineChat = TurbineChat; @@ -2119,58 +2170,6 @@ public sealed class GameWindow : IDisposable LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current); _liveSession.VitalCurrentUpdated += v => LocalPlayer.OnVitalCurrent(v.VitalId, v.Current); - - 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 || _liveSession.Characters.Characters.Count == 0) - { - Console.WriteLine("live: no characters on account; disconnecting"); - _liveSession.Dispose(); - _liveSession = null; - return; - } - - var chosen = _liveSession.Characters.Characters[0]; - _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry - _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid - Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast - _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads - Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); - _liveSession.EnterWorld(user, characterIndex: 0); - - // L.0 Character tab: swap the SettingsVM's character bag - // from the "default" pre-login bag to the actual chosen - // toon's bag. Every Save from now on writes under the - // chosen toon's name. LoadCharacterContext rebinds BOTH - // persisted + draft so HasUnsavedChanges doesn't flag the - // swap as a pending edit. - _activeToonKey = chosen.Name; - if (_settingsStore is not null && _settingsVm is not null) - { - var toonBag = _settingsStore.LoadCharacter(_activeToonKey); - _settingsVm.LoadCharacterContext(toonBag); - Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); - } - // Phase K.2: arm auto-entry. The guard's predicates won't - // pass yet — the entity stream hasn't started — but the - // OnUpdate tick re-checks every frame and fires once - // everything converges (typically 100-300 ms after EnterWorld - // returns). User can pre-empt via DebugPanel "Toggle - // Free-Fly Mode" or Tab; both call Cancel() first. - _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}"); - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSession?.Dispose(); - _liveSession = null; - } } /// @@ -6248,7 +6247,8 @@ public sealed class GameWindow : IDisposable // CreateObject events find their landblock already loaded in // GpuWorldState. Non-blocking — returns immediately if no datagrams // are in the kernel buffer. Fires EntitySpawned events synchronously. - _liveSession?.Tick(); + // Step 2: routed through the controller; functionally identical. + _liveSessionController?.Tick(); // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers @@ -10194,7 +10194,8 @@ public sealed class GameWindow : IDisposable // Phase I.7: unsubscribe combat → chat translator before the // session it depends on goes away. _combatChatTranslator?.Dispose(); - _liveSession?.Dispose(); + _liveSessionController?.Dispose(); + _liveSession = null; _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _wbDrawDispatcher?.Dispose(); _skyRenderer?.Dispose(); // depends on sampler cache; dispose first