refactor(app): extract LiveSessionController for network-side session lifecycle (Step 2)

Step 2 of the extraction sequence in docs/architecture/code-structure.md
§4. Lifts DNS resolution + WorldSession instantiation + wireEvents
callback + per-frame Tick + Dispose out of GameWindow.TryStartLiveSession
into a dedicated AcDream.App.Net.LiveSessionController.

What moves:
  - DNS resolution (IPAddress.TryParse + Dns.GetHostAddresses fallback,
    IPv4 preferred) → LiveSessionController.ResolveEndpoint
  - WorldSession instantiation → LiveSessionController.CreateAndWire
  - "live: connecting to ..." console line → CreateAndWire
  - Try/catch around the setup phase → CreateAndWire (separate from the
    Connect/EnterWorld try block that stays in GameWindow)
  - Per-frame _liveSession?.Tick() → _liveSessionController?.Tick()
  - OnClosing _liveSession?.Dispose() → _liveSessionController?.Dispose()

What stays in GameWindow:
  - The 25+ event subscriptions (extracted into a new private
    WireLiveSessionEvents method that the controller invokes via callback)
  - The Connect → CharacterList → EnterWorld → post-EnterWorld setup dance
    (touches Chat, _playerServerGuid, _vitalsVm, _worldState, _settingsStore,
    _settingsVm, _playerModeAutoEntry; moving these would balloon Step 2's
    scope and risk surface)
  - All 60+ outbound _liveSession.Send* call sites (touch the field by
    name; LiveSessionController.Session is the controller-side mirror)

The _liveSession field remains as a convenience handle synced with
_liveSessionController.Session; it tracks the same WorldSession instance.

Behavior preservation:
  - Same DNS-resolution sequence, same "live: connecting to ..." line,
    same wiring-vs-Connect ordering as pre-refactor.
  - Same 25+ event subscriptions in the same order, byte-for-byte.
  - Same Connect/EnterWorld error handling (the catch block stays in
    GameWindow because it disposes _combatChatTranslator which is also
    a GameWindow field).

Closes #76.

One subtle nullable-flow fix the compiler required: the chat-bus
lambda's `var liveSession = _liveSession;` capture became
`var liveSession = session;` (the non-null parameter) so the compiler
can prove non-null inside the lambda body. Both pointed to the same
WorldSession instance; only the static analysis changed.

Verification:
  - dotnet build green
  - dotnet test: AcDream.App.Tests 10/10, Core.Net.Tests 294/294,
    UI.Abstractions.Tests 419/419 — all green. Core.Tests 1073/1081
    (same 8 pre-existing physics failures as baseline; unrelated).
  - Live ACE session against +Acdream verified end-to-end:
    * Connection + handshake + EnterWorld
    * Door double-click → OnLiveMotionUpdated round-trip
      (cmd=0x000B open / cmd=0x000C close)
    * NPC double-click → outbound Use
    * Ground item F-key pickup × 4 successful (Amaranth, Comfrey,
      Damiana, Dragonsblood)
    * Spawn stream + chat channels + remote-entity motion all healthy
    * Clean OnClosing → controller.Dispose

Walking-range auto-walk + pickup-overshoot bugs observed during
verification are pre-existing (filed as #77); they live in
PlayerMovementController.DriveServerAutoWalk / threshold logic
which this refactor did not touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-17 16:15:57 +02:00
parent 4f3b8a6824
commit 0b25df53df
2 changed files with 194 additions and 79 deletions

View file

@ -0,0 +1,114 @@
using System;
using System.Net;
using System.Net.Sockets;
using AcDream.Core.Net;
namespace AcDream.App.Net;
/// <summary>
/// Owns the network-side lifecycle of a live <see cref="WorldSession"/> —
/// DNS resolution, endpoint construction, session instantiation, per-frame
/// <c>Tick</c>, and disposal. The post-construction work (event wiring,
/// <c>Connect</c>, character validation, <c>EnterWorld</c>, post-login UI
/// state setup) stays in <c>GameWindow</c> for now because it touches
/// renderer / chat / player-controller state that hasn't been extracted yet.
/// </summary>
/// <remarks>
/// <para>
/// Step 2 of the extraction sequence described in
/// <c>docs/architecture/code-structure.md</c> §4. Future expansions can
/// fold more of <c>TryStartLiveSession</c> into this controller as the
/// surrounding state (event handlers, command bus, settings VM) gets
/// extracted in later steps.
/// </para>
/// <para>
/// <strong>Behavior preservation contract:</strong> 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.
/// </para>
/// </remarks>
public sealed class LiveSessionController : IDisposable
{
/// <summary>
/// Active session, or <see langword="null"/> when offline / before
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
/// </summary>
public WorldSession? Session { get; private set; }
/// <summary>
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
/// hands it to <paramref name="wireEvents"/> for caller-side event
/// subscriptions, and returns the live session. The caller is
/// responsible for the subsequent <c>Connect</c> /
/// <c>EnterWorld</c> dance.
/// </summary>
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> 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;
}
}
/// <summary>
/// Drains the inbound network queue. Proxies to
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
/// is <see langword="null"/>.
/// </summary>
public void Tick() => Session?.Tick();
/// <summary>
/// Tears down the live session. Safe to call multiple times.
/// </summary>
public void Dispose()
{
Session?.Dispose();
Session = null;
}
/// <summary>
/// Resolve a host string (literal IP or DNS name) to an
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
/// try <see cref="IPAddress.TryParse"/> first, fall back to
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
/// IPv4 UDP exclusively), throw on empty resolution.
/// </summary>
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);
}
}