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:
2026-09-13 18:38:42 -04:00
co-authored by Claude Opus 4.7
parent 8c15cae80d
commit 13a29e5ace
25 changed files with 1103 additions and 123 deletions
+67 -42
View File
@@ -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 };
}