diff --git a/src/index.ts b/src/index.ts index 40f32d7..1d1968a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { TGSlot } from "@src/types"; import { persist } from "@systems/pollPersistence" import { buildTgAdminCommand } from "@commands/tgAdmin"; import { Scheduler } from "@systems/scheduler"; +import { Runtime } from "@systems/runtime"; const TOKEN = process.env.DISCORD_TOKEN!; const CLIENT_ID = process.env.CLIENT_ID!; @@ -71,11 +72,7 @@ client.on("interactionCreate", handleInteraction); client.once("clientReady", async () => { console.log(`Logged in as ${client.user!.tag}`); - loadConfig(); - loadMessages(); - Emoji.load(); - Char.load(); - WRank.load(); + await Runtime.start(); const restored = persist.load(); if (restored) { diff --git a/src/systems/benchmark.ts b/src/systems/benchmark.ts index 4ac361f..3584275 100644 --- a/src/systems/benchmark.ts +++ b/src/systems/benchmark.ts @@ -17,8 +17,7 @@ * }); */ - import { Logger } from "@systems/logger"; - import { Config } from "@systems/config"; + import { Logger, logLevel } from "@systems/logger"; const log = Logger.for("benchmark"); @@ -55,7 +54,7 @@ // ─── Namespace ──────────────────────────────────────────────────────────────── // Only profile when LOG_LEVEL=debug - const _enabled = () => Config.get("logLevel").toUpperCase() === "DEBUG"; + const _enabled = () => logLevel() === "DEBUG"; export const Benchmark = { /** diff --git a/src/systems/characters.ts b/src/systems/characters.ts index 3f7e89b..a3ab0e6 100644 --- a/src/systems/characters.ts +++ b/src/systems/characters.ts @@ -5,6 +5,10 @@ import { CLASSES, } from "@types"; import { Store } from "@systems/store"; +import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── +Runtime.phase("load", () => Char.load(), { name: "Char.load" }); let _chars: CharacterMap = {}; let _accounts: AccountMap = {}; diff --git a/src/systems/config.ts b/src/systems/config.ts index dc0a279..9be9519 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -1,6 +1,10 @@ import { Nation, TGSlot } from "@types"; import { Store } from "@systems/store"; import { Paths } from "@paths"; +import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── +Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }); // ─── Sub-interfaces ─────────────────────────────────────────────────────────── diff --git a/src/systems/emojis.ts b/src/systems/emojis.ts index 82af7bc..2addf2a 100644 --- a/src/systems/emojis.ts +++ b/src/systems/emojis.ts @@ -10,7 +10,11 @@ import fs from "fs"; import { Paths } from "@paths"; import { Nation, ClassKey, CharacterClass } from "@types"; - + import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── + Runtime.phase("load", () => { Emoji.load(); }, { name: "Emoji.load" }); + // ─── Cache ──────────────────────────────────────────────────────────────────── let _map: Record | null = null; diff --git a/src/systems/logger.ts b/src/systems/logger.ts index 676c60f..4edcbf9 100644 --- a/src/systems/logger.ts +++ b/src/systems/logger.ts @@ -12,10 +12,10 @@ * log.debug("State:", state); */ - import { Config } from "@systems/config"; - // ─── Log levels ─────────────────────────────────────────────────────────────── +export const logLevel = () => process.env.LOG_LEVEL?.toUpperCase(); + export enum LogLevel { Debug = 0, Info = 1, @@ -70,7 +70,7 @@ function getIcon(context: string): string { // ─── Global config ──────────────────────────────────────────────────────────── let _globalLevel: LogLevel = (() => { - const env = Config.get("logLevel").toUpperCase(); + const env = logLevel(); switch (env) { case "DEBUG": return LogLevel.Debug; case "WARN": return LogLevel.Warn; @@ -80,6 +80,7 @@ let _globalLevel: LogLevel = (() => { } })(); + // ─── Logger instance ────────────────────────────────────────────────────────── export interface LoggerInstance { @@ -136,7 +137,7 @@ export const Logger = { /** * Get the current global log level. */ - getLevel(): LogLevel { + level(): LogLevel { return _globalLevel; }, }; \ No newline at end of file diff --git a/src/systems/messages.ts b/src/systems/messages.ts index ff70577..023f8a8 100644 --- a/src/systems/messages.ts +++ b/src/systems/messages.ts @@ -3,6 +3,10 @@ import path from "path"; import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types"; import { Emoji } from "@systems/emojis"; import { UserRegistry } from "@registry/user-registry"; +import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── +Runtime.phase("load", () => loadMessages(), { name: "Messages.load" }); const MESSAGES_DIR = path.join(__dirname, "../../messages"); const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json"); diff --git a/src/systems/runtime.ts b/src/systems/runtime.ts new file mode 100644 index 0000000..8182ebd --- /dev/null +++ b/src/systems/runtime.ts @@ -0,0 +1,175 @@ +/** + * Runtime — manages application lifecycle phases. + * + * Usage: + * import { Runtime } from "@systems/runtime"; + * + * // Register hooks in any module: + * Runtime.phase("load", () => Config.load()); + * Runtime.phase("restore", () => restoreLayout()); + * Runtime.phase("ready", () => Scheduler.start(client)); + * + * // In index.ts: + * await Runtime.start(); + */ + + import { Logger } from "@systems/logger"; + + const log = Logger.for("runtime"); + + // ─── Types ──────────────────────────────────────────────────────────────────── + + export type RuntimePhase = + | "load" // read data from disk + | "restore" // restore in-memory state from loaded data + | "connect" // external connections (Discord client login) + | "schedule" // start cron jobs and timers + | "ready" // bot is fully operational + | "shutdown"; // graceful shutdown + + export type RuntimeState = + | "idle" + | "starting" + | "ready" + | "stopping" + | "stopped"; + + interface Hook { + phase: RuntimePhase; + fn: () => void | Promise; + priority: number; + name?: string; + } + + // ─── State ──────────────────────────────────────────────────────────────────── + + const PHASE_ORDER: RuntimePhase[] = [ + "load", + "restore", + "connect", + "schedule", + "ready", + ]; + + const SHUTDOWN_PHASES: RuntimePhase[] = ["shutdown"]; + + const _hooks: Hook[] = []; + let _state: RuntimeState = "idle"; + let _errorHandlers: ((err: Error) => void)[] = []; + + // ─── Runtime namespace ──────────────────────────────────────────────────────── + + export const Runtime = { + /** + * Register a hook for a lifecycle phase. + * Hooks within the same phase run in priority order (ascending), + * then registration order for equal priorities. + */ + phase( + phase: RuntimePhase, + fn: () => void | Promise, + options?: { priority?: number; name?: string } + ): void { + _hooks.push({ + phase, + fn, + priority: options?.priority ?? 0, + name: options?.name, + }); + }, + + /** + * Register a global error handler. + */ + on(event: "error", handler: (err: Error) => void): void { + if (event === "error") _errorHandlers.push(handler); + }, + + /** + * Current runtime state. + */ + state(): RuntimeState { + return _state; + }, + + /** + * Run all startup phases in order. + */ + async start(): Promise { + if (_state !== "idle") { + log.warn(`Runtime.start() called in state: ${_state}`); + return; + } + + _state = "starting"; + log.info("Starting..."); + + for (const phase of PHASE_ORDER) { + const hooks = _hooks + .filter((h) => h.phase === phase) + .sort((a, b) => a.priority - b.priority); + + if (hooks.length === 0) continue; + + log.debug(`Phase [${phase}] — ${hooks.length} hook(s)`); + + for (const hook of hooks) { + try { + await hook.fn(); + if (hook.name) log.debug(` ✓ ${hook.name}`); + } catch (err: any) { + log.error(`Phase [${phase}] hook failed:`, err.message); + _errorHandlers.forEach((h) => h(err)); + throw err; // halt startup on error + } + } + } + + _state = "ready"; + log.info("Ready."); + }, + + /** + * Run shutdown hooks and stop the runtime. + */ + async stop(): Promise { + if (_state === "stopped") return; + _state = "stopping"; + log.info("Shutting down..."); + + const hooks = _hooks + .filter((h) => h.phase === "shutdown") + .sort((a, b) => a.priority - b.priority); + + for (const hook of hooks) { + try { + await hook.fn(); + if (hook.name) log.debug(` ✓ ${hook.name}`); + } catch (err: any) { + log.error("Shutdown hook failed:", err.message); + } + } + + _state = "stopped"; + log.info("Stopped."); + }, + + /** + * Re-run a specific phase (e.g. "restore" on hot reload). + */ + async rerun(phase: RuntimePhase): Promise { + const hooks = _hooks + .filter((h) => h.phase === phase) + .sort((a, b) => a.priority - b.priority); + + log.debug(`Rerunning phase [${phase}] — ${hooks.length} hook(s)`); + + for (const hook of hooks) { + try { + await hook.fn(); + } catch (err: any) { + log.error(`Rerun [${phase}] hook failed:`, err.message); + } + } + }, + }; \ No newline at end of file diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 6d1ade5..0929efc 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -6,6 +6,10 @@ import { Bringer } from "@systems/bringer"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@paths"; +import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── +Runtime.phase("load", () => WRank.load(), { name: "WRank.load" }); const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); let _data: WRankData = {}; diff --git a/src/ui/poll/index.ts b/src/ui/poll/index.ts index 13afecc..d253ca5 100644 --- a/src/ui/poll/index.ts +++ b/src/ui/poll/index.ts @@ -4,6 +4,10 @@ import { Config } from "@systems/config"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; import path from "path"; import fs from "fs"; +import { Runtime } from "@systems/runtime"; + +// ─── Runtime ────────────────────────────────────────────────────────────────── +Runtime.phase("restore", () => restoreLayout(), { name: "PollUI.restoreLayout" }); // ─── Layout registry ──────────────────────────────────────────────────────────