From 590f803d93b1489fb01372cdfc937bcb39f2c419 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 13:35:43 -0400 Subject: [PATCH] Auto-derive server.game and server.map from the server name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/server/fleet/map-aliases.ts | 61 +++++++++++++++++++++++++ src/lib/server/fleet/servers.ts | 22 +++++++-- tests/unit/map-aliases.test.ts | 69 +++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/fleet/map-aliases.ts create mode 100644 tests/unit/map-aliases.test.ts diff --git a/src/lib/server/fleet/map-aliases.ts b/src/lib/server/fleet/map-aliases.ts new file mode 100644 index 0000000..5d86366 --- /dev/null +++ b/src/lib/server/fleet/map-aliases.ts @@ -0,0 +1,61 @@ +// 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. +// +// 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> = 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' +}); + +export interface ParsedServerName { + game: string; + mapAlias: string; + mapCanonical: string | null; // null when the alias isn't in T5_MAP_ALIASES + lobby: number | null; +} + +/** + * `t5_ascension_1` → { game:'t5', mapAlias:'ascension', mapCanonical:'zombie_cosmodrome', lobby:1 } + * + * Rules: + * - First segment = game (e.g. 't5') + * - Trailing all-digits segment = lobby number + * - Everything in between = map alias (joined without separators) + * + * Multi-word aliases work because we strip underscores when looking up + * (e.g. `t5_shi_no_numa_1` → alias 'shinonuma'). + */ +export function parseServerName(name: string): ParsedServerName | null { + const parts = name.split('_').filter(Boolean); + if (parts.length < 2) return 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 }; +} diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index b55bc00..470555e 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -2,6 +2,7 @@ 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 @@ -81,13 +82,28 @@ export interface ServerRow { export async function seedServerRegistry(): Promise { const rows = await allServers(); const insert = db().prepare( - 'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name) VALUES (?, ?, ?)' + '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 res = insert.run(name, 'deployment', name.replace(/_/g, '-')); - if (res.changes > 0) n++; + 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); diff --git a/tests/unit/map-aliases.test.ts b/tests/unit/map-aliases.test.ts new file mode 100644 index 0000000..920a04c --- /dev/null +++ b/tests/unit/map-aliases.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { parseServerName, T5_MAP_ALIASES } from '../../src/lib/server/fleet/map-aliases.js'; + +describe('parseServerName', () => { + it('extracts game, map alias, canonical map, and lobby from a T5 server name', () => { + expect(parseServerName('t5_ascension_1')).toEqual({ + game: 't5', + mapAlias: 'ascension', + mapCanonical: 'zombie_cosmodrome', + lobby: 1 + }); + expect(parseServerName('t5_kino_1')).toEqual({ + game: 't5', + mapAlias: 'kino', + mapCanonical: 'zombie_theater', + lobby: 1 + }); + expect(parseServerName('t5_cotd_1')).toEqual({ + game: 't5', + mapAlias: 'cotd', + mapCanonical: 'zombie_coast', + lobby: 1 + }); + }); + + it('joins multi-segment map aliases and matches the alias table', () => { + expect(parseServerName('t5_shi_no_numa_1')).toEqual({ + game: 't5', + mapAlias: 'shinonuma', + mapCanonical: 'zombie_cod5_sumpf', + lobby: 1 + }); + expect(parseServerName('t5_der_riese_2')).toEqual({ + game: 't5', + mapAlias: 'derriese', + mapCanonical: 'zombie_cod5_factory', + lobby: 2 + }); + }); + + it('treats a name with no trailing lobby number as an alias-only tail', () => { + expect(parseServerName('t5_moon')).toEqual({ + game: 't5', + mapAlias: 'moon', + mapCanonical: 'zombie_moon', + lobby: null + }); + }); + + it('returns mapCanonical=null for unknown aliases so operators can spot the gap', () => { + expect(parseServerName('t5_newmap_1')).toEqual({ + game: 't5', + mapAlias: 'newmap', + mapCanonical: null, + lobby: 1 + }); + }); + + it('rejects names with fewer than two segments', () => { + expect(parseServerName('t5')).toBeNull(); + expect(parseServerName('')).toBeNull(); + }); + + it('every alias in T5_MAP_ALIASES resolves to a zombie_* canonical mapname', () => { + for (const canonical of Object.values(T5_MAP_ALIASES)) { + expect(canonical.startsWith('zombie')).toBe(true); + } + }); +});