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 { persist } from "@systems/pollPersistence"
|
||||||
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
||||||
import { Scheduler } from "@systems/scheduler";
|
import { Scheduler } from "@systems/scheduler";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||||
|
|
@ -71,11 +72,7 @@ client.on("interactionCreate", handleInteraction);
|
||||||
client.once("clientReady", async () => {
|
client.once("clientReady", async () => {
|
||||||
console.log(`Logged in as ${client.user!.tag}`);
|
console.log(`Logged in as ${client.user!.tag}`);
|
||||||
|
|
||||||
loadConfig();
|
await Runtime.start();
|
||||||
loadMessages();
|
|
||||||
Emoji.load();
|
|
||||||
Char.load();
|
|
||||||
WRank.load();
|
|
||||||
|
|
||||||
const restored = persist.load();
|
const restored = persist.load();
|
||||||
if (restored) {
|
if (restored) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger } from "@systems/logger";
|
import { Logger, logLevel } from "@systems/logger";
|
||||||
import { Config } from "@systems/config";
|
|
||||||
|
|
||||||
const log = Logger.for("benchmark");
|
const log = Logger.for("benchmark");
|
||||||
|
|
||||||
|
|
@ -55,7 +54,7 @@
|
||||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Only profile when LOG_LEVEL=debug
|
// Only profile when LOG_LEVEL=debug
|
||||||
const _enabled = () => Config.get("logLevel").toUpperCase() === "DEBUG";
|
const _enabled = () => logLevel() === "DEBUG";
|
||||||
|
|
||||||
export const Benchmark = {
|
export const Benchmark = {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
|
||||||
|
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
Runtime.phase("load", () => Char.load(), { name: "Char.load" });
|
||||||
|
|
||||||
let _chars: CharacterMap = {};
|
let _chars: CharacterMap = {};
|
||||||
let _accounts: AccountMap = {};
|
let _accounts: AccountMap = {};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { Nation, TGSlot } from "@types";
|
import { Nation, TGSlot } from "@types";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@paths";
|
import { Paths } from "@paths";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
|
||||||
|
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 });
|
||||||
|
|
||||||
// ─── Sub-interfaces ───────────────────────────────────────────────────────────
|
// ─── Sub-interfaces ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { Paths } from "@paths";
|
import { Paths } from "@paths";
|
||||||
import { Nation, ClassKey, CharacterClass } from "@types";
|
import { Nation, ClassKey, CharacterClass } from "@types";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
|
||||||
|
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
Runtime.phase("load", () => { Emoji.load(); }, { name: "Emoji.load" });
|
||||||
|
|
||||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||||
let _map: Record<string, string> | null = null;
|
let _map: Record<string, string> | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@
|
||||||
* log.debug("State:", state);
|
* log.debug("State:", state);
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Config } from "@systems/config";
|
|
||||||
|
|
||||||
// ─── Log levels ───────────────────────────────────────────────────────────────
|
// ─── Log levels ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const logLevel = () => process.env.LOG_LEVEL?.toUpperCase();
|
||||||
|
|
||||||
export enum LogLevel {
|
export enum LogLevel {
|
||||||
Debug = 0,
|
Debug = 0,
|
||||||
Info = 1,
|
Info = 1,
|
||||||
|
|
@ -70,7 +70,7 @@ function getIcon(context: string): string {
|
||||||
// ─── Global config ────────────────────────────────────────────────────────────
|
// ─── Global config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _globalLevel: LogLevel = (() => {
|
let _globalLevel: LogLevel = (() => {
|
||||||
const env = Config.get("logLevel").toUpperCase();
|
const env = logLevel();
|
||||||
switch (env) {
|
switch (env) {
|
||||||
case "DEBUG": return LogLevel.Debug;
|
case "DEBUG": return LogLevel.Debug;
|
||||||
case "WARN": return LogLevel.Warn;
|
case "WARN": return LogLevel.Warn;
|
||||||
|
|
@ -80,6 +80,7 @@ let _globalLevel: LogLevel = (() => {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
// ─── Logger instance ──────────────────────────────────────────────────────────
|
// ─── Logger instance ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface LoggerInstance {
|
export interface LoggerInstance {
|
||||||
|
|
@ -136,7 +137,7 @@ export const Logger = {
|
||||||
/**
|
/**
|
||||||
* Get the current global log level.
|
* Get the current global log level.
|
||||||
*/
|
*/
|
||||||
getLevel(): LogLevel {
|
level(): LogLevel {
|
||||||
return _globalLevel;
|
return _globalLevel;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -3,6 +3,10 @@ import path from "path";
|
||||||
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types";
|
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types";
|
||||||
import { Emoji } from "@systems/emojis";
|
import { Emoji } from "@systems/emojis";
|
||||||
import { UserRegistry } from "@registry/user-registry";
|
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 MESSAGES_DIR = path.join(__dirname, "../../messages");
|
||||||
const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json");
|
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 { Nations } from "@systems/nations";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@paths";
|
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");
|
const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
|
||||||
let _data: WRankData = {};
|
let _data: WRankData = {};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { Config } from "@systems/config";
|
||||||
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
|
||||||
|
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
Runtime.phase("restore", () => restoreLayout(), { name: "PollUI.restoreLayout" });
|
||||||
|
|
||||||
// ─── Layout registry ──────────────────────────────────────────────────────────
|
// ─── Layout registry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue