Files
electric-cherry/src/lib/server/fleet/servers.ts
T
joshandClaude Opus 4.7 590f803d93 Auto-derive server.game and server.map from the server name
Server folders encode the game and map (e.g. t5_ascension_1 = game t5, map
alias ascension, lobby 1). A T5_MAP_ALIASES table translates the friendly
alias to the canonical mapname RCON reports (ascension → zombie_cosmodrome),
covering every base + Rezurrection map. seedServerRegistry now populates
game and map at insert time and backfills existing NULL rows on every boot;
unknown aliases log a warning so we know when to add them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:35:43 -04:00

183 lines
6.1 KiB
TypeScript

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<string[]> {
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;
created_at: string;
}
export async function seedServerRegistry(): Promise<number> {
const rows = await allServers();
const insert = db().prepare(
'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name, game, map) VALUES (?, ?, ?, ?, ?)'
);
// Also backfill existing rows where game/map were never set (upgrade path
// from before name-based auto-derivation existed).
const backfill = db().prepare(
'UPDATE servers SET game = COALESCE(game, ?), map = COALESCE(map, ?) WHERE name = ? AND (game IS NULL OR map IS NULL)'
);
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 res = insert.run(name, 'deployment', name.replace(/_/g, '-'), game, map);
if (res.changes > 0) {
n++;
} else {
backfill.run(game, map, name);
}
if (parsed && !parsed.mapCanonical) {
console.warn(`[servers] unknown map alias '${parsed.mapAlias}' for ${name} — add to T5_MAP_ALIASES`);
}
}
});
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<keyof ServerRow> = [
'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'
];
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<relPath, betaId>. 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<string, number> {
const rows = db()
.prepare(
`SELECT s.target_rel_path AS target, b.id AS beta_id
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
JOIN scripts s ON s.id = b.script_id
WHERE bs.server = ? AND b.state = 'running'`
)
.all(server) as Array<{ target: string; beta_id: number }>;
const map = new Map<string, number>();
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 bs
JOIN betas b ON b.id = bs.beta_id
JOIN scripts s ON s.id = b.script_id
WHERE bs.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;
}