feat: Runtime lifecycle system, poll layout persistence

This commit is contained in:
Nuno Duque Nunes 2026-06-10 04:44:57 +01:00
parent 2cde01e633
commit 1911cbe225
10 changed files with 209 additions and 13 deletions

View file

@ -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) {

View file

@ -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 = {
/**

View file

@ -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 = {};

View file

@ -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 ───────────────────────────────────────────────────────────

View file

@ -10,6 +10,10 @@
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<string, string> | null = null;

View file

@ -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;
},
};

View file

@ -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");

175
src/systems/runtime.ts Normal file
View file

@ -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<void>;
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<void>,
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<void> {
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<void> {
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<void> {
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);
}
}
},
};

View file

@ -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 = {};

View file

@ -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 ──────────────────────────────────────────────────────────