feat: Runtime lifecycle system, poll layout persistence
This commit is contained in:
parent
2cde01e633
commit
1911cbe225
10 changed files with 209 additions and 13 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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
175
src/systems/runtime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue