import { readdir, mkdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { config, destOf, serverDir, rootOfServer } from '../config.js'; import { db } from '../db/index.js'; import { parseServerName } from './map-aliases.js'; /** * All server folders under the game roots. Used by seedServerRegistry to backfill * the servers table for any on-disk server directory that isn't yet tracked. */ export async function serverDirectories(): Promise { const names = new Set(); for (const root of new Set([config.serversRoot, config.t6ServersRoot])) { const entries = await readdir(root, { withFileTypes: true }).catch(() => []); for (const entry of entries) { if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name); } } return [...names].sort(); } /** Fleet membership is owned by the database, independent of mounted files. */ export async function allServers(): Promise { return listServers().map((server) => server.name); } /** * Match a server name against a list of glob-like patterns (supports `*` and * `?` and comma-separated lists). */ export function matchServers(all: string[], patterns: string[]): string[] { if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) { return [...all]; } const regexes = patterns .flatMap((p) => p.split(',').map((s) => s.trim()).filter(Boolean)) .map((p) => new RegExp('^' + p.replace(/[.+^$()|{}\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')); return all.filter((name) => regexes.some((r) => r.test(name))); } /** * Ensure a server has a script destination. Creates it when create=true. */ export async function ensureDest( server: string, create: boolean ): Promise<{ ok: true; path: string } | { ok: false; reason: string }> { const dest = destOf(server); if (existsSync(dest)) { try { const s = await stat(dest); if (!s.isDirectory()) return { ok: false, reason: 'dest is not a directory' }; } catch (err) { return { ok: false, reason: `stat failed: ${(err as Error).message}` }; } return { ok: true, path: dest }; } if (!create) return { ok: false, reason: 'no script folder — use create=true' }; await mkdir(dest, { recursive: true }); return { ok: true, path: dest }; } // -- server registry -- export interface ServerRow { name: string; komodo_type: 'deployment' | 'stack' | 'container'; komodo_name: string; komodo_server: string | null; container_state: string | null; container_checked_at: string | null; desired_state: 'running' | 'stopped'; game_port: number | null; rcon_host: string | null; game: string | null; map: string | null; last_players: number | null; last_checked_at: string | null; last_map: string | null; last_gametype: string | null; last_roster: string | null; last_seen_at: string | null; last_error: string | null; last_reply_snippet: string | null; game_mode: string | null; map_slug: string | null; host_dir: string | null; config_file: string | null; game_id: string | null; image: string | null; created_by: string | null; destroyed_at: string | null; active_since: string | null; inactive_since: string | null; game_started_at: string | null; game_ended_at: string | null; game_cycle_key: string | null; created_at: string; } export async function seedServerRegistry(): Promise { // Explicit legacy import only; never run as part of application startup. const rows = await serverDirectories(); // Under the new naming scheme Komodo entities match EC server names 1:1 // (both underscore-separated), so komodo_name = name — no transformation. const insert = db().prepare( `INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name, game, map, game_mode, map_slug, host_dir) VALUES (?, 'deployment', ?, ?, ?, ?, ?, ?)` ); // Backfill existing rows where derived columns were never set. game_mode // and map_slug are new; older rows can still surface them from the name. const backfill = db().prepare( `UPDATE servers SET game = COALESCE(game, ?), map = COALESCE(map, ?), game_mode = COALESCE(game_mode, ?), map_slug = COALESCE(map_slug, ?), host_dir = COALESCE(host_dir, ?) WHERE name = ?` ); let n = 0; const tx = db().transaction((names: string[]) => { for (const name of names) { const parsed = parseServerName(name); const game = parsed?.game ?? null; const map = parsed?.mapCanonical ?? null; const gameMode = parsed?.gameMode ?? null; const mapSlug = parsed?.mapSlug ?? null; const hostDir = serverDir(name); const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir); if (res.changes > 0) { n++; } else { backfill.run(game, map, gameMode, mapSlug, hostDir, name); } if (parsed && !parsed.mapCanonical) { console.warn( `[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to the game map catalog` ); } } }); tx(rows); return n; } export function getServer(name: string): ServerRow | undefined { return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined; } export function listServers(): ServerRow[] { return db().prepare('SELECT * FROM servers WHERE destroyed_at IS NULL ORDER BY name').all() as ServerRow[]; } /** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */ export function activeBetaRemovalBlocker(server: string): string | null { const betas = db().prepare(`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote') ORDER BY b.id`).all(server) as { id: number }[]; if (!betas.length) return null; return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map(beta => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`; } export function setServerField( name: string, field: keyof ServerRow, value: string | number | null ): void { const allowed: Array = [ 'komodo_type', 'komodo_name', 'komodo_server', 'container_state', 'container_checked_at', 'game_port', 'rcon_host', 'game', 'map', 'last_players', 'last_checked_at', 'last_map', 'last_gametype', 'last_roster', 'last_seen_at', 'last_error', 'last_reply_snippet', 'game_mode', 'map_slug', 'host_dir', 'config_file', 'game_id', 'image', 'created_by', 'destroyed_at', 'active_since', 'inactive_since', 'game_started_at', 'game_ended_at', 'game_cycle_key' ]; if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); } /** * Which target rel_paths on this server are currently held by a running beta? * Returns Map. A target held by beta X blocks other betas AND * blocks unrelated writes to that same target on that server. */ export function heldByBeta(server: string): Map { const rows = db() .prepare( `SELECT s.target_rel_path AS target, b.id AS beta_id FROM beta_servers bsv JOIN betas b ON b.id = bsv.beta_id JOIN beta_scripts bs ON bs.beta_id = b.id JOIN scripts s ON s.id = bs.script_id WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')` ) .all(server) as Array<{ target: string; beta_id: number }>; const map = new Map(); for (const r of rows) map.set(r.target, r.beta_id); return map; } /** * Which running beta (if any) owns a specific target rel_path on a server? */ export function ownerOf(server: string, target: string): number | null { const row = db() .prepare( `SELECT b.id AS beta_id FROM beta_servers bsv JOIN betas b ON b.id = bsv.beta_id JOIN beta_scripts bs ON bs.beta_id = b.id JOIN scripts s ON s.id = bs.script_id WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote') LIMIT 1` ) .get(server, target) as { beta_id: number } | undefined; return row?.beta_id ?? null; }