Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming
Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto key in the Fleet page, EC provisions the deployment via Komodo, allocates a port in 28960-28980, creates the 0o777 host dir, and starts the container. Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir, transactional DB cleanup) and surfaces any partial-failure warnings inline. Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.). Komodo entity names now match EC server names 1:1 (no more _/- transform), and the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id, image, created_by, and destroyed_at columns for provisioning + audit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import { mkdir, chmod, rm } from 'node:fs/promises';
|
||||
import { config } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { getServer } from './servers.js';
|
||||
import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js';
|
||||
import {
|
||||
komodoCreateDeployment,
|
||||
komodoDeploy,
|
||||
komodoDestroyDeployment,
|
||||
komodoDeleteDeployment,
|
||||
type CreateDeploymentSpec,
|
||||
type DeploymentEnvVar
|
||||
} from '../integrations/komodo.js';
|
||||
import { kick } from '../pipeline/worker.js';
|
||||
|
||||
// Currently every t5 zombie server uses the same config file + game id. If we
|
||||
// ever add MP or SP, split these based on mode.
|
||||
const T5_ZOMBIE_CONFIG_FILE = 'dedicated_sp.cfg';
|
||||
const T5_ZOMBIE_GAME_ID = 't5sp';
|
||||
const CONTAINER_PORT = 28960;
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'unconfigured' // Komodo creds / target server missing
|
||||
| 'unknown-map' // slug isn't in the catalog
|
||||
| 'no-slot' // all lobby slots for this map are taken
|
||||
| 'no-port' // port range exhausted
|
||||
| 'fs' // couldn't create host dir
|
||||
| 'komodo' // Komodo CreateDeployment / Deploy rejected
|
||||
| 'db'; // shouldn't happen, but wraps INSERT failures
|
||||
|
||||
export type CreateServerResult =
|
||||
| { ok: true; name: string; port: number }
|
||||
| { ok: false; kind: CreateServerFailure; message: string };
|
||||
|
||||
export type DestroyServerResult =
|
||||
| { ok: true; warnings: string[] }
|
||||
| { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||
|
||||
export interface CreateServerInput {
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey: string; // required; never persisted
|
||||
createdBy?: string; // 'ui' by default
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the lowest lobby number `n` such that `t5_<mode>_<slug>_<n>` isn't in
|
||||
* the servers table. Caps at 20 to stay inside the port range — with 20 total
|
||||
* ports across the whole fleet, no one map can hold more than that.
|
||||
*/
|
||||
function nextLobbyNumber(baseName: string): number | null {
|
||||
const rows = db()
|
||||
.prepare('SELECT name FROM servers WHERE name LIKE ?')
|
||||
.all(`${baseName}_%`) as Array<{ name: string }>;
|
||||
const taken = new Set<number>();
|
||||
const prefix = `${baseName}_`;
|
||||
for (const r of rows) {
|
||||
const tail = r.name.slice(prefix.length);
|
||||
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
||||
}
|
||||
for (let n = 1; n <= 20; n++) {
|
||||
if (!taken.has(n)) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest unused port in the configured range. Reads every server's game_port
|
||||
* from the DB (which EC controls end-to-end under the new naming scheme, so
|
||||
* the DB is authoritative — no need to ask Komodo).
|
||||
*/
|
||||
function nextAvailablePort(): number | null {
|
||||
const rows = db()
|
||||
.prepare('SELECT game_port FROM servers WHERE game_port IS NOT NULL')
|
||||
.all() as Array<{ game_port: number }>;
|
||||
const taken = new Set(rows.map((r) => r.game_port));
|
||||
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
|
||||
if (!taken.has(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEnv(args: {
|
||||
name: string;
|
||||
port: number;
|
||||
plutoKey: string;
|
||||
}): DeploymentEnvVar[] {
|
||||
return [
|
||||
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name },
|
||||
{ variable: 'PLUTAINER_PORT', value: String(args.port) },
|
||||
{ variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID },
|
||||
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE },
|
||||
{ variable: 'PLUTO_SERVER_KEY', value: args.plutoKey },
|
||||
{ variable: 'PLUTAINER_RCON_PASSWORD', value: config.rcon.password }
|
||||
];
|
||||
}
|
||||
|
||||
function buildSpec(args: {
|
||||
name: string;
|
||||
port: number;
|
||||
hostDir: string;
|
||||
plutoKey: string;
|
||||
}): CreateDeploymentSpec {
|
||||
return {
|
||||
name: args.name,
|
||||
server: config.komodo.targetServer,
|
||||
image: config.komodo.defaultImage,
|
||||
network: config.komodo.defaultNetwork,
|
||||
ports: [
|
||||
`${args.port}:${CONTAINER_PORT}/udp`,
|
||||
`${args.port}:${CONTAINER_PORT}/tcp`
|
||||
],
|
||||
volumes: [
|
||||
`${args.hostDir}:/home/plutainer/app`,
|
||||
`${config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
|
||||
],
|
||||
environment: buildEnv(args)
|
||||
};
|
||||
}
|
||||
|
||||
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
|
||||
if (input.gameMode !== 'zombie') {
|
||||
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
||||
}
|
||||
if (!config.komodo.address || !config.komodo.apiKey || !config.komodo.apiSecret) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'KOMODO_ADDRESS / KOMODO_API_KEY / KOMODO_API_SECRET must be set' };
|
||||
}
|
||||
if (!config.komodo.targetServer) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
|
||||
}
|
||||
|
||||
const mapEntry = t5ZombieMapBySlug(input.mapSlug);
|
||||
if (!mapEntry) {
|
||||
return { ok: false, kind: 'unknown-map', message: `no t5 zombie map with slug '${input.mapSlug}'` };
|
||||
}
|
||||
|
||||
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
|
||||
const lobby = nextLobbyNumber(baseName);
|
||||
if (lobby === null) {
|
||||
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
|
||||
}
|
||||
const name = `${baseName}_${lobby}`;
|
||||
|
||||
const port = nextAvailablePort();
|
||||
if (port === null) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'no-port',
|
||||
message: `no free port in ${config.portRange.min}..${config.portRange.max} — destroy an existing server first`
|
||||
};
|
||||
}
|
||||
|
||||
const hostDir = `${config.serversRoot}/${name}`;
|
||||
|
||||
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
|
||||
// after — plutainer expects to be able to write to /home/plutainer/app.
|
||||
try {
|
||||
await mkdir(hostDir, { recursive: true, mode: 0o777 });
|
||||
await chmod(hostDir, 0o777);
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// Step 2: Komodo CreateDeployment.
|
||||
const spec = buildSpec({ name, port, hostDir, plutoKey: input.plutoKey });
|
||||
const createRes = await komodoCreateDeployment(spec);
|
||||
if (!createRes.ok) {
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
return { ok: false, kind: 'komodo', message: `CreateDeployment: ${createRes.message}` };
|
||||
}
|
||||
|
||||
// Step 3: DB row — insert *before* execute/Deploy so the sweep sees the
|
||||
// server immediately even if Deploy hangs.
|
||||
try {
|
||||
db().prepare(
|
||||
`INSERT INTO servers
|
||||
(name, komodo_type, komodo_name, game, map,
|
||||
game_mode, map_slug, host_dir, config_file, game_id, image,
|
||||
game_port, created_by)
|
||||
VALUES
|
||||
(?, 'deployment', ?, 't5', ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?)`
|
||||
).run(
|
||||
name, name, mapEntry.canonical,
|
||||
input.gameMode, mapEntry.slug, hostDir,
|
||||
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage,
|
||||
port, input.createdBy ?? 'ui'
|
||||
);
|
||||
} catch (err) {
|
||||
// Roll back Komodo + FS if the DB insert fails (very unlikely — most
|
||||
// often a race where the same name got inserted twice).
|
||||
await komodoDestroyDeployment(name).catch(() => {});
|
||||
await komodoDeleteDeployment(name).catch(() => {});
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
return { ok: false, kind: 'db', message: `failed to insert server row: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// Step 4: execute/Deploy — actually start the container.
|
||||
const deployRes = await komodoDeploy(name);
|
||||
if (!deployRes.ok) {
|
||||
// Container never started — undo everything so retrying is clean.
|
||||
await komodoDeleteDeployment(name).catch(() => {});
|
||||
db().prepare('DELETE FROM servers WHERE name = ?').run(name);
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
return { ok: false, kind: 'komodo', message: `Deploy: ${deployRes.message}` };
|
||||
}
|
||||
|
||||
// Step 5: kick the sweep so RCON status polling for this server starts
|
||||
// now instead of on the next 15s tick.
|
||||
void kick().catch((err) => console.error('[createServer] post-create kick failed:', err));
|
||||
|
||||
return { ok: true, name, port };
|
||||
}
|
||||
|
||||
export async function destroyServer(name: string): Promise<DestroyServerResult> {
|
||||
const row = getServer(name);
|
||||
if (!row) {
|
||||
return { ok: false, kind: 'not-found', message: `no server named '${name}'`, warnings: [] };
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1 + 2: Komodo teardown. Try both endpoints even if one fails — the
|
||||
// deployment entity might already be gone while the container is still up.
|
||||
const destroyRes = await komodoDestroyDeployment(name);
|
||||
if (!destroyRes.ok) {
|
||||
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
|
||||
}
|
||||
const deleteRes = await komodoDeleteDeployment(name);
|
||||
if (!deleteRes.ok) {
|
||||
warnings.push(`DeleteDeployment failed: ${deleteRes.message}`);
|
||||
}
|
||||
|
||||
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
||||
// legacy rows.
|
||||
const hostDir = row.host_dir ?? `${config.serversRoot}/${name}`;
|
||||
try {
|
||||
await rm(hostDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
warnings.push(`rm ${hostDir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 4: DB row + cascading rows. ON DELETE CASCADE covers server_scripts,
|
||||
// beta_file_snapshots; the rest (deployment_servers, deployment_files,
|
||||
// beta_servers, beta_server_scripts) reference servers(name) without
|
||||
// cascade — clean them up explicitly.
|
||||
try {
|
||||
const tx = db().transaction((n: string) => {
|
||||
db().prepare('DELETE FROM deployment_servers WHERE server = ?').run(n);
|
||||
db().prepare('DELETE FROM deployment_files WHERE server = ?').run(n);
|
||||
db().prepare('DELETE FROM beta_servers WHERE server = ?').run(n);
|
||||
db().prepare('DELETE FROM beta_server_scripts WHERE server = ?').run(n);
|
||||
db().prepare('DELETE FROM servers WHERE name = ?').run(n);
|
||||
});
|
||||
tx(name);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'db',
|
||||
message: `failed to remove DB rows for ${name}: ${(err as Error).message}`,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, warnings };
|
||||
}
|
||||
@@ -1,51 +1,66 @@
|
||||
// T5 (Black Ops 1) zombies maps: friendly aliases → canonical mapname the game
|
||||
// actually reports via RCON. Server names in the fleet encode the friendly
|
||||
// alias (t5_ascension_1 → 'ascension') so we can derive game + map from the
|
||||
// folder name instead of asking the operator to fill it in.
|
||||
// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can
|
||||
// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 →
|
||||
// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome').
|
||||
//
|
||||
// Add rows here when new servers land with unrecognized aliases; the parser
|
||||
// logs unknowns so they're easy to spot in worker startup.
|
||||
export const T5_MAP_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
||||
kino: 'zombie_theater', // Kino Der Toten
|
||||
five: 'zombie_pentagon', // Five
|
||||
pentagon: 'zombie_pentagon',
|
||||
doa: 'zombietron', // Dead Ops Arcade
|
||||
deadops: 'zombietron',
|
||||
ascension: 'zombie_cosmodrome',
|
||||
cotd: 'zombie_coast', // Call Of The Dead
|
||||
coast: 'zombie_coast',
|
||||
shangri: 'zombie_temple', // Shangri-La
|
||||
shangrila: 'zombie_temple',
|
||||
temple: 'zombie_temple',
|
||||
moon: 'zombie_moon',
|
||||
nacht: 'zombie_cod5_prototype', // Nacht Der Untoten (WaW map, playable in T5 via Rezurrection)
|
||||
verruckt: 'zombie_cod5_asylum',
|
||||
asylum: 'zombie_cod5_asylum',
|
||||
shi: 'zombie_cod5_sumpf', // Shi No Numa
|
||||
shinonuma: 'zombie_cod5_sumpf',
|
||||
sumpf: 'zombie_cod5_sumpf',
|
||||
riese: 'zombie_cod5_factory', // Der Riese
|
||||
derriese: 'zombie_cod5_factory',
|
||||
factory: 'zombie_cod5_factory'
|
||||
});
|
||||
// Fields:
|
||||
// friendly — human name shown in the deploy UI dropdown
|
||||
// slug — segment used in server name (t5_zombie_<slug>_<n>)
|
||||
// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping)
|
||||
//
|
||||
// Add rows when new maps land; keep slugs short so paths stay readable.
|
||||
|
||||
export interface T5MapEntry {
|
||||
friendly: string;
|
||||
slug: string;
|
||||
canonical: string;
|
||||
}
|
||||
|
||||
export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
||||
{ friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' },
|
||||
{ friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' },
|
||||
{ friendly: 'Call of the Dead', slug: 'coast', canonical: 'zombie_coast' },
|
||||
{ friendly: 'Shangri-La', slug: 'temple', canonical: 'zombie_temple' },
|
||||
{ friendly: 'Moon', slug: 'moon', canonical: 'zombie_moon' },
|
||||
{ friendly: 'Five', slug: 'pentagon', canonical: 'zombie_pentagon' },
|
||||
{ friendly: 'Dead Ops Arcade', slug: 'tron', canonical: 'zombietron' },
|
||||
{ friendly: 'Nacht Der Untoten', slug: 'nacht', canonical: 'zombie_cod5_prototype' },
|
||||
{ friendly: 'Verrückt', slug: 'verruckt', canonical: 'zombie_cod5_asylum' },
|
||||
{ friendly: 'Shi No Numa', slug: 'shinonuma', canonical: 'zombie_cod5_sumpf' },
|
||||
{ friendly: 'Der Riese', slug: 'derriese', canonical: 'zombie_cod5_factory' }
|
||||
]);
|
||||
|
||||
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
|
||||
T5_ZOMBIE_MAPS.map((m) => [m.slug, m])
|
||||
);
|
||||
|
||||
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
||||
return BY_SLUG.get(slug.toLowerCase());
|
||||
}
|
||||
|
||||
export type GameMode = 'zombie' | 'mp' | 'sp';
|
||||
|
||||
export interface ParsedServerName {
|
||||
game: string;
|
||||
mapAlias: string;
|
||||
mapCanonical: string | null; // null when the alias isn't in T5_MAP_ALIASES
|
||||
game: string; // 't5'
|
||||
gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode
|
||||
mapSlug: string; // 'cosmodrome' — joined without separators for lookup
|
||||
mapCanonical: string | null;// 'zombie_cosmodrome' — null when slug not in catalog
|
||||
lobby: number | null;
|
||||
}
|
||||
|
||||
const KNOWN_MODES: ReadonlySet<GameMode> = new Set(['zombie', 'mp', 'sp']);
|
||||
|
||||
/**
|
||||
* `t5_ascension_1` → { game:'t5', mapAlias:'ascension', mapCanonical:'zombie_cosmodrome', lobby:1 }
|
||||
* `t5_zombie_cosmodrome_1` → { game:'t5', gameMode:'zombie', mapSlug:'cosmodrome', mapCanonical:'zombie_cosmodrome', lobby:1 }
|
||||
*
|
||||
* Rules:
|
||||
* - First segment = game (e.g. 't5')
|
||||
* - First segment = game
|
||||
* - Second segment = mode when it matches a known mode (zombie|mp|sp); otherwise the whole
|
||||
* tail is treated as legacy `<game>_<slug>_<n>` (mode null, slug from segments 1..-1)
|
||||
* - Trailing all-digits segment = lobby number
|
||||
* - Everything in between = map alias (joined without separators)
|
||||
* - Everything in between = map slug (joined without separators for the catalog lookup)
|
||||
*
|
||||
* Multi-word aliases work because we strip underscores when looking up
|
||||
* (e.g. `t5_shi_no_numa_1` → alias 'shinonuma').
|
||||
* Multi-word slugs still work because we strip underscores when looking up
|
||||
* (e.g. `t5_zombie_shi_no_numa_1` → slug 'shinonuma').
|
||||
*/
|
||||
export function parseServerName(name: string): ParsedServerName | null {
|
||||
const parts = name.split('_').filter(Boolean);
|
||||
@@ -53,9 +68,19 @@ export function parseServerName(name: string): ParsedServerName | null {
|
||||
const game = parts[0];
|
||||
const lastIsLobby = /^\d+$/.test(parts[parts.length - 1]);
|
||||
const lobby = lastIsLobby ? Number.parseInt(parts[parts.length - 1], 10) : null;
|
||||
const mapParts = lastIsLobby ? parts.slice(1, -1) : parts.slice(1);
|
||||
if (mapParts.length === 0) return null;
|
||||
const mapAlias = mapParts.join('').toLowerCase();
|
||||
const mapCanonical = T5_MAP_ALIASES[mapAlias] ?? null;
|
||||
return { game, mapAlias, mapCanonical, lobby };
|
||||
const tail = lastIsLobby ? parts.slice(1, -1) : parts.slice(1);
|
||||
if (tail.length === 0) return null;
|
||||
|
||||
let gameMode: GameMode | null = null;
|
||||
let slugParts = tail;
|
||||
const maybeMode = tail[0].toLowerCase();
|
||||
if (KNOWN_MODES.has(maybeMode as GameMode)) {
|
||||
gameMode = maybeMode as GameMode;
|
||||
slugParts = tail.slice(1);
|
||||
}
|
||||
if (slugParts.length === 0) return null;
|
||||
|
||||
const mapSlug = slugParts.join('').toLowerCase();
|
||||
const mapCanonical = BY_SLUG.get(mapSlug)?.canonical ?? null;
|
||||
return { game, gameMode, mapSlug, mapCanonical, lobby };
|
||||
}
|
||||
|
||||
@@ -76,18 +76,36 @@ export interface ServerRow {
|
||||
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<number> {
|
||||
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) VALUES (?, ?, ?, ?, ?)'
|
||||
`INSERT OR IGNORE INTO servers
|
||||
(name, komodo_type, komodo_name, game, map, game_mode, map_slug, host_dir)
|
||||
VALUES (?, 'deployment', ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
// Also backfill existing rows where game/map were never set (upgrade path
|
||||
// from before name-based auto-derivation existed).
|
||||
// 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, ?) WHERE name = ? AND (game IS NULL OR map IS NULL)'
|
||||
`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[]) => {
|
||||
@@ -95,14 +113,19 @@ export async function seedServerRegistry(): Promise<number> {
|
||||
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);
|
||||
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, name);
|
||||
backfill.run(game, map, gameMode, mapSlug, hostDir, name);
|
||||
}
|
||||
if (parsed && !parsed.mapCanonical) {
|
||||
console.warn(`[servers] unknown map alias '${parsed.mapAlias}' for ${name} — add to T5_MAP_ALIASES`);
|
||||
console.warn(
|
||||
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to T5_ZOMBIE_MAPS`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -138,7 +161,15 @@ export function setServerField(
|
||||
'last_roster',
|
||||
'last_seen_at',
|
||||
'last_error',
|
||||
'last_reply_snippet'
|
||||
'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);
|
||||
|
||||
Reference in New Issue
Block a user