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:
@@ -14,6 +14,19 @@ DEPLOY_KEEP_DAYS=7
|
||||
KOMODO_ADDRESS=https://komodo.example.internal
|
||||
KOMODO_API_KEY=
|
||||
KOMODO_API_SECRET=
|
||||
# Komodo server (host) that new deployments land on. Must match the exact
|
||||
# name shown on the Servers page in the Komodo UI. Required to deploy new
|
||||
# servers from the Fleet page.
|
||||
KOMODO_TARGET_SERVER=
|
||||
# Optional overrides for the plutainer defaults used when EC creates a new
|
||||
# server. Change these if you fork the image or use a different docker network.
|
||||
# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
|
||||
# PLUTAINER_NETWORK=komodo_default
|
||||
# PLUTAINER_GAMEFILES_VOLUME=t5_game_files
|
||||
# Port range EC picks from when auto-assigning a game_port to a new server.
|
||||
# Must correspond to what's actually forwarded on the Komodo host firewall.
|
||||
# SERVER_PORT_MIN=28960
|
||||
# SERVER_PORT_MAX=28980
|
||||
|
||||
# RCON — shared across the fleet (every server uses the same rcon_password).
|
||||
# Host defaults to host.docker.internal so the container can reach game
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
KOMODO_ADDRESS: ${KOMODO_ADDRESS}
|
||||
KOMODO_API_KEY: ${KOMODO_API_KEY}
|
||||
KOMODO_API_SECRET: ${KOMODO_API_SECRET}
|
||||
KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER}
|
||||
RCON_PASSWORD: ${RCON_PASSWORD}
|
||||
RCON_HOST: ${RCON_HOST:-host.docker.internal}
|
||||
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
|
||||
|
||||
@@ -31,6 +31,8 @@ DEPLOY_KEEP_DAYS = "7"
|
||||
KOMODO_ADDRESS = "https://komodo.example.internal"
|
||||
KOMODO_API_KEY = ""
|
||||
KOMODO_API_SECRET = ""
|
||||
# Host new deployments land on — must match the name shown in Komodo's UI.
|
||||
KOMODO_TARGET_SERVER = ""
|
||||
|
||||
# RCON — shared password across every T5 Plutonium server in the fleet.
|
||||
RCON_PASSWORD = ""
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main(): Promise<void> {
|
||||
await mkdir(betaArchive, { recursive: true });
|
||||
|
||||
const rel = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||
for (const name of ['t5_kino_1', 't5_ascension_1', 't5_cotd_1']) {
|
||||
for (const name of ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1', 't5_zombie_coast_1']) {
|
||||
await mkdir(join(t5, name, rel), { recursive: true });
|
||||
await mkdir(join(t5, name, 'logs'), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -33,7 +33,26 @@ export const config = {
|
||||
komodo: {
|
||||
address: env.KOMODO_ADDRESS ?? '',
|
||||
apiKey: env.KOMODO_API_KEY ?? '',
|
||||
apiSecret: env.KOMODO_API_SECRET ?? ''
|
||||
apiSecret: env.KOMODO_API_SECRET ?? '',
|
||||
// Komodo server (host) new deployments land on. Required for the
|
||||
// deploy flow — Komodo needs to know which machine to schedule the
|
||||
// container onto. Look up the exact name in Komodo's UI (Servers page).
|
||||
targetServer: env.KOMODO_TARGET_SERVER ?? '',
|
||||
// Default image + config for freshly-provisioned t5 zombie servers.
|
||||
// Overridable per-deploy in the UI later if we ever need to.
|
||||
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
|
||||
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
|
||||
// Named docker volume that holds the read-only game files layer.
|
||||
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files'
|
||||
},
|
||||
// Shared across every newly-provisioned server. Kept in sync with the
|
||||
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond
|
||||
// to the same rcon secret the sweep already uses.
|
||||
// Port range Electric Cherry auto-allocates from when creating a new
|
||||
// deployment. Update this when the host's firewall opens more.
|
||||
portRange: {
|
||||
min: num('SERVER_PORT_MIN', 28960),
|
||||
max: num('SERVER_PORT_MAX', 28980)
|
||||
},
|
||||
rcon: {
|
||||
// Host every RCON packet is sent to. Defaults to the Docker
|
||||
|
||||
@@ -28,7 +28,16 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
|
||||
// Script-scope columns — static per server, populated via server-edit UI.
|
||||
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
|
||||
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }
|
||||
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' },
|
||||
// Provisioning columns — populated when EC creates the deployment.
|
||||
{ name: 'game_mode', ddl: 'ALTER TABLE servers ADD COLUMN game_mode TEXT' },
|
||||
{ name: 'map_slug', ddl: 'ALTER TABLE servers ADD COLUMN map_slug TEXT' },
|
||||
{ name: 'host_dir', ddl: 'ALTER TABLE servers ADD COLUMN host_dir TEXT' },
|
||||
{ name: 'config_file', ddl: 'ALTER TABLE servers ADD COLUMN config_file TEXT' },
|
||||
{ name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
|
||||
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
|
||||
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' },
|
||||
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' }
|
||||
];
|
||||
|
||||
function migrateServers(handle: Database.Database): void {
|
||||
|
||||
@@ -23,6 +23,15 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
last_seen_at TEXT, -- ISO ts of last successful RCON reply
|
||||
last_error TEXT, -- last RCON failure reason (cleared on success)
|
||||
last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload
|
||||
-- Provisioning fields — set when Electric Cherry creates the deployment.
|
||||
game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only)
|
||||
map_slug TEXT, -- 'cosmodrome' — short slug used in server name
|
||||
host_dir TEXT, -- absolute host path bind-mounted into the container
|
||||
config_file TEXT, -- e.g. 'dedicated_sp.cfg' (PLUTAINER_CONFIG_FILE)
|
||||
game_id TEXT, -- e.g. 't5sp' (PLUTAINER_GAME)
|
||||
image TEXT, -- e.g. 'ghcr.io/ayymoss/plutainer:latest'
|
||||
created_by TEXT, -- 'ui' | operator identifier
|
||||
destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -407,6 +407,118 @@ export async function komodoInspectHealthy(serverName: string): Promise<KomodoHe
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Write / Execute (create + destroy deployments) ----------
|
||||
|
||||
export type KomodoWriteResult =
|
||||
| { ok: true }
|
||||
| { ok: false; kind: 'unconfigured' | 'http-error' | 'unreachable'; message: string; url?: string };
|
||||
|
||||
/**
|
||||
* Env var pairs get flattened into a single newline-delimited string, matching
|
||||
* Komodo's DeploymentConfig.environment shape.
|
||||
*/
|
||||
export interface DeploymentEnvVar {
|
||||
variable: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CreateDeploymentSpec {
|
||||
name: string; // Komodo deployment name (== EC server name)
|
||||
server: string; // Komodo server (host) id or name
|
||||
image: string; // 'ghcr.io/ayymoss/plutainer:latest'
|
||||
network?: string; // 'komodo_default'
|
||||
ports: string[]; // ['28960:28960/udp', '28960:28960/tcp']
|
||||
volumes: string[]; // ['/host/path:/container/path', 'namedvol:/container/path:ro']
|
||||
environment: DeploymentEnvVar[];
|
||||
}
|
||||
|
||||
async function postAndClassify(path: string, body: unknown): Promise<KomodoWriteResult> {
|
||||
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await komodoCall(path, body);
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'unreachable', message: describeFetchError(err, url), url };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const snippet = await res.text().catch(() => '');
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'http-error',
|
||||
message: `HTTP ${res.status}${snippet ? ` — ${truncate(snippet)}` : ''}`,
|
||||
url
|
||||
};
|
||||
}
|
||||
// Some Komodo endpoints return `{ success: false }` with a 200. Treat that
|
||||
// as a failure so callers can surface the real problem.
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
|
||||
const msg = typeof (json as { message?: unknown }).message === 'string'
|
||||
? (json as { message: string }).message
|
||||
: 'komodo returned success=false';
|
||||
return { ok: false, kind: 'http-error', message: msg, url };
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — trust the 2xx.
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new deployment entity in Komodo. Doesn't start the container —
|
||||
* caller must follow up with `komodoDeploy(name)` to actually run it.
|
||||
*
|
||||
* The config shape here targets Komodo's DeploymentConfig: newline-delimited
|
||||
* ports/volumes strings and an environment array with `{ variable, value }`
|
||||
* entries. If Komodo rejects the payload, the error surfaces in the http-error
|
||||
* message so we can iterate on the shape without guessing.
|
||||
*/
|
||||
export async function komodoCreateDeployment(spec: CreateDeploymentSpec): Promise<KomodoWriteResult> {
|
||||
if (!komodoConfigured()) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||
}
|
||||
const body = {
|
||||
name: spec.name,
|
||||
config: {
|
||||
server_id: spec.server,
|
||||
image: { type: 'Image', params: { image: spec.image } },
|
||||
network: spec.network ?? 'komodo_default',
|
||||
ports: spec.ports.join('\n'),
|
||||
volumes: spec.volumes.join('\n'),
|
||||
environment: spec.environment,
|
||||
restart: 'unless-stopped'
|
||||
}
|
||||
};
|
||||
return postAndClassify('write/CreateDeployment', body);
|
||||
}
|
||||
|
||||
/** Start (or restart) the container backing a Komodo deployment. */
|
||||
export async function komodoDeploy(deployment: string): Promise<KomodoWriteResult> {
|
||||
if (!komodoConfigured()) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||
}
|
||||
return postAndClassify('execute/Deploy', { deployment });
|
||||
}
|
||||
|
||||
/** Stop + remove the container backing a Komodo deployment. Entity remains. */
|
||||
export async function komodoDestroyDeployment(deployment: string): Promise<KomodoWriteResult> {
|
||||
if (!komodoConfigured()) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||
}
|
||||
return postAndClassify('execute/DestroyDeployment', { deployment });
|
||||
}
|
||||
|
||||
/** Remove the deployment entity from Komodo entirely. */
|
||||
export async function komodoDeleteDeployment(deployment: string): Promise<KomodoWriteResult> {
|
||||
if (!komodoConfigured()) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||
}
|
||||
// Komodo's DeleteDeployment historically accepts `{ id }` where id may be
|
||||
// the name; supply both to be forgiving.
|
||||
return postAndClassify('write/DeleteDeployment', { id: deployment, name: deployment });
|
||||
}
|
||||
|
||||
// ---------- Extractor ----------
|
||||
|
||||
// Property names Komodo/Docker use for the collection of port mappings.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface T5MapEntry {
|
||||
friendly: string;
|
||||
slug: string;
|
||||
canonical: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let maps = $state<T5MapEntry[]>([]);
|
||||
let mapsError = $state<string | null>(null);
|
||||
let mapsLoaded = $state(false);
|
||||
|
||||
let gameMode = $state<'zombie'>('zombie');
|
||||
let mapSlug = $state<string>('');
|
||||
let plutoKey = $state('');
|
||||
let deploying = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/maps');
|
||||
const body = (await res.json()) as { t5Zombie: T5MapEntry[] };
|
||||
maps = body.t5Zombie;
|
||||
if (maps.length > 0) mapSlug = maps[0].slug;
|
||||
} catch (err) {
|
||||
mapsError = (err as Error).message;
|
||||
} finally {
|
||||
mapsLoaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Track prior open value manually — using $effect with `open` triggers on
|
||||
// every render and repeatedly re-shows the modal.
|
||||
let wasOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) {
|
||||
reset();
|
||||
dialog.showModal();
|
||||
} else if (!open && wasOpen) {
|
||||
if (dialog.open) dialog.close();
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function reset(): void {
|
||||
plutoKey = '';
|
||||
flash = null;
|
||||
deploying = false;
|
||||
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
open = false;
|
||||
}
|
||||
|
||||
// Click-outside-to-close (native <dialog> only supports Esc).
|
||||
function onDialogClick(e: MouseEvent): void {
|
||||
if (!dialog) return;
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const inDialog =
|
||||
e.clientX >= rect.left && e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
if (!inDialog) close();
|
||||
}
|
||||
|
||||
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
|
||||
let previewName = $derived(
|
||||
selectedMap ? `t5_${gameMode}_${selectedMap.slug}_?` : ''
|
||||
);
|
||||
|
||||
async function submit(e: SubmitEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (deploying) return;
|
||||
if (!selectedMap) {
|
||||
flash = 'pick a map';
|
||||
return;
|
||||
}
|
||||
if (plutoKey.trim().length === 0) {
|
||||
flash = 'pluto server key is required';
|
||||
return;
|
||||
}
|
||||
deploying = true;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, plutoKey })
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
name?: string;
|
||||
port?: number;
|
||||
error?: string;
|
||||
kind?: string;
|
||||
};
|
||||
if (!body.ok || !body.name) {
|
||||
flash = body.error ?? 'deploy failed';
|
||||
deploying = false;
|
||||
return;
|
||||
}
|
||||
close();
|
||||
await goto(`/servers/${encodeURIComponent(body.name)}`);
|
||||
} catch (err) {
|
||||
flash = (err as Error).message;
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}>
|
||||
<form class="dlg" onsubmit={submit}>
|
||||
<h3>Deploy new server</h3>
|
||||
|
||||
{#if mapsError}
|
||||
<p class="flash">failed to load map catalog: {mapsError}</p>
|
||||
{:else if !mapsLoaded}
|
||||
<p class="hint">loading map catalog…</p>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<span class="row-label">Game</span>
|
||||
<div class="readonly mono">Black Ops 1 (t5)</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="mode">Mode</label>
|
||||
<select id="mode" bind:value={gameMode} disabled={deploying}>
|
||||
<option value="zombie">Zombies</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="map">Map</label>
|
||||
<select id="map" bind:value={mapSlug} disabled={deploying}>
|
||||
{#each maps as m (m.slug)}
|
||||
<option value={m.slug}>{m.friendly}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="key">Pluto server key</label>
|
||||
<input
|
||||
id="key"
|
||||
type="password"
|
||||
bind:value={plutoKey}
|
||||
placeholder="paste each deploy — not stored"
|
||||
autocomplete="off"
|
||||
disabled={deploying}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<span class="k">will create</span>
|
||||
<span class="v mono">{previewName || '—'}</span>
|
||||
<span class="sub">(lobby number + port auto-assigned)</span>
|
||||
</div>
|
||||
|
||||
<div class="dlg-actions">
|
||||
<button type="button" class="secondary" onclick={close} disabled={deploying}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={deploying || !selectedMap}>
|
||||
{deploying ? 'deploying…' : 'deploy server'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
background: var(--bg-nav);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 0;
|
||||
max-width: 520px;
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
|
||||
.dlg { display: flex; flex-direction: column; gap: 14px; padding: var(--pad-panel); }
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.row label, .row-label {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.row select, .row input {
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
width: 100%;
|
||||
}
|
||||
.row input:focus, .row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
.readonly {
|
||||
color: var(--ink-300);
|
||||
padding: 10px 0;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.preview .k {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.preview .v { color: var(--cream); }
|
||||
.preview .sub {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.dlg-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 6px; }
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
button.secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
button.secondary:disabled { opacity: 0.4; cursor: default; }
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
.hint { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
</style>
|
||||
+26
-1
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
|
||||
@@ -23,11 +24,16 @@
|
||||
}
|
||||
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let deployOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
async function refresh(): Promise<void> {
|
||||
const res = await fetch('/api/servers');
|
||||
const body = (await res.json()) as { servers: ServerRow[] };
|
||||
servers = body.servers;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Live server view — merge SSE data over the initial GET
|
||||
@@ -62,7 +68,13 @@
|
||||
<h1>Fleet</h1>
|
||||
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="hdr-actions">
|
||||
<button class="deploy" onclick={() => (deployOpen = true)}>Deploy new server</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DeployServerDialog bind:open={deployOpen} />
|
||||
|
||||
{#if servers.length === 0}
|
||||
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||
{:else}
|
||||
@@ -123,4 +135,17 @@
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.hdr-actions { display: flex; gap: 10px; }
|
||||
.deploy {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 18px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.deploy:hover { box-shadow: var(--accent-glow); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { T5_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
export function GET() {
|
||||
return json({ t5Zombie: T5_ZOMBIE_MAPS });
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
||||
import { classifyHealth } from '$lib/server/fleet/health.js';
|
||||
import { createServer } from '$lib/server/fleet/create.js';
|
||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
||||
import type { GameMode } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
function safeJson<T>(raw: string | null): T | null {
|
||||
if (!raw) return null;
|
||||
@@ -35,3 +37,34 @@ export async function GET() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
gameMode?: string;
|
||||
mapSlug?: string;
|
||||
plutoKey?: string;
|
||||
};
|
||||
if (typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || typeof body.plutoKey !== 'string') {
|
||||
return json(
|
||||
{ ok: false, error: 'gameMode, mapSlug, and plutoKey are required strings', kind: 'bad-request' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.plutoKey.trim().length === 0) {
|
||||
return json({ ok: false, error: 'plutoKey cannot be empty', kind: 'bad-request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createServer({
|
||||
gameMode: body.gameMode as GameMode,
|
||||
mapSlug: body.mapSlug,
|
||||
plutoKey: body.plutoKey,
|
||||
createdBy: 'ui'
|
||||
});
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'unconfigured' ? 503
|
||||
: result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
|
||||
: 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind }, { status });
|
||||
}
|
||||
return json({ ok: true, name: result.name, port: result.port });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { destroyServer } from '$lib/server/fleet/create.js';
|
||||
|
||||
export async function DELETE({ params }: { params: { name: string } }) {
|
||||
const name = params.name;
|
||||
if (!name) {
|
||||
return json({ ok: false, error: 'server name required', kind: 'bad-request' }, { status: 400 });
|
||||
}
|
||||
const result = await destroyServer(name);
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'not-found' ? 404 : 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
|
||||
}
|
||||
return json({ ok: true, warnings: result.warnings });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
import StatusDot from '$ui/components/StatusDot.svelte';
|
||||
@@ -111,6 +112,9 @@
|
||||
let diagnosing = $state(false);
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroying = $state(false);
|
||||
let destroyError = $state<string | null>(null);
|
||||
let destroyWarnings = $state<string[]>([]);
|
||||
|
||||
async function loadServer(): Promise<void> {
|
||||
const [srvRes, depRes, invRes] = await Promise.all([
|
||||
@@ -213,6 +217,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyThisServer(): Promise<void> {
|
||||
if (destroying || !name) return;
|
||||
const confirmed = confirm(
|
||||
`Destroy ${name}?\n\nThis stops the container, deletes the Komodo deployment, ` +
|
||||
`removes the host directory, and drops all deployment history for this server. ` +
|
||||
`Cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
destroying = true;
|
||||
destroyError = null;
|
||||
destroyWarnings = [];
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
if (!body.ok) {
|
||||
destroyError = body.error ?? 'destroy failed';
|
||||
destroyWarnings = body.warnings ?? [];
|
||||
destroying = false;
|
||||
return;
|
||||
}
|
||||
// Best-effort partial failures still deleted the server — proceed
|
||||
// to the fleet page and let the operator see the warnings there,
|
||||
// but for now just show them here if any occurred.
|
||||
if (body.warnings && body.warnings.length > 0) {
|
||||
destroyWarnings = body.warnings;
|
||||
// Give the operator a moment to notice, then navigate.
|
||||
setTimeout(() => goto('/'), 1500);
|
||||
return;
|
||||
}
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
destroyError = (err as Error).message;
|
||||
destroying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeRcon(): Promise<void> {
|
||||
if (probingRcon || !name) return;
|
||||
probingRcon = true;
|
||||
@@ -242,13 +286,31 @@
|
||||
<StatusDot health={effectiveHealth} size={12} />
|
||||
<h1>{server.name}</h1>
|
||||
</div>
|
||||
<div class="player-block">
|
||||
<div class="player-count" class:has-players={(effectivePlayers ?? 0) > 0}>
|
||||
{effectivePlayers ?? '—'}
|
||||
<div class="hero-right">
|
||||
<div class="player-block">
|
||||
<div class="player-count" class:has-players={(effectivePlayers ?? 0) > 0}>
|
||||
{effectivePlayers ?? '—'}
|
||||
</div>
|
||||
<div class="player-label">players</div>
|
||||
</div>
|
||||
<div class="player-label">players</div>
|
||||
<button
|
||||
class="danger destroy-btn"
|
||||
onclick={destroyThisServer}
|
||||
disabled={destroying}
|
||||
title="Stop container, delete Komodo deployment, remove host dir, drop DB rows."
|
||||
>
|
||||
{destroying ? 'destroying…' : 'destroy server'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if destroyError}
|
||||
<p class="destroy-flash">{destroyError}</p>
|
||||
{/if}
|
||||
{#if destroyWarnings.length > 0}
|
||||
<ul class="destroy-warn">
|
||||
{#each destroyWarnings as w}<li>{w}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="hero-meta">
|
||||
<span class="pair"><span class="k">map</span> <span class="v">{effectiveMap ?? '—'}</span></span>
|
||||
{#if gametypeLabel(effectiveGametype)}
|
||||
@@ -530,6 +592,42 @@
|
||||
gap: 24px;
|
||||
}
|
||||
.name-row { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.hero-right { display: flex; align-items: flex-start; gap: 20px; }
|
||||
.destroy-btn {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid rgba(255, 48, 80, 0.4);
|
||||
color: var(--danger-500);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
align-self: center;
|
||||
}
|
||||
.destroy-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 48, 80, 0.10);
|
||||
box-shadow: 0 0 0 1px rgba(255, 48, 80, 0.4);
|
||||
}
|
||||
.destroy-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.destroy-flash {
|
||||
margin: 16px 0 0;
|
||||
color: var(--danger-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.destroy-warn {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--amber-500);
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
border: 1px solid rgba(255, 176, 32, 0.3);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono-sm);
|
||||
list-style: disc inside;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Fixture {
|
||||
* under test is imported. There's no shared/scripts directory anymore — the
|
||||
* upload-only model puts script content in the DB.
|
||||
*/
|
||||
export async function newFixture(servers: string[] = ['t5_kino_1']): Promise<Fixture> {
|
||||
export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
||||
const t5 = join(root, 't5');
|
||||
const betaArchive = join(root, 'beta-archive');
|
||||
|
||||
@@ -8,7 +8,7 @@ let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(['t5_kino_1']);
|
||||
fixture = await newFixture(['t5_zombie_theater_1']);
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
});
|
||||
@@ -30,7 +30,7 @@ async function seedFleet(): Promise<void> {
|
||||
await seedServerRegistry();
|
||||
// Pre-create the configs directory so the config write path finds a
|
||||
// hospitable server tree.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'configs'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'configs'), { recursive: true });
|
||||
}
|
||||
|
||||
describe('config baseline + overlay flow', () => {
|
||||
@@ -65,14 +65,14 @@ describe('config baseline + overlay flow', () => {
|
||||
{ scriptId: baselineScript.id, scriptVersionId: bVer.id },
|
||||
{ scriptId: overlayScript.id, scriptVersionId: oVer.id }
|
||||
],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
// 4. Verify: composed cfg lands at configs/dedicated_sp.cfg (in place,
|
||||
// NO -beta suffix — the game reads the exact filename).
|
||||
const cfgPath = join(fixture.t5, 't5_kino_1', 'configs', 'dedicated_sp.cfg');
|
||||
const cfgPath = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg');
|
||||
expect(existsSync(cfgPath)).toBe(true);
|
||||
const composed = (await readFile(cfgPath)).toString('utf8');
|
||||
expect(composed).toContain('set g_inactivity "0"');
|
||||
@@ -106,14 +106,14 @@ describe('config baseline + overlay flow', () => {
|
||||
// baseline is available.
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: overlayScript.id, scriptVersionId: oVer.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
const row = db()
|
||||
.prepare(`SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, 't5_kino_1') as { stage: string; error: string | null };
|
||||
.get(betaId, 't5_zombie_theater_1') as { stage: string; error: string | null };
|
||||
expect(row.stage).toBe('failed');
|
||||
expect(row.error ?? '').toMatch(/no baseline available/);
|
||||
});
|
||||
@@ -123,7 +123,7 @@ describe('plugin kind writes to plugin destination', () => {
|
||||
it('deploys a plugin .dll directly to its target filename', async () => {
|
||||
await seedFleet();
|
||||
// Pre-create the plugins dir so the write can succeed.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
|
||||
@@ -139,12 +139,12 @@ describe('plugin kind writes to plugin destination', () => {
|
||||
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo.dll');
|
||||
const pluginPath = join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/plugins', 'demo.dll');
|
||||
expect(existsSync(pluginPath)).toBe(true);
|
||||
const bytes = await readFile(pluginPath);
|
||||
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
||||
|
||||
@@ -8,13 +8,13 @@ let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(['t5_kino_1']);
|
||||
fixture = await newFixture(['t5_zombie_theater_1']);
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
// Ensure the three per-kind destinations exist under the fixture tree.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'configs'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'configs'), { recursive: true });
|
||||
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -28,7 +28,7 @@ describe('drift scanning', () => {
|
||||
await seedServerRegistry();
|
||||
|
||||
// Drop one stray file in each managed destination.
|
||||
const server = 't5_kino_1';
|
||||
const server = 't5_zombie_theater_1';
|
||||
await writeFile(
|
||||
join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', 'stray.gsc'),
|
||||
'main(){}\n'
|
||||
@@ -84,7 +84,7 @@ async function promoteAndOrphan(
|
||||
const ver = getLatestVersion(created.script.id)!;
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: created.script.id, scriptVersionId: ver.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -121,7 +121,7 @@ describe('missing artifact detection', () => {
|
||||
|
||||
const targetPath = join(
|
||||
fixture.t5,
|
||||
't5_kino_1',
|
||||
't5_zombie_theater_1',
|
||||
'runtime/plutonium/storage/t5/raw/scripts/sp',
|
||||
'hud.gsc'
|
||||
);
|
||||
@@ -131,13 +131,13 @@ describe('missing artifact detection', () => {
|
||||
// Simulate someone deleting the file out-of-band.
|
||||
await rm(targetPath, { force: true });
|
||||
|
||||
const drift = await scanServerDrift('t5_kino_1');
|
||||
const drift = await scanServerDrift('t5_zombie_theater_1');
|
||||
const missing = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'script');
|
||||
expect(missing.length).toBe(1);
|
||||
expect(missing[0].relPath).toBe('hud.gsc');
|
||||
|
||||
const result = await runDriftDeployment({
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
dryRun: false,
|
||||
triggeredBy: 'ui'
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('missing artifact detection', () => {
|
||||
const bVer = getLatestVersion(baseline.script.id)!;
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: baseline.script.id, scriptVersionId: bVer.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -179,11 +179,11 @@ describe('missing artifact detection', () => {
|
||||
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
|
||||
await promoteBeta(betaId);
|
||||
|
||||
const cfgPath = join(fixture.t5, 't5_kino_1', 'configs', 'dedicated_sp.cfg');
|
||||
const cfgPath = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg');
|
||||
expect(existsSync(cfgPath)).toBe(true);
|
||||
await rm(cfgPath, { force: true });
|
||||
|
||||
const drift = await scanServerDrift('t5_kino_1');
|
||||
const drift = await scanServerDrift('t5_zombie_theater_1');
|
||||
const missingCfg = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'config');
|
||||
expect(missingCfg.length).toBe(1);
|
||||
expect(missingCfg[0].relPath).toBe('dedicated_sp.cfg');
|
||||
|
||||
@@ -5,6 +5,6 @@ describe('slugify', () => {
|
||||
it('matches the legacy behavior', () => {
|
||||
expect(slugify('New HUD Test')).toBe('new-hud-test');
|
||||
expect(slugify(' --Weird!!! ')).toBe('weird');
|
||||
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
||||
expect(slugify('t5_zombie_theater_1')).toBe('t5-zombie-theater-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ const { resolveLogPath, tailLog, isLogKey } = await import(
|
||||
'../../src/lib/server/fleet/logs.js'
|
||||
);
|
||||
|
||||
const SERVER = 't5_test_1';
|
||||
const SERVER = 't5_zombie_theater_1';
|
||||
const LOG_DIR = resolve(TMP_ROOT, SERVER, 'logs');
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ let fixture: Fixture;
|
||||
// fixtures and hits a released sqlite handle.
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(['t5_kino_1', 't5_ascension_1']);
|
||||
fixture = await newFixture(['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']);
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
});
|
||||
@@ -55,7 +55,7 @@ describe('promote creates a deployment', () => {
|
||||
// other server (via scope) and mark it pending for restart.
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -68,7 +68,7 @@ describe('promote creates a deployment', () => {
|
||||
const result = await promoteBeta(betaId);
|
||||
|
||||
expect(result.deploymentId).toMatch(/^dep-.*-promote-\d+$/);
|
||||
expect(new Set(result.landed)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
|
||||
expect(new Set(result.landed)).toEqual(new Set(['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']));
|
||||
expect(result.scriptIds).toEqual([script.id]);
|
||||
|
||||
// Deployment row is action='promote'. The multi-script schema stores
|
||||
@@ -99,8 +99,8 @@ describe('promote creates a deployment', () => {
|
||||
.prepare(`SELECT * FROM deployment_servers WHERE deployment_id = ? ORDER BY server`)
|
||||
.all(result.deploymentId) as any[];
|
||||
const byName = Object.fromEntries(servers.map((s) => [s.server, s.restart_state]));
|
||||
expect(byName['t5_kino_1']).toBe('done');
|
||||
expect(byName['t5_ascension_1']).toBe('pending');
|
||||
expect(byName['t5_zombie_theater_1']).toBe('done');
|
||||
expect(byName['t5_zombie_cosmodrome_1']).toBe('pending');
|
||||
|
||||
// Script's current_version_id and beta state land.
|
||||
const after = getScript(script.id)!;
|
||||
@@ -128,7 +128,7 @@ describe('rollback beta on ready-promote marks version test-failed', () => {
|
||||
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -165,7 +165,7 @@ describe('rollback promote deployment', () => {
|
||||
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -179,7 +179,7 @@ describe('rollback promote deployment', () => {
|
||||
|
||||
const result = await rollbackPromoteDeployment(promote.deploymentId);
|
||||
expect(result.deploymentId).toMatch(/^dep-.*-rollback-/);
|
||||
expect(new Set(result.affectedServers)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
|
||||
expect(new Set(result.affectedServers)).toEqual(new Set(['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']));
|
||||
expect(result.scriptIds).toEqual([script.id]);
|
||||
|
||||
// Script's current_version_id reverted (was null before the promote).
|
||||
@@ -241,7 +241,7 @@ describe('multi-script beta', () => {
|
||||
{ scriptId: a.script.id, scriptVersionId: va.id },
|
||||
{ scriptId: b.script.id, scriptVersionId: vb.id }
|
||||
],
|
||||
servers: ['t5_kino_1', 't5_ascension_1'],
|
||||
servers: ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
@@ -252,7 +252,7 @@ describe('multi-script beta', () => {
|
||||
expect(new Set(bundle.map((r) => r.script_id))).toEqual(new Set([a.script.id, b.script.id]));
|
||||
|
||||
// Both target rel paths are held on both servers.
|
||||
const held1 = heldByBeta('t5_kino_1');
|
||||
const held1 = heldByBeta('t5_zombie_theater_1');
|
||||
expect(held1.get('hud.gsc')).toBe(betaId);
|
||||
expect(held1.get('flag.gsc')).toBe(betaId);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(['t5_kino_1', 't5_ascension_1']);
|
||||
fixture = await newFixture(['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']);
|
||||
// No integrations → sweep marks rows manual, exercising that branch.
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
@@ -31,12 +31,12 @@ describe('sweep', () => {
|
||||
.prepare(
|
||||
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, attempts) VALUES (?, ?, 1, 'pending', 0)"
|
||||
)
|
||||
.run(depId, 't5_kino_1');
|
||||
.run(depId, 't5_zombie_theater_1');
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, attempts) VALUES (?, ?, 1, 'pending', 0)"
|
||||
)
|
||||
.run(depId, 't5_ascension_1');
|
||||
.run(depId, 't5_zombie_cosmodrome_1');
|
||||
|
||||
await sweep();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user