import { readdir, mkdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { config, destOf } from '../config.js'; import { db } from '../db/index.js'; import { parseServerName } from './map-aliases.js'; /** * All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill * the servers table for any on-disk server directory that isn't yet tracked. */ export async function allServers(): Promise { let entries; try { entries = await readdir(config.serversRoot, { withFileTypes: true }); } catch { return []; } return entries .filter((e) => e.isDirectory()) .map((e) => e.name) .sort(); } /** * 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 an sp/ 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 sp 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; 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; created_at: string; } export async function seedServerRegistry(): Promise { const rows = await allServers(); // 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 = `${config.serversRoot}/${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 T5_ZOMBIE_MAPS` ); } } }); 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 ORDER BY name').all() as ServerRow[]; } export function setServerField( name: string, field: keyof ServerRow, value: string | number | null ): void { const allowed: Array = [ 'komodo_type', 'komodo_name', 'komodo_server', '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' ]; 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 b.state = 'running'` ) .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 s.target_rel_path = ? AND b.state = 'running' LIMIT 1` ) .get(server, target) as { beta_id: number } | undefined; return row?.beta_id ?? null; }