modernuo/dev-docs/server-lifecycle.md
Kamron Batman eec37edd67
refactor(server): unify first-boot prompts into the ConfigurePrompts phase (#2477)
Stacked on #2475 (the `ConfigurePrompts` phase). Base will switch to `main` once #2475 merges.

## What

Move the engine's own first-boot prompts — data directories, listeners, server name, expansion + map selection — out of `ServerConfiguration.Load` and into **`ServerConfiguration.ConfigurePrompts()`** (`[CallPriority(0)]`), so **all** first-boot prompting (engine and content) runs through the single `AssemblyHandler.Invoke("ConfigurePrompts")` phase. `Load` now only reads/creates the config file.

## Why it's safe

- **Assembly loading uses `AssemblyDirectories` (default `./Assemblies`), not `DataDirectories`** — so assemblies load fine before the now-later data-dir prompt. This is the linchpin that makes the move possible.
- **`UOClient.Load()`** (client-file discovery via `Core.FindDataFile`) needs `DataDirectories`, so it moved *with* the data-dir prompt into `ConfigurePrompts`.
- **`Core.Expansion`** is now assigned in `ConfigurePrompts` (every non-mocked boot). Nothing between `LoadAssemblies` and that phase reads it — type initializers run lazily on first use, not during `LoadAssemblies`.
- **`[CallPriority(0)]`** keeps the engine prompts (including map selection) ahead of content prompts such as the pathfinding pre-bake (priority 50), preserving "after map selection".
- `Main.cs` already invokes the phase — **no startup-ordering edit** here.

## Tests

`Server.Tests` **708/708**, `UOContent.Tests` **418/418**, build clean. Fixtures are unaffected: they call `Load(true)` (now just reads config) and set expansion/data dirs directly; `ConfigurePrompts` is gated on `m_Mocked`.

## ⚠️ Needs first-boot runtime verification

`Main.cs` startup ordering is **not** covered by the fixture-based suite (the fixtures bypass `Main`). Please boot once with a fresh `modernuo.json` to confirm the first-boot prompt sequence (data dirs → … → expansion/maps → pathfinding pre-bake) and that `Core.Expansion` resolves correctly. Docs updated in `dev-docs/server-lifecycle.md`.
2026-06-08 05:18:46 -07:00

7.6 KiB

Server Lifecycle & Bootstrap Phases

How a ModernUO server starts, the reflection-discovered lifecycle hooks (ConfigurePrompts, Configure, Initialize), the runtime EventSink events, and which hook to use for what.

The startup orchestration lives in Projects/Server/Main.cs (Core entry point). The named phases are dispatched by AssemblyHandler.Invoke("<Name>"), which finds every public static void <Name>() (parameterless) across Core.Assembly and all loaded content assemblies and calls them — no registration required.

Startup sequence (in order)

Don't hardcode line numbers when reasoning about this — refer to the phase/method names; the ordering is what's stable.

  1. Console banner + setup — direct synchronous Console.* writes (no logging yet).
  2. ServerConfiguration.Load() — reads/creates modernuo.json. On first boot (file absent) it runs the engine's own interactive console prompts: data directories, listeners, server name, expansion + map selection. Pre-Serilog — nothing has logged yet, so the console is clean for prompts. (Load(mocked: true) skips all prompts; that's what tests use.)
  3. AssemblyHandler.LoadAssemblies(...) — loads UOContent.dll (and friends) from AssemblyDirectories (default ./Assemblies). Note: this depends on AssemblyDirectories, not DataDirectories, so it does not need the data-dir prompt to have run.
  4. AssemblyHandler.Invoke("ConfigurePrompts") — first-boot interactive prompts contributed by any assembly (engine or content). Runs after assemblies load (so content can participate) but before the first Serilog line (so prompts aren't interleaved with the async console sink). Each handler self-gates on first-boot state.
  5. First logger.Information(...) — Serilog goes live. From here on, log via the logger; the console sink is async, so anything you write with Console.* after this can interleave with log output.
  6. VerifySerialization()Timer.Init(...).
  7. AssemblyHandler.Invoke("Configure") — the main configuration phase. World is not loaded yet (no entities), but maps are registered.
  8. TileMatrixLoader.LoadTileMatrix()RegionJsonSerializer.LoadRegions().
  9. World.Load() — deserializes all items/mobiles; fires EventSink.WorldLoad.
  10. AssemblyHandler.Invoke("Initialize") — post-world phase. World entities and the tile matrix are available.
  11. NetState.Start() / PingServer.Start()EventSink.InvokeServerStarted()RunEventLoop() (the single-threaded game loop begins).

The three reflection phases — which to use

Phase Runs Use it for Don't
ConfigurePrompts() after assemblies load, before logging one-time first-boot interactive prompts; persist the answer to modernuo.json; self-gate so it asks once; skip when input is redirected log (Serilog isn't live — use Console); touch World/maps/tile data (not ready)
Configure() post-logging, pre-World command registration, reading settings (GetOrUpdateSetting), EventSink subscriptions, wiring systems anything needing loaded World entities or the tile matrix
Initialize() post-World, post-tile-matrix work needing a loaded world / tile data: decoration/generation, validation, pre-baking caches first-boot prompts (too late, and it would clobber logs)

All three are public static void <Name>(), parameterless, discovered across every loaded assembly. Within a phase, order is controlled by [CallPriority(n)] (lower runs first; default 50). Same-priority order is unspecified, so never rely on one class's Configure running before another's at the same priority — use EventSink/explicit calls for ordering.

Pre-Serilog vs post-Serilog — why ConfigurePrompts exists

Logging uses an async Serilog console sink (Serilog.Sinks.AsyncLogFactory). Once the first logger.* call fires (right after the ConfigurePrompts phase), log lines are pumped to the console from a background thread and will interleave with anything written via Console.*. Interactive prompts therefore have to run before that point. ConfigurePrompts is the only reflection phase that runs pre-logging — that is its entire reason to exist. Inside it: use Console, never the logger; and guard with Console.IsInputRedirected so headless/CI boots don't block on Console.ReadLine.

Runtime lifecycle events (EventSink)

Subscribe to these from Configure/Initialize (EventSink.<Event> += handler):

  • ServerStarted — after world load and listeners are up, at loop start.
  • WorldLoad / WorldSave — around persistence (see WorldEvents).
  • Shutdown — during shutdown.

Recipe: add a first-boot prompt

public static void ConfigurePrompts()
{
    // Ask once, and only when a human is at the console. The answer persists in modernuo.json.
    if (ServerConfiguration.GetSetting("my.feature", (string)null) != null || Console.IsInputRedirected)
    {
        return;
    }

    Console.Write("Enable my feature? [y/N] ");
    var yes = Console.ReadLine()?.Trim().StartsWith("y", StringComparison.OrdinalIgnoreCase) == true;
    ServerConfiguration.SetSetting("my.feature", yes);
}

If acting on the answer needs a loaded world / tile data, do that in Initialize() (read the setting there), not in ConfigurePrompts.

Canonical example — pathfinding pre-bake

Projects/UOContent/Engines/Pathing/PathCacheCommands.cs is the reference pairing:

  • ConfigurePrompts() — first-boot [y/N], stores pathfinding.prebakeMaps.
  • Initialize() — when set, bakes any missing/stale .swb (needs the tile matrix, so it must be Initialize, not Configure).

Testing note

Tests do not go through Main. The test fixtures (Server.Tests/UOContent.Tests TestServerInitializer) call a curated subset of phase methods directly with ServerConfiguration.Load(mocked: true), so console prompts are skipped. Consequence: changes to the startup ordering in Main.cs (including the prompt phases) are not covered by the test suite and need first-boot runtime verification.

Unified: the engine's first-boot prompts run through ConfigurePrompts

The engine's own first-boot prompts (data directories, listeners, server name, expansion + map selection) live in ServerConfiguration.ConfigurePrompts() ([CallPriority(0)]) and are discovered by the same Invoke("ConfigurePrompts") phase as content prompts — one sequence and one wiring for all first-boot prompting. ServerConfiguration.Load now only reads/creates the config file. What made this safe:

  • Assembly loading uses AssemblyDirectories (default ./Assemblies), not DataDirectories, so assemblies load fine before the (now-later) data-dir prompt.
  • UOClient.Load() (client-file discovery via Core.FindDataFile) needs DataDirectories, so it moved with the data-dir prompt into ConfigurePrompts.
  • Core.Expansion is now assigned in ConfigurePrompts (every non-mocked boot). Nothing between assembly-load and that phase reads it — type initializers run lazily on first use, not during LoadAssemblies.
  • [CallPriority(0)] keeps the engine prompts (including map selection) ahead of content prompts such as the pathfinding pre-bake (default priority 50), preserving "after map selection".

Since Main.cs startup ordering isn't covered by the fixture-based suite (see Testing note), this path is validated by a first-boot runtime check rather than tests.