From 9dcf4ac1d3bfa309ae1dd01676fa33d4b91f9674 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 17 Sep 2026 18:03:12 -0400 Subject: [PATCH] Add T6 Zombies fleet support with Plutainer configuration --- .env.example | 5 + README.md | 52 +++++++- docker-compose.yml | 2 + komodo/stack.toml | 6 +- package.json | 2 +- src/lib/map-aliases.ts | 66 ++++++++-- src/lib/server/config.ts | 24 +++- src/lib/server/fleet/beta.ts | 20 +-- src/lib/server/fleet/config-compose.ts | 9 +- src/lib/server/fleet/create.ts | 58 +++++---- src/lib/server/fleet/drift.ts | 27 ++-- src/lib/server/fleet/logs.ts | 8 +- src/lib/server/fleet/match-log.ts | 6 +- src/lib/server/fleet/scripts.ts | 5 +- src/lib/server/fleet/server-config.ts | 12 +- src/lib/server/fleet/server-keys.ts | 8 +- src/lib/server/fleet/servers.ts | 28 ++--- .../ui/components/DeployServerDialog.svelte | 38 +++--- src/lib/ui/server-labels.ts | 8 +- src/routes/api/maps/+server.ts | 4 +- src/routes/api/servers/+server.ts | 8 +- src/routes/configs/+page.svelte | 6 +- src/routes/server-keys/+page.svelte | 2 +- src/routes/servers/[name]/+page.svelte | 2 +- tests/helpers/fixture.ts | 8 +- tests/unit/create-server.test.ts | 115 ++++++++++++++++++ tests/unit/map-aliases.test.ts | 18 ++- tests/unit/t6-config-flow.test.ts | 91 ++++++++++++++ 28 files changed, 492 insertions(+), 146 deletions(-) create mode 100644 tests/unit/t6-config-flow.test.ts diff --git a/.env.example b/.env.example index 357e179..250bbaf 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # Filesystem paths — inside the Komodo container, these mirror the host layout SERVERS_ROOT=/root/zombies/t5 +# T6 is separate; defaults to a sibling t6 directory beside SERVERS_ROOT. +T6_SERVERS_ROOT=/root/zombies/t6 REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp # On-disk archive of active beta files (cleared per-beta on promote/rollback) BETA_ARCHIVE_DIR=/root/zombies/beta-archive @@ -27,6 +29,9 @@ KOMODO_TARGET_SERVER= # PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest # PLUTAINER_NETWORK=komodo_default # PLUTAINER_GAMEFILES_VOLUME=t5_game_files +# PLUTAINER_T6_GAMEFILES_VOLUME=t6_game_files +# T6_REL_DEST_SCRIPT=runtime/plutonium/storage/t6/scripts/zm +# T6_CONFIG_TARGET_FILE=dedicated_zm.cfg # 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 diff --git a/README.md b/README.md index c9ef9ba..7ff7e95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Electric Cherry -Web app that manages a fleet of T5 Plutonium servers running under Komodo. +Web app that manages a fleet of T5 and T6 Plutonium Zombies servers running under Komodo. Fleet membership lives in SQLite. EC registers servers during creation and removes them during destruction; startup does not discover or import server folders. @@ -122,6 +122,54 @@ reconciliation; this change does not immediately rewrite or restart them. Servers with an unknown map retain their existing config behavior. Rollback restores the exact prior config bytes. +## T6 Zombies (Black Ops II) + +Choose **Black Ops II (T6)** in Deploy new server. T6 uses `t6_zombie__` +IDs, its own saved Plutonium keys, `PLUTAINER_GAME=t6zm`, and +`configs/dedicated_zm.cfg`. Existing T5 deployments and environment settings +keep their current paths. API clients can send `game: "t6"`; omitting it selects T5. + +Before the first deployment, populate the Docker volume `t6_game_files` on the +Komodo host with your Black Ops II game files, including the DLC maps you intend +to host. Set `PLUTAINER_T6_GAMEFILES_VOLUME` to use another volume or an absolute +host directory. It is mounted read-only at `/home/plutainer/gamefiles`. +Plutainer supplies its runtime and config seeds, not the base game files. +Use a Plutonium server key created for **T6 Zombies**. + +Server state defaults to `/root/zombies/t6`, or the sibling `t6` directory beside +`SERVERS_ROOT`; override with `T6_SERVERS_ROOT`. Electric Cherry and the Komodo +host must see this directory at the same absolute path. The supplied Compose +mount already covers both games. GSC files go to +`runtime/plutonium/storage/t6/scripts/zm` (no `raw/`), and game logs use +`logs/games_zm.log`. Optional overrides are `T6_REL_DEST_SCRIPT` and +`T6_CONFIG_TARGET_FILE`; a custom config filename needs a promoted baseline or +a file supplied at that name, since Plutainer seeds `dedicated_zm.cfg`. + +The catalog includes the classic maps, Survival, Grief, and Turned selections. +Rotations use `execgts .cfg map `; Plutainer seeds the required +`storage/t6/gamesettings` files. Keep seeding enabled, including with a promoted +baseline. Town, Farm, and Bus Depot share `zm_transit`, so EC keeps their selection +in `map_slug` through creation, beta, promotion, and drift repair. Map-scoped +artifacts target the engine map and therefore apply to all its variants. +Creation health checks verify the engine map reported by RCON; this does not +independently verify the start location within that map. + +Both games share the fleet's port allocator (`SERVER_PORT_MIN`/`SERVER_PORT_MAX`) +to prevent collisions. EC explicitly sets `PLUTAINER_PORT`, so T6 need not use +its upstream default of 4976. Forward the allocated UDP ports. Use a current +Plutainer v2 image: EC relies on `PLUTAINER_RCON_PASSWORD`, +`PLUTAINER_MAP_ROTATE=false`, and extra arguments loaded after the main config. +Current Plutainer automatically whitelists the Docker gateway for T5/T6 RCON; +an admin client on another host needs its address allowed in the server config. + +Implementation references (checked September 2026): + +- [Plutainer game requirements](https://github.com/Ayymoss/Plutainer/blob/main/docs/games.md) +- [Plutainer launch, seeding, and RCON implementation](https://github.com/Ayymoss/Plutainer/blob/main/scripts/lib/cod.sh) +- [Plutainer configuration](https://github.com/Ayymoss/Plutainer/blob/main/docs/configuration.md) +- [T6 map/ruleset combinations](https://github.com/xerxes-at/T6ServerConfigs/blob/master/localappdata/Plutonium/storage/t6/dedicated_zm.cfg) +- [Plutonium GSC paths](https://plutonium.pw/docs/modding/loading-mods/) + ## Round telemetry experiment Send `POST /api/telemetry` with `Content-Type: application/json` to the Electric @@ -174,7 +222,7 @@ RCON parsing (fake UDP socket). ``` KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET - RCON_PASSWORD # single shared password across every T5 server + RCON_PASSWORD # single shared password across every T5/T6 server RCON_HOST # optional; defaults to host.docker.internal ``` diff --git a/docker-compose.yml b/docker-compose.yml index 2c78a4a..2419465 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: # replace with a concrete byte cap (e.g. "128M") instead. BODY_SIZE_LIMIT: "Infinity" SERVERS_ROOT: /root/zombies/t5 + T6_SERVERS_ROOT: /root/zombies/t6 REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp BETA_ARCHIVE_DIR: /root/zombies/beta-archive DB_PATH: /data/cherry.db @@ -36,6 +37,7 @@ services: KOMODO_API_KEY: ${KOMODO_API_KEY} KOMODO_API_SECRET: ${KOMODO_API_SECRET} KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER} + PLUTAINER_T6_GAMEFILES_VOLUME: ${PLUTAINER_T6_GAMEFILES_VOLUME:-t6_game_files} RCON_PASSWORD: ${RCON_PASSWORD} RCON_HOST: ${RCON_HOST:-host.docker.internal} RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000} diff --git a/komodo/stack.toml b/komodo/stack.toml index 0249067..b787461 100644 --- a/komodo/stack.toml +++ b/komodo/stack.toml @@ -1,5 +1,5 @@ # Komodo stack definition. Deploys electric-cherry as a managed container on -# the same host running the T5 Plutonium servers. Mounts /root/zombies so the +# the same host running the T5/T6 Plutonium servers. Mounts /root/zombies so the # app can push scripts to the same paths push_scripts.sh used, and dedicates # a small subdir for its SQLite state. @@ -22,6 +22,8 @@ extra_hosts = ["host.docker.internal:host-gateway"] [stack.services.web.env] SERVERS_ROOT = "/root/zombies/t5" +T6_SERVERS_ROOT = "/root/zombies/t6" +PLUTAINER_T6_GAMEFILES_VOLUME = "t6_game_files" REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp" BETA_ARCHIVE_DIR = "/root/zombies/beta-archive" DB_PATH = "/data/cherry.db" @@ -35,7 +37,7 @@ 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 — shared password across every Plutonium server in the fleet. RCON_PASSWORD = "" RCON_HOST = "host.docker.internal" RCON_TIMEOUT_MS = "3000" diff --git a/package.json b/package.json index 62b45b4..9d92efe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "electric-cherry", "version": "0.1.0", - "description": "T5 Plutonium fleet manager — replaces push_scripts.sh with a live web UI", + "description": "T5/T6 Plutonium Zombies fleet manager — replaces push_scripts.sh with a live web UI", "private": true, "type": "module", "scripts": { diff --git a/src/lib/map-aliases.ts b/src/lib/map-aliases.ts index d794a77..469fa4e 100644 --- a/src/lib/map-aliases.ts +++ b/src/lib/map-aliases.ts @@ -1,20 +1,25 @@ -// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can +// T5/T6 zombies map catalogs. 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'). // // Fields: // friendly — human name shown in the deploy UI dropdown // slug — segment used in server name (t5_zombie__) -// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping) +// canonical — actual mapname Pluto/RCON emits (used for script scoping) // // Add rows when new maps land; keep slugs short so paths stay readable. -export interface T5MapEntry { +export interface ZombieMapEntry { friendly: string; slug: string; canonical: string; + /** T6 selects a ruleset/location before loading the engine map. */ + gameSettings?: string; } +export type T5MapEntry = ZombieMapEntry; +export type ZombieGame = 't5' | 't6'; + 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' }, @@ -29,14 +34,52 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([ { friendly: 'Der Riese', slug: 'factory', canonical: 'zombie_cod5_factory' } ]); +// Rulesets from xerxes-at/T6ServerConfigs, also seeded by Plutainer. +// Several selections share an engine map; slug preserves the selected variant. +export const T6_ZOMBIE_MAPS: readonly ZombieMapEntry[] = Object.freeze([ + { friendly: 'TranZit', slug: 'transit', canonical: 'zm_transit', gameSettings: 'zm_classic_transit.cfg' }, + { friendly: 'Town Survival', slug: 'town', canonical: 'zm_transit', gameSettings: 'zm_standard_town.cfg' }, + { friendly: 'Farm Survival', slug: 'farm', canonical: 'zm_transit', gameSettings: 'zm_standard_farm.cfg' }, + { friendly: 'Bus Depot Survival', slug: 'busdepot', canonical: 'zm_transit', gameSettings: 'zm_standard_transit.cfg' }, + { friendly: 'Nuketown', slug: 'nuked', canonical: 'zm_nuked', gameSettings: 'zm_standard_nuked.cfg' }, + { friendly: 'Die Rise', slug: 'highrise', canonical: 'zm_highrise', gameSettings: 'zm_classic_rooftop.cfg' }, + { friendly: 'Mob of the Dead', slug: 'prison', canonical: 'zm_prison', gameSettings: 'zm_classic_prison.cfg' }, + { friendly: 'Buried', slug: 'buried', canonical: 'zm_buried', gameSettings: 'zm_classic_processing.cfg' }, + { friendly: 'Origins', slug: 'tomb', canonical: 'zm_tomb', gameSettings: 'zm_classic_tomb.cfg' }, + { friendly: 'Town Grief', slug: 'towngrief', canonical: 'zm_transit', gameSettings: 'zm_grief_town.cfg' }, + { friendly: 'Farm Grief', slug: 'farmgrief', canonical: 'zm_transit', gameSettings: 'zm_grief_farm.cfg' }, + { friendly: 'Bus Depot Grief', slug: 'busdepotgrief', canonical: 'zm_transit', gameSettings: 'zm_grief_transit.cfg' }, + { friendly: 'Cell Block Grief', slug: 'cellblock', canonical: 'zm_prison', gameSettings: 'zm_grief_cellblock.cfg' }, + { friendly: 'Buried Grief', slug: 'buriedgrief', canonical: 'zm_buried', gameSettings: 'zm_grief_street.cfg' }, + { friendly: 'Diner Turned', slug: 'diner', canonical: 'zm_transit_dr', gameSettings: 'zm_cleansed_diner.cfg' }, + { friendly: 'Buried Turned', slug: 'buriedturned', canonical: 'zm_buried', gameSettings: 'zm_cleansed_street.cfg' } +]); + +export function zombieMaps(game: string | null): readonly ZombieMapEntry[] { + return game === 't5' ? T5_ZOMBIE_MAPS : game === 't6' ? T6_ZOMBIE_MAPS : []; +} + +export function zombieMapBySlug(game: string, slug: string): ZombieMapEntry | undefined { + return zombieMaps(game).find((entry) => entry.slug === slug.toLowerCase()); +} + +export function selectedZombieMap(game: string | null, map: string | null, slug?: string | null): ZombieMapEntry | undefined { + const maps = zombieMaps(game); + // T5 maps are unambiguous, including legacy friendly slugs. T6 variants + // require the stored selection; never fall back from an unknown variant. + return maps.find((entry) => entry.canonical === map && ( + game !== 't6' || !slug || entry.slug === slug + || entry.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === slug + )); +} + +export function mapRotation(entry: ZombieMapEntry): string { + return `${entry.gameSettings ? `execgts ${entry.gameSettings} ` : ''}map ${entry.canonical}`; +} + const BY_SLUG: ReadonlyMap = new Map( T5_ZOMBIE_MAPS.map((m) => [m.slug, m]) ); -// Older folder names used friendly names (shi_no_numa) instead of engine -// slugs (sumpf). Keep deriving their canonical map from the same catalog. -const BY_FRIENDLY_SLUG: ReadonlyMap = new Map( - T5_ZOMBIE_MAPS.map((m) => [m.friendly.toLowerCase().replace(/[^a-z0-9]/g, ''), m]) -); export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined { return BY_SLUG.get(slug.toLowerCase()); @@ -86,6 +129,11 @@ export function parseServerName(name: string): ParsedServerName | null { if (slugParts.length === 0) return null; const mapSlug = slugParts.join('').toLowerCase(); - const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null; + const catalog = zombieMaps(game.toLowerCase()); + const entry = gameMode === null || gameMode === 'zombie' + ? catalog.find((m) => m.slug === mapSlug) + ?? catalog.find((m) => m.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === mapSlug) + : undefined; + const mapCanonical = entry?.canonical ?? null; return { game, gameMode, mapSlug, mapCanonical, lobby }; } diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index f8587da..fc770a4 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -1,4 +1,5 @@ import { env } from 'node:process'; +import { dirname, join } from 'node:path'; function req(name: string, fallback?: string): string { const v = env[name] ?? fallback; @@ -22,13 +23,16 @@ function positiveInt(name: string, fallback: number): number { export const config = { serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), + t6ServersRoot: env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'), + t6RelDestScript: env.T6_REL_DEST_SCRIPT ?? 'runtime/plutonium/storage/t6/scripts/zm', + t6ConfigTargetFile: env.T6_CONFIG_TARGET_FILE ?? 'dedicated_zm.cfg', relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'), // Per-kind destination sub-paths under each server's directory. `relDest` // above stays as the script fallback so existing deploys keep working. relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp', relDestConfig: env.REL_DEST_CONFIG ?? 'configs', relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins', - // Filename the baseline+overlays compose into. Same for every scope today. + // Legacy T5 config filename. T6 has its own target above. configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg', // Where on-disk copies of active beta files live for operator visibility. // Cleared per-beta on promote/rollback. @@ -45,12 +49,13 @@ export const config = { // 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. + // Default image for freshly-provisioned T5 and T6 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' + gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files', + t6GameFilesVolume: env.PLUTAINER_T6_GAMEFILES_VOLUME ?? 't6_game_files' }, // Shared across every newly-provisioned server. Kept in sync with the // fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond @@ -83,17 +88,24 @@ export function rconConfigured(): boolean { export type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; -export function relDestForKind(kind: ArtifactKind): string { +export function relDestForKind(kind: ArtifactKind, game = 't5'): string { switch (kind) { - case 'script': return config.relDestScript; + case 'script': return game === 't6' ? config.t6RelDestScript : config.relDestScript; case 'config': return config.relDestConfig; case 'baseline': return config.relDestConfig; case 'plugin': return config.relDestPlugin; } } +// Server IDs keep the game prefix even while creation is queued and no registry +// row exists yet. Legacy IDs retain the original T5 filesystem defaults. +export const gameOfServer = (server: string) => server.startsWith('t6_') ? 't6' : 't5'; +export const rootOfServer = (server: string) => gameOfServer(server) === 't6' ? config.t6ServersRoot : config.serversRoot; +export const serverDir = (server: string) => `${rootOfServer(server)}/${server}`; +export const configFileForGame = (game: string | null) => game === 't6' ? config.t6ConfigTargetFile : config.configTargetFile; +export const configFileOf = (server: string) => configFileForGame(gameOfServer(server)); export const destOfKind = (server: string, kind: ArtifactKind) => - `${config.serversRoot}/${server}/${relDestForKind(kind)}`; + `${serverDir(server)}/${relDestForKind(kind, gameOfServer(server))}`; // Back-compat: existing call sites written before the multi-kind refactor // resolve to the script destination. diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index d8db1bb..5698d9a 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, posix } from 'node:path'; -import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js'; +import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { sha256Buffer } from './files.js'; import { ensureDest, getServer, heldByBeta } from './servers.js'; @@ -29,7 +29,7 @@ function isCollapsingKind(kind: ArtifactKind): boolean { // The path we should write to for the composed cfg on this server. function composedCfgPath(server: string): string { - return join(destOfKind(server, 'config'), config.configTargetFile); + return join(destOfKind(server, 'config'), configFileOf(server)); } // Compose the effective dedicated_sp.cfg for a given beta+server, taking @@ -112,7 +112,7 @@ function composeCfgForServer( .filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap })) .map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') })); - const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap); + const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug); return { text: composed, baselineScriptId }; } @@ -480,7 +480,7 @@ export async function openBeta(input: OpenBetaInput): Promise { } const dest = destOfKind(name, 'config'); await mkdir(dest, { recursive: true }); - const targetRel = config.configTargetFile; + const targetRel = configFileOf(name); const targetPath = join(dest, targetRel); if (existsSync(targetPath)) { const priorBytes = await readFile(targetPath); @@ -504,7 +504,7 @@ export async function openBeta(input: OpenBetaInput): Promise { `beta:${betaId}` ); // Archive the composed preview for operator visibility. - await writeFile(join(archive, `${name}.${config.configTargetFile}`), composedBuf); + await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf); } catch (err) { firstErr = `configs: ${(err as Error).message}`; } @@ -603,7 +603,7 @@ export async function rollbackBeta(betaId: number): Promise { // Collapsing config layer: single restore for the shared cfg file. if (hasCollapsing) { const dest = destOfKind(bs.server, 'config'); - const targetRel = config.configTargetFile; + const targetRel = configFileOf(bs.server); const targetPath = join(dest, targetRel); const snap = getSnapshot.get(betaId, bs.server, targetRel) as | { prior_content: Buffer; prior_sha256: string; prior_size: number } @@ -828,7 +828,7 @@ export async function promoteBeta(betaId: number): Promise { const composedBuf = Buffer.from(composedResult.text, 'utf8'); const composedSha = sha256Buffer(composedBuf); const dest = destOfKind(name, 'config'); - const targetRel = config.configTargetFile; + const targetRel = configFileOf(name); const targetPath = join(dest, targetRel); let priorContent: Buffer | null = null; @@ -862,7 +862,7 @@ export async function promoteBeta(betaId: number): Promise { try { const dest = destOfKind(cp.server, 'config'); await mkdir(dest, { recursive: true }); - const targetRel = config.configTargetFile; + const targetRel = configFileOf(cp.server); const targetPath = join(dest, targetRel); // Beta server already has composed content at targetPath; overwrite // idempotently. Scope-expansion server: write in place. @@ -942,7 +942,7 @@ export async function promoteBeta(betaId: number): Promise { depId, cp.server, verb, - config.configTargetFile, + configFileOf(cp.server), cp.priorSha, cp.composedSha, cp.priorContent, @@ -950,7 +950,7 @@ export async function promoteBeta(betaId: number): Promise { ); recordScript( cp.server, - config.configTargetFile, + configFileOf(cp.server), null, cp.composedSha, cp.composedContent.byteLength, diff --git a/src/lib/server/fleet/config-compose.ts b/src/lib/server/fleet/config-compose.ts index abf2930..34e7782 100644 --- a/src/lib/server/fleet/config-compose.ts +++ b/src/lib/server/fleet/config-compose.ts @@ -14,7 +14,7 @@ // - Line endings default to \n for the composed output. Baseline CRLF is // preserved if the baseline uses CRLF throughout. -import { T5_ZOMBIE_MAPS } from './map-aliases.js'; +import { selectedZombieMap, mapRotation } from './map-aliases.js'; interface CfgLine { kind: 'set' | 'raw'; @@ -130,17 +130,18 @@ export function composeServerCfg( baselineText: string, overlays: OverlayInput[], game: string | null, - map: string | null + map: string | null, + mapSlug?: string | null ): string { const composed = composeCfg(baselineText, overlays); - const selected = game === 't5' ? T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map) : undefined; + const selected = selectedZombieMap(game, map, mapSlug); // Legacy/unrecognized servers retain their existing config semantics. if (!selected) return composed; const eol = parseCfg(composed).eol; const lines = composed.split(/\r?\n/); lines.pop(); // composeCfg always emits a trailing newline. - const rotation = `set sv_maprotation "map ${selected.canonical}"`; + const rotation = `set sv_maprotation "${mapRotation(selected)}"`; // Dvars are case-insensitive. Remove duplicate assignments, including // seta and bare forms, so old map-choice overlays cannot undo selection. const isRotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i; diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 801c051..8b8b6a3 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -2,11 +2,11 @@ import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js'; import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js'; import { mkdir, chmod, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { config, destOfKind } from '../config.js'; +import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js'; import { db } from '../db/index.js'; import { getServerKey, saveServerKey } from './server-keys.js'; import { getServer } from './servers.js'; -import { t5ZombieMapBySlug, type GameMode, type T5MapEntry } from './map-aliases.js'; +import { zombieMapBySlug, type GameMode, type ZombieMapEntry, type ZombieGame } from './map-aliases.js'; import { komodoCreateDeployment, komodoDeploy, @@ -24,11 +24,6 @@ import { recordScript } from './inventory.js'; import { sha256Buffer } from './files.js'; import { promotedArtifacts } from './promoted-artifacts.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 = config.configTargetFile; -const T5_ZOMBIE_GAME_ID = 't5sp'; - export type CreateServerFailure = | 'bad-request' | 'unconfigured' // Komodo creds / target server missing @@ -48,6 +43,7 @@ export type DestroyServerResult = | { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] }; export interface CreateServerInput { + game?: ZombieGame; // omitted by older clients: T5 gameMode: GameMode; // 'zombie' — only value supported today mapSlug: string; // 'cosmodrome' plutoKey?: string; // saved for this server ID when supplied @@ -98,6 +94,7 @@ function nextAvailablePort(): number | null { } function buildEnv(args: { + game: ZombieGame; name: string; port: number; plutoKey: string; @@ -105,8 +102,8 @@ function buildEnv(args: { 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: 'PLUTAINER_GAME', value: args.game === 't6' ? 't6zm' : 't5sp' }, + { variable: 'PLUTAINER_CONFIG_FILE', value: configFileForGame(args.game) }, // Plutainer seeds/loads the main config first. Load our generated map // config afterward, then rotate. A separate exec avoids quoted values // in EXTRA_ARGS, which Plutainer splits on whitespace. @@ -119,6 +116,7 @@ function buildEnv(args: { } function buildSpec(args: { + game: ZombieGame; name: string; port: number; hostDir: string; @@ -137,7 +135,7 @@ function buildSpec(args: { ], volumes: [ `${args.hostDir}:/home/plutainer/app`, - `${config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro` + `${args.game === 't6' ? config.komodo.t6GameFilesVolume : config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro` ], environment: buildEnv(args) }; @@ -145,7 +143,7 @@ function buildSpec(args: { let creating = false; interface PreparedCreation { - ok: true; name: string; port: number; plutoKey: string; mapEntry: T5MapEntry; + ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry; } export function queueCreateServer(input: CreateServerInput): CreateServerResult { @@ -170,6 +168,10 @@ export async function createServer(input: CreateServerInput): Promise { + const game = input.game ?? 't5'; + if (game !== 't5' && game !== 't6') { + return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' }; + } if (input.gameMode !== 'zombie') { return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` }; } @@ -180,12 +182,12 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract { const { name, port, plutoKey, mapEntry } = prepared; + const game = gameOfServer(name); + const configFile = configFileForGame(game); deploymentStage(operation, 'Preparing server files'); - const hostDir = `${config.serversRoot}/${name}`; - const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical); - const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!; - const initialArtifacts = promotedArtifacts('t5', mapEntry.canonical); + const hostDir = serverDir(name); + const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug); + const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!; + const initialArtifacts = promotedArtifacts(game, mapEntry.canonical); // Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly // after — plutainer expects to be able to write to /home/plutainer/app. @@ -244,7 +248,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared await writeFile(mapConfigPath, mapConfig); await chmod(mapConfigPath, 0o666); if (initialConfig) { - const configPath = join(destOfKind(name, 'config'), T5_ZOMBIE_CONFIG_FILE); + const configPath = join(destOfKind(name, 'config'), configFile); await mkdir(dirname(configPath), { recursive: true, mode: 0o777 }); await chmod(dirname(configPath), 0o777); await writeFile(configPath, initialConfig); @@ -256,7 +260,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared // Step 2: Komodo CreateDeployment. deploymentStage(operation, 'Creating Komodo deployment'); - const spec = buildSpec({ name, port, hostDir, plutoKey }); + const spec = buildSpec({ game, name, port, hostDir, plutoKey }); const createRes = await komodoCreateDeployment(spec); if (!createRes.ok) { await rm(hostDir, { recursive: true, force: true }).catch(() => {}); @@ -273,17 +277,17 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared game_mode, map_slug, host_dir, config_file, game_id, image, game_port, created_by, container_state) VALUES - (?, 'deployment', ?, 't5', ?, + (?, 'deployment', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting')` ).run( - name, name, mapEntry.canonical, + name, name, game, mapEntry.canonical, input.gameMode, mapEntry.slug, hostDir, - T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage, + configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage, port, input.createdBy ?? 'ui' ); if (initialConfig) { - recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create'); + recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create'); } recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map'); for (const { script, version } of initialArtifacts) { @@ -303,7 +307,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared // by game/map. Best-effort — a tag failure doesn't roll back the server; // the deployment is fully functional without cosmetic tags. Fires in // parallel to keep the create latency down. - void applyDeploymentTags(name, 't5', mapEntry.canonical); + void applyDeploymentTags(name, game, mapEntry.canonical); // Step 5: execute/Deploy — actually start the container. deploymentStage(operation, 'Starting container'); @@ -440,7 +444,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise { creating = true; db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id); try { - const mapEntry = t5ZombieMapBySlug(job.map_slug); + const mapEntry = zombieMapBySlug(gameOfServer(job.server), job.map_slug); const plutoKey = getServerKey(job.server); if (!mapEntry || !plutoKey) { const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' }; diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index 95cc230..25c42bb 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, posix, relative, sep } from 'node:path'; -import { config, destOfKind, type ArtifactKind } from '../config.js'; +import { configFileOf, destOfKind, type ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { emit } from '../pipeline/bus.js'; import { sha256Buffer } from './files.js'; @@ -13,7 +13,7 @@ import { } from './inventory.js'; import { getBaselineForScope, getVersion } from './scripts.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; -import { T5_ZOMBIE_MAPS } from './map-aliases.js'; +import { selectedZombieMap } from './map-aliases.js'; import { serverDirectories, getServer, listServers } from './servers.js'; import { promotedArtifacts } from './promoted-artifacts.js'; @@ -92,8 +92,8 @@ export async function scanServerDrift(server: string): Promise { const srv = getServer(server); // Without a promoted baseline, Plutainer owns the seeded config. Keep it // until a baseline is promoted; startup arguments handle map selection. - const keepDefaultConfig = srv?.game === 't5' - && T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map) + const keepDefaultConfig = srv?.game + && selectedZombieMap(srv.game, srv.map, srv.map_slug) && !getBaselineForScope('game', srv.game)?.current_version_id; // Scan for untracked files only after resolving managed targets. @@ -104,9 +104,9 @@ export async function scanServerDrift(server: string): Promise { for (const relPath of onDisk) { if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue; - if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue; + if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) continue; if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by - && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue; + && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) continue; const abs = join(dest, relPath); try { const st = await stat(abs); @@ -138,7 +138,7 @@ async function scanManagedForServer(server: string): Promise { // Provisioned servers load this generated file after their main cfg. It // remains managed even when there is no promoted baseline yet. const mapConfig = srv.created_by && srv.game_mode === 'zombie' - ? getServerMapConfig(srv.game, srv.map) : null; + ? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null; if (mapConfig) { const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE); const content = await readFile(abs).catch(() => null); @@ -168,10 +168,10 @@ async function scanManagedForServer(server: string): Promise { if (srv.game) { const baseline = getBaselineForScope('game', srv.game); if (baseline?.current_version_id) { - const composed = composeExpectedCfg(srv.game, srv.map); + const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug); if (composed) { const dest = destOfKind(server, 'config'); - const targetRel = config.configTargetFile; + const targetRel = configFileOf(server); const abs = join(dest, targetRel); const inv = getServerScript(server, targetRel); const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256; @@ -203,8 +203,8 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp'; export function isLogKey(v: string): v is LogKey { return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v); @@ -26,7 +28,7 @@ export function isLogKey(v: string): v is LogKey { * Returns null on any escape. */ export function resolveLogPath(serverName: string, key: LogKey): string | null { - const serversRoot = resolve(config.serversRoot); + const serversRoot = resolve(rootOfServer(serverName)); const serverDir = resolve(serversRoot, serverName); if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) { return null; diff --git a/src/lib/server/fleet/match-log.ts b/src/lib/server/fleet/match-log.ts index 4c39fdc..f359e6e 100644 --- a/src/lib/server/fleet/match-log.ts +++ b/src/lib/server/fleet/match-log.ts @@ -1,4 +1,4 @@ -import { resolveLogPath, tailLog } from './logs.js'; +import { resolveLogPath, tailLog, gameLogKey } from './logs.js'; // Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live // sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`. @@ -26,7 +26,7 @@ export async function scanForMatchEnd( serverName: string, sinceIso: string ): Promise<{ ended: boolean; started: boolean; reason?: string }> { - const path = resolveLogPath(serverName, 'games_sp'); + const path = resolveLogPath(serverName, gameLogKey(serverName)); if (!path) return { ended: false, started: false, reason: 'log path resolution failed' }; const tail = await tailLog(path, 2000); @@ -68,7 +68,7 @@ export interface MatchScanResult { * - `no-signal`: log missing / unreadable / no InitGame in tail. */ export async function scanMatchState(serverName: string): Promise { - const path = resolveLogPath(serverName, 'games_sp'); + const path = resolveLogPath(serverName, gameLogKey(serverName)); if (!path) return { kind: 'no-signal' }; const tail = await tailLog(path, 2000); diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index c8a2ae0..5b5cb6c 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { db } from '../db/index.js'; -import { config, type ArtifactKind } from '../config.js'; +import { configFileForGame, type ArtifactKind } from '../config.js'; export type { ArtifactKind }; @@ -172,7 +172,8 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver // the same on-disk cfg file, so the operator never picks it. let targetRelPath = input.targetRelPath?.trim() ?? ''; if (!targetRelPath && (kind === 'config' || kind === 'baseline')) { - targetRelPath = config.configTargetFile; + targetRelPath = configFileForGame(input.scopeType === 'game' ? input.scopeValue ?? null + : input.scopeValue?.startsWith('zm_') ? 't6' : 't5'); } if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty'); diff --git a/src/lib/server/fleet/server-config.ts b/src/lib/server/fleet/server-config.ts index fb68264..2fc3197 100644 --- a/src/lib/server/fleet/server-config.ts +++ b/src/lib/server/fleet/server-config.ts @@ -1,17 +1,17 @@ import { composeServerCfg } from './config-compose.js'; import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js'; -import { T5_ZOMBIE_MAPS } from './map-aliases.js'; +import { selectedZombieMap, mapRotation } from './map-aliases.js'; export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg'; /** Loaded after Plutainer's default/main config and before map_rotate. */ -export function getServerMapConfig(game: string | null, map: string | null): Buffer | null { - const entry = game === 't5' ? T5_ZOMBIE_MAPS.find((item) => item.canonical === map) : undefined; - return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "map ${entry.canonical}"\n`) : null; +export function getServerMapConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null { + const entry = selectedZombieMap(game, map, mapSlug); + return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "${mapRotation(entry)}"\n`) : null; } /** The promoted config used for first boot and drift reconciliation. */ -export function getPromotedServerConfig(game: string | null, map: string | null): Buffer | null { +export function getPromotedServerConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null { if (!game) return null; const baseline = getBaselineForScope('game', game); if (!baseline?.current_version_id) return null; @@ -25,5 +25,5 @@ export function getPromotedServerConfig(game: string | null, map: string | null) const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined; return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : []; }); - return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map), 'utf8'); + return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map, mapSlug), 'utf8'); } diff --git a/src/lib/server/fleet/server-keys.ts b/src/lib/server/fleet/server-keys.ts index 137ab94..76adb8c 100644 --- a/src/lib/server/fleet/server-keys.ts +++ b/src/lib/server/fleet/server-keys.ts @@ -1,10 +1,10 @@ import { db } from '../db/index.js'; -import { t5ZombieMapBySlug } from './map-aliases.js'; +import { zombieMapBySlug } from './map-aliases.js'; export function validServerId(value: unknown): value is string { if (typeof value !== 'string') return false; - const match = /^t5_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value); - return !!match && !!t5ZombieMapBySlug(match[1]); + const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value); + return !!match && !!zombieMapBySlug(match[1], match[2]); } export function listServerKeys() { @@ -21,7 +21,7 @@ export function getServerKey(serverId: string): string | undefined { } export function saveServerKey(serverId: unknown, key: unknown): void { - if (!validServerId(serverId)) throw new Error('Choose a valid T5 zombie server ID with a lobby number from 1 to 20'); + if (!validServerId(serverId)) throw new Error('Choose a valid T5 or T6 zombie server ID with a lobby number from 1 to 20'); if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) { throw new Error('Enter a server key without whitespace (maximum 512 characters)'); } diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index 405eb92..4a4ad42 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -1,24 +1,22 @@ import { readdir, mkdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { config, destOf } from '../config.js'; +import { config, destOf, serverDir, rootOfServer } 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 + * All server folders under the game roots. Used by seedServerRegistry to backfill * the servers table for any on-disk server directory that isn't yet tracked. */ export async function serverDirectories(): Promise { - let entries; - try { - entries = await readdir(config.serversRoot, { withFileTypes: true }); - } catch { - return []; + const names = new Set(); + for (const root of new Set([config.serversRoot, config.t6ServersRoot])) { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name); + } } - return entries - .filter((e) => e.isDirectory()) - .map((e) => e.name) - .sort(); + return [...names].sort(); } /** Fleet membership is owned by the database, independent of mounted files. */ @@ -41,7 +39,7 @@ export function matchServers(all: string[], patterns: string[]): string[] { } /** - * Ensure a server has an sp/ destination. Creates it when create=true. + * Ensure a server has a script destination. Creates it when create=true. */ export async function ensureDest( server: string, @@ -57,7 +55,7 @@ export async function ensureDest( } return { ok: true, path: dest }; } - if (!create) return { ok: false, reason: 'no sp folder — use create=true' }; + if (!create) return { ok: false, reason: 'no script folder — use create=true' }; await mkdir(dest, { recursive: true }); return { ok: true, path: dest }; } @@ -127,7 +125,7 @@ export async function seedServerRegistry(): Promise { const map = parsed?.mapCanonical ?? null; const gameMode = parsed?.gameMode ?? null; const mapSlug = parsed?.mapSlug ?? null; - const hostDir = `${config.serversRoot}/${name}`; + const hostDir = serverDir(name); const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir); if (res.changes > 0) { n++; @@ -136,7 +134,7 @@ export async function seedServerRegistry(): Promise { } if (parsed && !parsed.mapCanonical) { console.warn( - `[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to T5_ZOMBIE_MAPS` + `[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to the game map catalog` ); } } diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 1c88b98..2791be3 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -6,16 +6,14 @@ import Input from './primitives/Input.svelte'; import Select from './primitives/Select.svelte'; - interface T5MapEntry { - friendly: string; - slug: string; - canonical: string; - } + import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; let { open = $bindable(false) }: { open?: boolean } = $props(); let dialog: HTMLDialogElement | undefined = $state(); - let maps = $state([]); + let game = $state('t5'); + let catalogs = $state>({ t5: [], t6: [] }); + let maps = $derived(catalogs[game]); let mapsError = $state(null); let mapsLoaded = $state(false); @@ -31,8 +29,9 @@ onMount(async () => { try { const res = await fetch('/api/maps'); - const body = (await res.json()) as { t5Zombie: T5MapEntry[] }; - maps = body.t5Zombie; + if (!res.ok) throw new Error('Could not load map catalog'); + const body = (await res.json()) as { t5Zombie: ZombieMapEntry[]; t6Zombie: ZombieMapEntry[] }; + catalogs = { t5: body.t5Zombie, t6: body.t6Zombie }; if (maps.length > 0) mapSlug = maps[0].slug; } catch (err) { mapsError = (err as Error).message; @@ -78,12 +77,15 @@ } let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1) - .find(n => !deployedIds.includes(`t5_${gameMode}_${mapSlug}_${n}`)) ?? null); + .find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null); let selectedMap = $derived(maps.find((m) => m.slug === mapSlug)); - let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : ''); + let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : ''); let hasSavedKey = $derived(savedIds.includes(previewName)); - $effect(() => { mapSlug; lobby; plutoKey = ''; }); + $effect(() => { game; mapSlug; lobby; plutoKey = ''; }); + $effect(() => { + if (maps.length && !maps.some((entry) => entry.slug === mapSlug)) mapSlug = maps[0].slug; + }); let modeOptions = [{ value: 'zombie', label: 'Zombies' }]; let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly }))); @@ -99,7 +101,7 @@ const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined }) + body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined }) }); const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string }; if (!res.ok || !body.ok || !body.deploymentId) { @@ -131,7 +133,7 @@ {:else}
-
Black Ops 1 (t5)
+ +
+ - +
diff --git a/src/routes/server-keys/+page.svelte b/src/routes/server-keys/+page.svelte index 8df5ddd..cdec23a 100644 --- a/src/routes/server-keys/+page.svelte +++ b/src/routes/server-keys/+page.svelte @@ -56,7 +56,7 @@

Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.

{editing ? 'Update key' : 'Add key'}

- + diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index b6522e8..811249f 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -296,7 +296,7 @@ server={server.name} tabs={[ { key: 'console', label: 'console.log' }, - { key: 'games_sp', label: 'games_sp.log' } + server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' } ]} /> diff --git a/tests/helpers/fixture.ts b/tests/helpers/fixture.ts index e4f3b6d..ad5041b 100644 --- a/tests/helpers/fixture.ts +++ b/tests/helpers/fixture.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; export interface Fixture { root: string; t5: string; + t6: string; betaArchive: string; dbPath: string; cleanup: () => Promise; @@ -18,16 +19,20 @@ export interface Fixture { export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise { const root = await mkdtemp(join(tmpdir(), 'cherry-')); const t5 = join(root, 't5'); + const t6 = join(root, 't6'); const betaArchive = join(root, 'beta-archive'); const dbPath = join(root, 'cherry.db'); await mkdir(t5, { recursive: true }); + await mkdir(t6, { recursive: true }); await mkdir(betaArchive, { recursive: true }); for (const s of servers) { - await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true }); + await mkdir(s.startsWith('t6_') ? join(t6, s, 'runtime/plutonium/storage/t6/scripts/zm') + : join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true }); } process.env.SERVERS_ROOT = t5; + process.env.T6_SERVERS_ROOT = t6; process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp'; process.env.BETA_ARCHIVE_DIR = betaArchive; process.env.DB_PATH = dbPath; @@ -37,6 +42,7 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P return { root, t5, + t6, betaArchive, dbPath, cleanup: async () => { diff --git a/tests/unit/create-server.test.ts b/tests/unit/create-server.test.ts index bbf40ec..709fd50 100644 --- a/tests/unit/create-server.test.ts +++ b/tests/unit/create-server.test.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { T6_ZOMBIE_MAPS } from '../../src/lib/map-aliases.js'; vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoCreateDeployment: vi.fn(async () => ({ ok: true })), @@ -57,6 +58,120 @@ async function promotedConfig() { } } +describe('T6 Zombies provisioning', () => { + it('keeps T6 storage and config overrides separate from T5 settings', async () => { + vi.stubEnv('T6_SERVERS_ROOT', join(fixture.root, 'custom-t6')); + vi.stubEnv('T6_CONFIG_TARGET_FILE', 'custom_zm.cfg'); + vi.stubEnv('T6_REL_DEST_SCRIPT', 'custom/scripts/zm'); + vi.stubEnv('PLUTAINER_T6_GAMEFILES_VOLUME', 'bo2-files'); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const baseline = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', content: Buffer.from('sv_voice 1\n') }); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baseline.version.id, baseline.script.id); + const { createServer } = await import('../../src/lib/server/fleet/create.js'); + const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + const { destOf, configFileOf } = await import('../../src/lib/server/config.js'); + const created = await createServer({ ...input, game: 't6', mapSlug: 'town' }); + if (!created.ok) throw new Error(created.message); + const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0]; + expect(spec.volumes).toContain('bo2-files:/home/plutainer/gamefiles:ro'); + expect(spec.environment).toContainEqual({ variable: 'PLUTAINER_CONFIG_FILE', value: 'custom_zm.cfg' }); + expect(existsSync(join(fixture.root, 'custom-t6', created.name, 'configs/custom_zm.cfg'))).toBe(true); + expect(destOf(created.name)).toBe(`${join(fixture.root, 'custom-t6')}/${created.name}/custom/scripts/zm`); + expect(configFileOf(serverName)).toBe('dedicated_sp.cfg'); + expect(destOf(serverName)).toBe(`${fixture.t5}/${serverName}/runtime/plutonium/storage/t5/raw/scripts/sp`); + }); + + it.each(T6_ZOMBIE_MAPS)('provisions $friendly with the T6 mount, config, and ruleset', async (map) => { + const { createServer } = await import('../../src/lib/server/fleet/create.js'); + const { getServer } = await import('../../src/lib/server/fleet/servers.js'); + const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + const name = `t6_zombie_${map.slug}_1`; + expect(await createServer({ ...input, game: 't6', mapSlug: map.slug })).toMatchObject({ ok: true, name }); + const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0]; + const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value])); + expect(env).toMatchObject({ PLUTAINER_GAME: 't6zm', PLUTAINER_CONFIG_FILE: 'dedicated_zm.cfg', + PLUTAINER_RCON_PASSWORD: 'test-rcon', PLUTAINER_MAP_ROTATE: 'false', + PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${name} +map_rotate` }); + expect(env.PLUTAINER_SKIP_SEED).toBeUndefined(); + expect(spec.volumes).toEqual([`${fixture.t6}/${name}:/home/plutainer/app`, 't6_game_files:/home/plutainer/gamefiles:ro']); + expect(getServer(name)).toMatchObject({ game: 't6', game_id: 't6zm', map: map.canonical, + map_slug: map.slug, config_file: 'dedicated_zm.cfg' }); + expect(await readFile(join(fixture.t6, name, 'configs/electric_cherry_map.cfg'), 'utf8')) + .toContain(`set sv_maprotation "execgts ${map.gameSettings} map ${map.canonical}"`); + expect(existsSync(join(fixture.t5, name))).toBe(false); + expect(existsSync(join(fixture.t6, name, 'configs/dedicated_zm.cfg'))).toBe(false); + }); + + it('persists T6 queued work, reuses its key, and allocates ports across both games', async () => { + const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); + const { saveServerKey, validServerId } = await import('../../src/lib/server/fleet/server-keys.js'); + const { closeDb } = await import('../../src/lib/server/db/index.js'); + const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); + const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); + const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); + saveServerKey('t6_zombie_town_1', 't6-key'); + expect(validServerId('t6_zombie_town_20')).toBe(true); + expect(validServerId('t5_zombie_town_1')).toBe(false); + expect(validServerId('t6_zombie_theater_1')).toBe(false); + const t5 = queueCreateServer(input); + const t6 = queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town' }); + if (!t5.ok || !t6.ok) throw new Error('queue failed'); + expect(t6.port).not.toBe(t5.port); + closeDb(); + vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' }); + vi.mocked(rconStatusResult).mockImplementation(async (server) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', + status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : 'zombie_cosmodrome', gametype: null, roster: [] } })); + await processCreations(); + expect(getDeployment(t6.deploymentId)?.deployment).toMatchObject({ state: 'done' }); + const content = await readFile(join(fixture.t6, t6.name, 'configs/electric_cherry_map.cfg'), 'utf8'); + expect(content).toContain('execgts zm_standard_town.cfg map zm_transit'); + }); + + it('installs only matching promoted artifacts into T6 paths and removes only its directory on destruction', async () => { + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + for (const game of ['t5', 't6']) { + for (const kind of ['script', 'baseline'] as const) { + const { script, version } = createScript({ name: `${game}-${kind}`, kind, scopeType: 'game', scopeValue: game, + targetRelPath: kind === 'script' ? `${game}.gsc` : undefined, + content: Buffer.from(kind === 'script' ? 'main(){}' : 'sv_voice 1\nsv_maprotation "map wrong"\n') }); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); + } + } + const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js'); + const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); + const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); + await createServer(input); + const t6 = await createServer({ ...input, game: 't6', mapSlug: 'tomb' }); + if (!t6.ok) throw new Error(t6.message); + expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t6.gsc'))).toBe(true); + expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t5.gsc'))).toBe(false); + expect(await readFile(join(fixture.t6, t6.name, 'configs/dedicated_zm.cfg'), 'utf8')) + .toContain('execgts zm_classic_tomb.cfg map zm_tomb'); + expect(await scanServerDrift(t6.name)).toEqual([]); + expect(destroyServer(t6.name).ok).toBe(true); + const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); + vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' }); + await processDestructions(); + expect(existsSync(join(fixture.t6, t6.name))).toBe(false); + expect(existsSync(join(fixture.t5, serverName))).toBe(true); + expect(getServerKey(t6.name)).toBe('test-key'); + }); + + it('validates game/map combinations at the API boundary', async () => { + const { POST } = await import('../../src/routes/api/servers/+server.js'); + for (const game of ['t7', null, 6]) { + const res = await POST({ request: new Request('http://localhost/api/servers', { + method: 'POST', body: JSON.stringify({ ...input, game }) + }) }); + expect(res.status).toBe(400); + } + const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); + expect(queueCreateServer({ ...input, game: 't6' })).toMatchObject({ ok: false, kind: 'unknown-map' }); + }); +}); + describe('server creation map selection', () => { it('returns a queued creation before provisioning and reserves its name and port', async () => { const { POST } = await import('../../src/routes/api/servers/+server.js'); diff --git a/tests/unit/map-aliases.test.ts b/tests/unit/map-aliases.test.ts index c999d26..9069075 100644 --- a/tests/unit/map-aliases.test.ts +++ b/tests/unit/map-aliases.test.ts @@ -2,8 +2,24 @@ import { describe, expect, it } from 'vitest'; import { parseServerName, t5ZombieMapBySlug, - T5_ZOMBIE_MAPS + T5_ZOMBIE_MAPS, + T6_ZOMBIE_MAPS } from '../../src/lib/server/fleet/map-aliases.js'; +import { serverTitle } from '../../src/lib/ui/server-labels.js'; + +describe('T6 map identity', () => { + it.each(T6_ZOMBIE_MAPS)('parses and labels $friendly without losing its variant', (map) => { + const name = `t6_zombie_${map.slug}_2`; + expect(parseServerName(name)).toMatchObject({ game: 't6', gameMode: 'zombie', mapCanonical: map.canonical, mapSlug: map.slug, lobby: 2 }); + expect(serverTitle(name)).toBe(`${map.friendly} · Lobby 2`); + }); + + it('does not interpret another game or mode through the T5 catalog', () => { + for (const name of ['t6_zombie_theater_1', 't5_zombie_tomb_1', 't5_mp_theater_1', 'iw5_theater_1']) { + expect(parseServerName(name)?.mapCanonical).toBeNull(); + } + }); +}); describe('parseServerName (new naming: t5___)', () => { it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => { diff --git a/tests/unit/t6-config-flow.test.ts b/tests/unit/t6-config-flow.test.ts new file mode 100644 index 0000000..089f838 --- /dev/null +++ b/tests/unit/t6-config-flow.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +const town = 't6_zombie_town_1'; +const farm = 't6_zombie_farm_1'; +const t5 = 't5_zombie_theater_1'; +const cfgPath = (server: string) => join(fixture.t6, server, 'configs/dedicated_zm.cfg'); + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([town, farm, t5]); + vi.stubEnv('KOMODO_ADDRESS', ''); + vi.stubEnv('RCON_PASSWORD', ''); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + for (const name of [town, farm]) await mkdir(join(fixture.t6, name, 'configs'), { recursive: true }); +}); + +afterEach(async () => { + await fixture.cleanup(); + vi.unstubAllEnvs(); +}); + +describe('T6 config lifecycle in a mixed fleet', () => { + it('preserves variants through beta, scope promotion, drift repair, and campaign rollback', async () => { + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js'); + const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const previous = '// prior operator settings\r\nsv_voice 0\r\n'; + await writeFile(cfgPath(town), previous); + const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', + content: Buffer.from('sv_voice 1\nsv_maprotation "map zm_buried"\n') }); + expect(script.target_rel_path).toBe('dedicated_zm.cfg'); + const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], + servers: [town], waitForHealth: false, waitForMatch: false }); + expect(await readFile(cfgPath(town), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit'); + db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), betaId); + db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId); + const promoted = await promoteBeta(betaId); + expect(new Set(promoted.landed)).toEqual(new Set([town, farm])); + const farmConfig = await readFile(cfgPath(farm), 'utf8'); + expect(farmConfig).toContain('execgts zm_standard_farm.cfg map zm_transit'); + expect(existsSync(join(fixture.t5, t5, 'configs/dedicated_sp.cfg'))).toBe(false); + expect(existsSync(join(fixture.t6, farm, 'configs/dedicated_sp.cfg'))).toBe(false); + expect(await scanServerDrift(farm)).toEqual([]); + await rm(cfgPath(farm)); + expect((await runDriftDeployment({ servers: [farm] })).totalRestored).toBe(1); + expect(await readFile(cfgPath(farm), 'utf8')).toBe(farmConfig); + await rollbackPromoteDeployment(promoted.deploymentId); + expect(await readFile(cfgPath(town), 'utf8')).toBe(previous); + expect(existsSync(cfgPath(farm))).toBe(false); + }); + + it('restores the exact pre-beta T6 config and preserves seeded rulesets during drift repair', async () => { + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js'); + const previous = 'sv_voice 1\n'; + await writeFile(cfgPath(town), previous); + const settings = join(fixture.t6, town, 'runtime/plutonium/storage/t6/gamesettings'); + await mkdir(settings, { recursive: true }); + await writeFile(join(settings, 'zm_standard_town.cfg'), 'ui_zm_mapstartlocation town\n'); + await writeFile(join(fixture.t6, town, 'configs/dedicated.cfg'), 'sv_voice 1\n'); + const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', + content: Buffer.from('sv_voice 0\n') }); + const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], + servers: [town], waitForHealth: false, waitForMatch: false }); + await rollbackBeta(betaId); + expect(await readFile(cfgPath(town), 'utf8')).toBe(previous); + expect(await scanServerDrift(town)).toEqual([]); + await runDriftDeployment({ servers: [town] }); + expect(await readFile(join(settings, 'zm_standard_town.cfg'), 'utf8')).toBe('ui_zm_mapstartlocation town\n'); + }); + + it('reads the T6 game log and keeps log path traversal checks', async () => { + const { resolveLogPath, gameLogKey } = await import('../../src/lib/server/fleet/logs.js'); + const { scanMatchState } = await import('../../src/lib/server/fleet/match-log.js'); + const logs = join(fixture.t6, town, 'logs'); + await mkdir(logs, { recursive: true }); + await writeFile(join(logs, 'games_zm.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n'); + expect(resolveLogPath(town, gameLogKey(town))).toBe(resolve(logs, 'games_zm.log')); + expect(await scanMatchState(town)).toMatchObject({ kind: 'in-progress' }); + expect(resolveLogPath('t6_/../../escape', 'games_zm')).toBeNull(); + }); +});