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
+61 -28
View File
@@ -1,69 +1,102 @@
import { describe, expect, it } from 'vitest';
import { parseServerName, T5_MAP_ALIASES } from '../../src/lib/server/fleet/map-aliases.js';
import {
parseServerName,
t5ZombieMapBySlug,
T5_ZOMBIE_MAPS
} 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({
describe('parseServerName (new naming: t5_<mode>_<slug>_<n>)', () => {
it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => {
expect(parseServerName('t5_zombie_cosmodrome_1')).toEqual({
game: 't5',
mapAlias: 'ascension',
gameMode: 'zombie',
mapSlug: 'cosmodrome',
mapCanonical: 'zombie_cosmodrome',
lobby: 1
});
expect(parseServerName('t5_kino_1')).toEqual({
expect(parseServerName('t5_zombie_theater_2')).toEqual({
game: 't5',
mapAlias: 'kino',
gameMode: 'zombie',
mapSlug: 'theater',
mapCanonical: 'zombie_theater',
lobby: 1
lobby: 2
});
expect(parseServerName('t5_cotd_1')).toEqual({
expect(parseServerName('t5_zombie_coast_1')).toEqual({
game: 't5',
mapAlias: 'cotd',
gameMode: 'zombie',
mapSlug: 'coast',
mapCanonical: 'zombie_coast',
lobby: 1
});
});
it('joins multi-segment map aliases and matches the alias table', () => {
expect(parseServerName('t5_shi_no_numa_1')).toEqual({
it('joins multi-segment slugs and matches the catalog', () => {
// 'shinonuma' is written in-catalog as one word, so a name with
// separators still resolves via join-without-separators lookup.
expect(parseServerName('t5_zombie_shi_no_numa_1')).toEqual({
game: 't5',
mapAlias: 'shinonuma',
gameMode: 'zombie',
mapSlug: '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({
it('treats an absent lobby number as null', () => {
expect(parseServerName('t5_zombie_moon')).toEqual({
game: 't5',
mapAlias: 'moon',
gameMode: 'zombie',
mapSlug: '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({
it('returns mapCanonical=null for unknown slugs so operators can spot the gap', () => {
expect(parseServerName('t5_zombie_newmap_1')).toEqual({
game: 't5',
mapAlias: 'newmap',
gameMode: 'zombie',
mapSlug: 'newmap',
mapCanonical: null,
lobby: 1
});
});
it('parses legacy names without a mode segment (mode=null)', () => {
// Not the new format, but a name like 't5_theater_1' still returns
// meaningful info so the seeder can surface it during migration.
const parsed = parseServerName('t5_theater_1');
expect(parsed).toEqual({
game: 't5',
gameMode: null,
mapSlug: 'theater',
mapCanonical: 'zombie_theater',
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);
describe('t5ZombieMapBySlug', () => {
it('resolves each catalog slug to its canonical + friendly', () => {
for (const entry of T5_ZOMBIE_MAPS) {
const found = t5ZombieMapBySlug(entry.slug);
expect(found?.canonical).toBe(entry.canonical);
expect(found?.friendly).toBe(entry.friendly);
}
});
it('is case-insensitive', () => {
expect(t5ZombieMapBySlug('COSMODROME')?.canonical).toBe('zombie_cosmodrome');
});
it('every catalog entry canonical starts with zombie', () => {
for (const entry of T5_ZOMBIE_MAPS) {
expect(entry.canonical.startsWith('zombie')).toBe(true);
}
});
});