Add T6 Zombies fleet support with Plutainer configuration

This commit is contained in:
2026-09-17 18:03:12 -04:00
parent 1350e6df7c
commit 9dcf4ac1d3
28 changed files with 492 additions and 146 deletions
+5
View File
@@ -1,5 +1,7 @@
# Filesystem paths — inside the Komodo container, these mirror the host layout # Filesystem paths — inside the Komodo container, these mirror the host layout
SERVERS_ROOT=/root/zombies/t5 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 REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
# On-disk archive of active beta files (cleared per-beta on promote/rollback) # On-disk archive of active beta files (cleared per-beta on promote/rollback)
BETA_ARCHIVE_DIR=/root/zombies/beta-archive BETA_ARCHIVE_DIR=/root/zombies/beta-archive
@@ -27,6 +29,9 @@ KOMODO_TARGET_SERVER=
# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest # PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# PLUTAINER_NETWORK=komodo_default # PLUTAINER_NETWORK=komodo_default
# PLUTAINER_GAMEFILES_VOLUME=t5_game_files # 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. # 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. # Must correspond to what's actually forwarded on the Komodo host firewall.
# SERVER_PORT_MIN=28960 # SERVER_PORT_MIN=28960
+50 -2
View File
@@ -1,6 +1,6 @@
# Electric Cherry # 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 Fleet membership lives in SQLite. EC registers servers during creation and removes
them during destruction; startup does not discover or import server folders. 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 Servers with an unknown map retain their existing config behavior. Rollback
restores the exact prior config bytes. restores the exact prior config bytes.
## T6 Zombies (Black Ops II)
Choose **Black Ops II (T6)** in Deploy new server. T6 uses `t6_zombie_<slug>_<lobby>`
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 <ruleset>.cfg map <engine-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 ## Round telemetry experiment
Send `POST /api/telemetry` with `Content-Type: application/json` to the Electric 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 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 RCON_HOST # optional; defaults to host.docker.internal
``` ```
+2
View File
@@ -27,6 +27,7 @@ services:
# replace with a concrete byte cap (e.g. "128M") instead. # replace with a concrete byte cap (e.g. "128M") instead.
BODY_SIZE_LIMIT: "Infinity" BODY_SIZE_LIMIT: "Infinity"
SERVERS_ROOT: /root/zombies/t5 SERVERS_ROOT: /root/zombies/t5
T6_SERVERS_ROOT: /root/zombies/t6
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
BETA_ARCHIVE_DIR: /root/zombies/beta-archive BETA_ARCHIVE_DIR: /root/zombies/beta-archive
DB_PATH: /data/cherry.db DB_PATH: /data/cherry.db
@@ -36,6 +37,7 @@ services:
KOMODO_API_KEY: ${KOMODO_API_KEY} KOMODO_API_KEY: ${KOMODO_API_KEY}
KOMODO_API_SECRET: ${KOMODO_API_SECRET} KOMODO_API_SECRET: ${KOMODO_API_SECRET}
KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER} KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER}
PLUTAINER_T6_GAMEFILES_VOLUME: ${PLUTAINER_T6_GAMEFILES_VOLUME:-t6_game_files}
RCON_PASSWORD: ${RCON_PASSWORD} RCON_PASSWORD: ${RCON_PASSWORD}
RCON_HOST: ${RCON_HOST:-host.docker.internal} RCON_HOST: ${RCON_HOST:-host.docker.internal}
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000} RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
+4 -2
View File
@@ -1,5 +1,5 @@
# Komodo stack definition. Deploys electric-cherry as a managed container on # 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 # app can push scripts to the same paths push_scripts.sh used, and dedicates
# a small subdir for its SQLite state. # a small subdir for its SQLite state.
@@ -22,6 +22,8 @@ extra_hosts = ["host.docker.internal:host-gateway"]
[stack.services.web.env] [stack.services.web.env]
SERVERS_ROOT = "/root/zombies/t5" 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" REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
BETA_ARCHIVE_DIR = "/root/zombies/beta-archive" BETA_ARCHIVE_DIR = "/root/zombies/beta-archive"
DB_PATH = "/data/cherry.db" 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. # Host new deployments land on — must match the name shown in Komodo's UI.
KOMODO_TARGET_SERVER = "" 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_PASSWORD = ""
RCON_HOST = "host.docker.internal" RCON_HOST = "host.docker.internal"
RCON_TIMEOUT_MS = "3000" RCON_TIMEOUT_MS = "3000"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "electric-cherry", "name": "electric-cherry",
"version": "0.1.0", "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, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+57 -9
View File
@@ -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 → // derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 →
// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome'). // game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome').
// //
// Fields: // Fields:
// friendly — human name shown in the deploy UI dropdown // friendly — human name shown in the deploy UI dropdown
// slug — segment used in server name (t5_zombie_<slug>_<n>) // slug — segment used in server name (t5_zombie_<slug>_<n>)
// 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. // Add rows when new maps land; keep slugs short so paths stay readable.
export interface T5MapEntry { export interface ZombieMapEntry {
friendly: string; friendly: string;
slug: string; slug: string;
canonical: 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([ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
{ friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' }, { friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' },
{ friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' }, { 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' } { 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<string, T5MapEntry> = new Map( const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
T5_ZOMBIE_MAPS.map((m) => [m.slug, m]) 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<string, T5MapEntry> = new Map(
T5_ZOMBIE_MAPS.map((m) => [m.friendly.toLowerCase().replace(/[^a-z0-9]/g, ''), m])
);
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined { export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
return BY_SLUG.get(slug.toLowerCase()); return BY_SLUG.get(slug.toLowerCase());
@@ -86,6 +129,11 @@ export function parseServerName(name: string): ParsedServerName | null {
if (slugParts.length === 0) return null; if (slugParts.length === 0) return null;
const mapSlug = slugParts.join('').toLowerCase(); 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 }; return { game, gameMode, mapSlug, mapCanonical, lobby };
} }
+18 -6
View File
@@ -1,4 +1,5 @@
import { env } from 'node:process'; import { env } from 'node:process';
import { dirname, join } from 'node:path';
function req(name: string, fallback?: string): string { function req(name: string, fallback?: string): string {
const v = env[name] ?? fallback; const v = env[name] ?? fallback;
@@ -22,13 +23,16 @@ function positiveInt(name: string, fallback: number): number {
export const config = { export const config = {
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), 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'), relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
// Per-kind destination sub-paths under each server's directory. `relDest` // Per-kind destination sub-paths under each server's directory. `relDest`
// above stays as the script fallback so existing deploys keep working. // 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', relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp',
relDestConfig: env.REL_DEST_CONFIG ?? 'configs', relDestConfig: env.REL_DEST_CONFIG ?? 'configs',
relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins', 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', configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg',
// Where on-disk copies of active beta files live for operator visibility. // Where on-disk copies of active beta files live for operator visibility.
// Cleared per-beta on promote/rollback. // Cleared per-beta on promote/rollback.
@@ -45,12 +49,13 @@ export const config = {
// deploy flow — Komodo needs to know which machine to schedule 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). // container onto. Look up the exact name in Komodo's UI (Servers page).
targetServer: env.KOMODO_TARGET_SERVER ?? '', 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. // Overridable per-deploy in the UI later if we ever need to.
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest', defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default', defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
// Named docker volume that holds the read-only game files layer. // 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 // Shared across every newly-provisioned server. Kept in sync with the
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond // 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 type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
export function relDestForKind(kind: ArtifactKind): string { export function relDestForKind(kind: ArtifactKind, game = 't5'): string {
switch (kind) { switch (kind) {
case 'script': return config.relDestScript; case 'script': return game === 't6' ? config.t6RelDestScript : config.relDestScript;
case 'config': return config.relDestConfig; case 'config': return config.relDestConfig;
case 'baseline': return config.relDestConfig; case 'baseline': return config.relDestConfig;
case 'plugin': return config.relDestPlugin; 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) => 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 // Back-compat: existing call sites written before the multi-kind refactor
// resolve to the script destination. // resolve to the script destination.
+10 -10
View File
@@ -1,7 +1,7 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join, posix } from 'node:path'; 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 { db } from '../db/index.js';
import { sha256Buffer } from './files.js'; import { sha256Buffer } from './files.js';
import { ensureDest, getServer, heldByBeta } from './servers.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. // The path we should write to for the composed cfg on this server.
function composedCfgPath(server: string): string { 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 // 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 })) .filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') })); .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 }; return { text: composed, baselineScriptId };
} }
@@ -480,7 +480,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
} }
const dest = destOfKind(name, 'config'); const dest = destOfKind(name, 'config');
await mkdir(dest, { recursive: true }); await mkdir(dest, { recursive: true });
const targetRel = config.configTargetFile; const targetRel = configFileOf(name);
const targetPath = join(dest, targetRel); const targetPath = join(dest, targetRel);
if (existsSync(targetPath)) { if (existsSync(targetPath)) {
const priorBytes = await readFile(targetPath); const priorBytes = await readFile(targetPath);
@@ -504,7 +504,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
`beta:${betaId}` `beta:${betaId}`
); );
// Archive the composed preview for operator visibility. // 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) { } catch (err) {
firstErr = `configs: ${(err as Error).message}`; firstErr = `configs: ${(err as Error).message}`;
} }
@@ -603,7 +603,7 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
// Collapsing config layer: single restore for the shared cfg file. // Collapsing config layer: single restore for the shared cfg file.
if (hasCollapsing) { if (hasCollapsing) {
const dest = destOfKind(bs.server, 'config'); const dest = destOfKind(bs.server, 'config');
const targetRel = config.configTargetFile; const targetRel = configFileOf(bs.server);
const targetPath = join(dest, targetRel); const targetPath = join(dest, targetRel);
const snap = getSnapshot.get(betaId, bs.server, targetRel) as const snap = getSnapshot.get(betaId, bs.server, targetRel) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number } | { prior_content: Buffer; prior_sha256: string; prior_size: number }
@@ -828,7 +828,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const composedBuf = Buffer.from(composedResult.text, 'utf8'); const composedBuf = Buffer.from(composedResult.text, 'utf8');
const composedSha = sha256Buffer(composedBuf); const composedSha = sha256Buffer(composedBuf);
const dest = destOfKind(name, 'config'); const dest = destOfKind(name, 'config');
const targetRel = config.configTargetFile; const targetRel = configFileOf(name);
const targetPath = join(dest, targetRel); const targetPath = join(dest, targetRel);
let priorContent: Buffer | null = null; let priorContent: Buffer | null = null;
@@ -862,7 +862,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
try { try {
const dest = destOfKind(cp.server, 'config'); const dest = destOfKind(cp.server, 'config');
await mkdir(dest, { recursive: true }); await mkdir(dest, { recursive: true });
const targetRel = config.configTargetFile; const targetRel = configFileOf(cp.server);
const targetPath = join(dest, targetRel); const targetPath = join(dest, targetRel);
// Beta server already has composed content at targetPath; overwrite // Beta server already has composed content at targetPath; overwrite
// idempotently. Scope-expansion server: write in place. // idempotently. Scope-expansion server: write in place.
@@ -942,7 +942,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
depId, depId,
cp.server, cp.server,
verb, verb,
config.configTargetFile, configFileOf(cp.server),
cp.priorSha, cp.priorSha,
cp.composedSha, cp.composedSha,
cp.priorContent, cp.priorContent,
@@ -950,7 +950,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
); );
recordScript( recordScript(
cp.server, cp.server,
config.configTargetFile, configFileOf(cp.server),
null, null,
cp.composedSha, cp.composedSha,
cp.composedContent.byteLength, cp.composedContent.byteLength,
+5 -4
View File
@@ -14,7 +14,7 @@
// - Line endings default to \n for the composed output. Baseline CRLF is // - Line endings default to \n for the composed output. Baseline CRLF is
// preserved if the baseline uses CRLF throughout. // preserved if the baseline uses CRLF throughout.
import { T5_ZOMBIE_MAPS } from './map-aliases.js'; import { selectedZombieMap, mapRotation } from './map-aliases.js';
interface CfgLine { interface CfgLine {
kind: 'set' | 'raw'; kind: 'set' | 'raw';
@@ -130,17 +130,18 @@ export function composeServerCfg(
baselineText: string, baselineText: string,
overlays: OverlayInput[], overlays: OverlayInput[],
game: string | null, game: string | null,
map: string | null map: string | null,
mapSlug?: string | null
): string { ): string {
const composed = composeCfg(baselineText, overlays); 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. // Legacy/unrecognized servers retain their existing config semantics.
if (!selected) return composed; if (!selected) return composed;
const eol = parseCfg(composed).eol; const eol = parseCfg(composed).eol;
const lines = composed.split(/\r?\n/); const lines = composed.split(/\r?\n/);
lines.pop(); // composeCfg always emits a trailing newline. 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 // Dvars are case-insensitive. Remove duplicate assignments, including
// seta and bare forms, so old map-choice overlays cannot undo selection. // seta and bare forms, so old map-choice overlays cannot undo selection.
const isRotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i; const isRotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i;
+31 -27
View File
@@ -2,11 +2,11 @@ import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js'; import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises'; import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path'; 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 { db } from '../db/index.js';
import { getServerKey, saveServerKey } from './server-keys.js'; import { getServerKey, saveServerKey } from './server-keys.js';
import { getServer } from './servers.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 { import {
komodoCreateDeployment, komodoCreateDeployment,
komodoDeploy, komodoDeploy,
@@ -24,11 +24,6 @@ import { recordScript } from './inventory.js';
import { sha256Buffer } from './files.js'; import { sha256Buffer } from './files.js';
import { promotedArtifacts } from './promoted-artifacts.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 = export type CreateServerFailure =
| 'bad-request' | 'bad-request'
| 'unconfigured' // Komodo creds / target server missing | '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[] }; | { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
export interface CreateServerInput { export interface CreateServerInput {
game?: ZombieGame; // omitted by older clients: T5
gameMode: GameMode; // 'zombie' — only value supported today gameMode: GameMode; // 'zombie' — only value supported today
mapSlug: string; // 'cosmodrome' mapSlug: string; // 'cosmodrome'
plutoKey?: string; // saved for this server ID when supplied plutoKey?: string; // saved for this server ID when supplied
@@ -98,6 +94,7 @@ function nextAvailablePort(): number | null {
} }
function buildEnv(args: { function buildEnv(args: {
game: ZombieGame;
name: string; name: string;
port: number; port: number;
plutoKey: string; plutoKey: string;
@@ -105,8 +102,8 @@ function buildEnv(args: {
return [ return [
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name }, { variable: 'PLUTAINER_SERVER_NAME', value: args.name },
{ variable: 'PLUTAINER_PORT', value: String(args.port) }, { variable: 'PLUTAINER_PORT', value: String(args.port) },
{ variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID }, { variable: 'PLUTAINER_GAME', value: args.game === 't6' ? 't6zm' : 't5sp' },
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE }, { variable: 'PLUTAINER_CONFIG_FILE', value: configFileForGame(args.game) },
// Plutainer seeds/loads the main config first. Load our generated map // Plutainer seeds/loads the main config first. Load our generated map
// config afterward, then rotate. A separate exec avoids quoted values // config afterward, then rotate. A separate exec avoids quoted values
// in EXTRA_ARGS, which Plutainer splits on whitespace. // in EXTRA_ARGS, which Plutainer splits on whitespace.
@@ -119,6 +116,7 @@ function buildEnv(args: {
} }
function buildSpec(args: { function buildSpec(args: {
game: ZombieGame;
name: string; name: string;
port: number; port: number;
hostDir: string; hostDir: string;
@@ -137,7 +135,7 @@ function buildSpec(args: {
], ],
volumes: [ volumes: [
`${args.hostDir}:/home/plutainer/app`, `${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) environment: buildEnv(args)
}; };
@@ -145,7 +143,7 @@ function buildSpec(args: {
let creating = false; let creating = false;
interface PreparedCreation { 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 { export function queueCreateServer(input: CreateServerInput): CreateServerResult {
@@ -170,6 +168,10 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
} }
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> { function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
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') { if (input.gameMode !== 'zombie') {
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` }; return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
} }
@@ -180,12 +182,12 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' }; return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
} }
const mapEntry = t5ZombieMapBySlug(input.mapSlug); const mapEntry = zombieMapBySlug(game, input.mapSlug);
if (!mapEntry) { if (!mapEntry) {
return { ok: false, kind: 'unknown-map', message: `no t5 zombie map with slug '${input.mapSlug}'` }; return { ok: false, kind: 'unknown-map', message: `no ${game} zombie map with slug '${input.mapSlug}'` };
} }
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`; const baseName = `${game}_${input.gameMode}_${mapEntry.slug}`;
const lobby = nextLobbyNumber(baseName); const lobby = nextLobbyNumber(baseName);
if (lobby === null) { if (lobby === null) {
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` }; return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
@@ -215,11 +217,13 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> { async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
const { name, port, plutoKey, mapEntry } = prepared; const { name, port, plutoKey, mapEntry } = prepared;
const game = gameOfServer(name);
const configFile = configFileForGame(game);
deploymentStage(operation, 'Preparing server files'); deploymentStage(operation, 'Preparing server files');
const hostDir = `${config.serversRoot}/${name}`; const hostDir = serverDir(name);
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical); const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!; const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
const initialArtifacts = promotedArtifacts('t5', mapEntry.canonical); const initialArtifacts = promotedArtifacts(game, mapEntry.canonical);
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly // Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
// after — plutainer expects to be able to write to /home/plutainer/app. // 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 writeFile(mapConfigPath, mapConfig);
await chmod(mapConfigPath, 0o666); await chmod(mapConfigPath, 0o666);
if (initialConfig) { 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 mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
await chmod(dirname(configPath), 0o777); await chmod(dirname(configPath), 0o777);
await writeFile(configPath, initialConfig); await writeFile(configPath, initialConfig);
@@ -256,7 +260,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
// Step 2: Komodo CreateDeployment. // Step 2: Komodo CreateDeployment.
deploymentStage(operation, 'Creating Komodo deployment'); 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); const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) { if (!createRes.ok) {
await rm(hostDir, { recursive: true, force: true }).catch(() => {}); 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_mode, map_slug, host_dir, config_file, game_id, image,
game_port, created_by, container_state) game_port, created_by, container_state)
VALUES VALUES
(?, 'deployment', ?, 't5', ?, (?, 'deployment', ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, 'starting')` ?, ?, 'starting')`
).run( ).run(
name, name, mapEntry.canonical, name, name, game, mapEntry.canonical,
input.gameMode, mapEntry.slug, hostDir, 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' port, input.createdBy ?? 'ui'
); );
if (initialConfig) { 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'); recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
for (const { script, version } of initialArtifacts) { 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; // by game/map. Best-effort — a tag failure doesn't roll back the server;
// the deployment is fully functional without cosmetic tags. Fires in // the deployment is fully functional without cosmetic tags. Fires in
// parallel to keep the create latency down. // 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. // Step 5: execute/Deploy — actually start the container.
deploymentStage(operation, 'Starting container'); deploymentStage(operation, 'Starting container');
@@ -440,7 +444,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
// 3: host directory. Fall back to derived path if host_dir wasn't set on // 3: host directory. Fall back to derived path if host_dir wasn't set on
// legacy rows. // legacy rows.
deploymentStage(operation, 'Removing server files and registry'); deploymentStage(operation, 'Removing server files and registry');
const hostDir = row.host_dir ?? `${config.serversRoot}/${name}`; const hostDir = row.host_dir ?? serverDir(name);
try { try {
await rm(hostDir, { recursive: true, force: true }); await rm(hostDir, { recursive: true, force: true });
} catch (err) { } catch (err) {
@@ -481,7 +485,7 @@ async function provisionCreation(id: string): Promise<CreateServerResult> {
creating = true; creating = true;
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id); db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id);
try { try {
const mapEntry = t5ZombieMapBySlug(job.map_slug); const mapEntry = zombieMapBySlug(gameOfServer(job.server), job.map_slug);
const plutoKey = getServerKey(job.server); const plutoKey = getServerKey(job.server);
if (!mapEntry || !plutoKey) { 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' }; const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' };
+14 -13
View File
@@ -1,7 +1,7 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join, posix, relative, sep } from 'node:path'; 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 { db } from '../db/index.js';
import { emit } from '../pipeline/bus.js'; import { emit } from '../pipeline/bus.js';
import { sha256Buffer } from './files.js'; import { sha256Buffer } from './files.js';
@@ -13,7 +13,7 @@ import {
} from './inventory.js'; } from './inventory.js';
import { getBaselineForScope, getVersion } from './scripts.js'; import { getBaselineForScope, getVersion } from './scripts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.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 { serverDirectories, getServer, listServers } from './servers.js';
import { promotedArtifacts } from './promoted-artifacts.js'; import { promotedArtifacts } from './promoted-artifacts.js';
@@ -92,8 +92,8 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
const srv = getServer(server); const srv = getServer(server);
// Without a promoted baseline, Plutainer owns the seeded config. Keep it // Without a promoted baseline, Plutainer owns the seeded config. Keep it
// until a baseline is promoted; startup arguments handle map selection. // until a baseline is promoted; startup arguments handle map selection.
const keepDefaultConfig = srv?.game === 't5' const keepDefaultConfig = srv?.game
&& T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map) && selectedZombieMap(srv.game, srv.map, srv.map_slug)
&& !getBaselineForScope('game', srv.game)?.current_version_id; && !getBaselineForScope('game', srv.game)?.current_version_id;
// Scan for untracked files only after resolving managed targets. // Scan for untracked files only after resolving managed targets.
@@ -104,9 +104,9 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
for (const relPath of onDisk) { for (const relPath of onDisk) {
if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue; 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 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); const abs = join(dest, relPath);
try { try {
const st = await stat(abs); const st = await stat(abs);
@@ -138,7 +138,7 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
// Provisioned servers load this generated file after their main cfg. It // Provisioned servers load this generated file after their main cfg. It
// remains managed even when there is no promoted baseline yet. // remains managed even when there is no promoted baseline yet.
const mapConfig = srv.created_by && srv.game_mode === 'zombie' 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) { if (mapConfig) {
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE); const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
const content = await readFile(abs).catch(() => null); const content = await readFile(abs).catch(() => null);
@@ -168,10 +168,10 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
if (srv.game) { if (srv.game) {
const baseline = getBaselineForScope('game', srv.game); const baseline = getBaselineForScope('game', srv.game);
if (baseline?.current_version_id) { if (baseline?.current_version_id) {
const composed = composeExpectedCfg(srv.game, srv.map); const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
if (composed) { if (composed) {
const dest = destOfKind(server, 'config'); const dest = destOfKind(server, 'config');
const targetRel = config.configTargetFile; const targetRel = configFileOf(server);
const abs = join(dest, targetRel); const abs = join(dest, targetRel);
const inv = getServerScript(server, targetRel); const inv = getServerScript(server, targetRel);
const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256; const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256;
@@ -203,8 +203,8 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
if (entry.kind === 'config') { if (entry.kind === 'config') {
const srv = getServer(server); const srv = getServer(server);
if (!srv?.game) return null; if (!srv?.game) return null;
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map); if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug);
const composed = composeExpectedCfg(srv.game, srv.map); const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
return composed?.content ?? null; return composed?.content ?? null;
} }
if (!entry.scriptVersionId) return null; if (!entry.scriptVersionId) return null;
@@ -214,9 +214,10 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
function composeExpectedCfg( function composeExpectedCfg(
game: string | null, game: string | null,
map: string | null map: string | null,
mapSlug?: string | null
): { content: Buffer; sha256: string } | null { ): { content: Buffer; sha256: string } | null {
const content = getPromotedServerConfig(game, map); const content = getPromotedServerConfig(game, map, mapSlug);
if (!content) return null; if (!content) return null;
return { content, sha256: sha256Buffer(content) }; return { content, sha256: sha256Buffer(content) };
} }
+5 -3
View File
@@ -1,16 +1,18 @@
import { open, stat } from 'node:fs/promises'; import { open, stat } from 'node:fs/promises';
import { resolve, sep } from 'node:path'; import { resolve, sep } from 'node:path';
import { config } from '../config.js'; import { rootOfServer, gameOfServer } from '../config.js';
// Whitelisted log files exposed to the UI, mapped to their path relative // Whitelisted log files exposed to the UI, mapped to their path relative
// to each server's root directory. Adding a new supported log = one entry // to each server's root directory. Adding a new supported log = one entry
// here + one tab in LogPanel.svelte. // here + one tab in LogPanel.svelte.
export const KNOWN_LOGS = { export const KNOWN_LOGS = {
console: 'logs/console.log', console: 'logs/console.log',
games_sp: 'logs/games_sp.log' games_sp: 'logs/games_sp.log',
games_zm: 'logs/games_zm.log'
} as const; } as const;
export type LogKey = keyof typeof KNOWN_LOGS; export type LogKey = keyof typeof KNOWN_LOGS;
export const gameLogKey = (server: string): LogKey => gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp';
export function isLogKey(v: string): v is LogKey { export function isLogKey(v: string): v is LogKey {
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v); 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. * Returns null on any escape.
*/ */
export function resolveLogPath(serverName: string, key: LogKey): string | null { export function resolveLogPath(serverName: string, key: LogKey): string | null {
const serversRoot = resolve(config.serversRoot); const serversRoot = resolve(rootOfServer(serverName));
const serverDir = resolve(serversRoot, serverName); const serverDir = resolve(serversRoot, serverName);
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) { if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
return null; return null;
+3 -3
View File
@@ -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 // Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`. // sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
@@ -26,7 +26,7 @@ export async function scanForMatchEnd(
serverName: string, serverName: string,
sinceIso: string sinceIso: string
): Promise<{ ended: boolean; started: boolean; reason?: 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' }; if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
const tail = await tailLog(path, 2000); const tail = await tailLog(path, 2000);
@@ -68,7 +68,7 @@ export interface MatchScanResult {
* - `no-signal`: log missing / unreadable / no InitGame in tail. * - `no-signal`: log missing / unreadable / no InitGame in tail.
*/ */
export async function scanMatchState(serverName: string): Promise<MatchScanResult> { export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
const path = resolveLogPath(serverName, 'games_sp'); const path = resolveLogPath(serverName, gameLogKey(serverName));
if (!path) return { kind: 'no-signal' }; if (!path) return { kind: 'no-signal' };
const tail = await tailLog(path, 2000); const tail = await tailLog(path, 2000);
+3 -2
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { config, type ArtifactKind } from '../config.js'; import { configFileForGame, type ArtifactKind } from '../config.js';
export type { ArtifactKind }; 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. // the same on-disk cfg file, so the operator never picks it.
let targetRelPath = input.targetRelPath?.trim() ?? ''; let targetRelPath = input.targetRelPath?.trim() ?? '';
if (!targetRelPath && (kind === 'config' || kind === 'baseline')) { 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'); if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
+6 -6
View File
@@ -1,17 +1,17 @@
import { composeServerCfg } from './config-compose.js'; import { composeServerCfg } from './config-compose.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.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'; export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg';
/** Loaded after Plutainer's default/main config and before map_rotate. */ /** Loaded after Plutainer's default/main config and before map_rotate. */
export function getServerMapConfig(game: string | null, map: string | null): Buffer | null { export function getServerMapConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
const entry = game === 't5' ? T5_ZOMBIE_MAPS.find((item) => item.canonical === map) : undefined; const entry = selectedZombieMap(game, map, mapSlug);
return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "map ${entry.canonical}"\n`) : null; 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. */ /** 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; if (!game) return null;
const baseline = getBaselineForScope('game', game); const baseline = getBaselineForScope('game', game);
if (!baseline?.current_version_id) return null; 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; const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : []; 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');
} }
+4 -4
View File
@@ -1,10 +1,10 @@
import { db } from '../db/index.js'; 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 { export function validServerId(value: unknown): value is string {
if (typeof value !== 'string') return false; if (typeof value !== 'string') return false;
const match = /^t5_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value); const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
return !!match && !!t5ZombieMapBySlug(match[1]); return !!match && !!zombieMapBySlug(match[1], match[2]);
} }
export function listServerKeys() { export function listServerKeys() {
@@ -21,7 +21,7 @@ export function getServerKey(serverId: string): string | undefined {
} }
export function saveServerKey(serverId: unknown, key: unknown): void { 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())) { 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)'); throw new Error('Enter a server key without whitespace (maximum 512 characters)');
} }
+13 -15
View File
@@ -1,24 +1,22 @@
import { readdir, mkdir, stat } from 'node:fs/promises'; import { readdir, mkdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs'; 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 { db } from '../db/index.js';
import { parseServerName } from './map-aliases.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. * the servers table for any on-disk server directory that isn't yet tracked.
*/ */
export async function serverDirectories(): Promise<string[]> { export async function serverDirectories(): Promise<string[]> {
let entries; const names = new Set<string>();
try { for (const root of new Set([config.serversRoot, config.t6ServersRoot])) {
entries = await readdir(config.serversRoot, { withFileTypes: true }); const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
} catch { for (const entry of entries) {
return []; if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name);
}
} }
return entries return [...names].sort();
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
} }
/** Fleet membership is owned by the database, independent of mounted files. */ /** 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( export async function ensureDest(
server: string, server: string,
@@ -57,7 +55,7 @@ export async function ensureDest(
} }
return { ok: true, path: dest }; 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 }); await mkdir(dest, { recursive: true });
return { ok: true, path: dest }; return { ok: true, path: dest };
} }
@@ -127,7 +125,7 @@ export async function seedServerRegistry(): Promise<number> {
const map = parsed?.mapCanonical ?? null; const map = parsed?.mapCanonical ?? null;
const gameMode = parsed?.gameMode ?? null; const gameMode = parsed?.gameMode ?? null;
const mapSlug = parsed?.mapSlug ?? 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); const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir);
if (res.changes > 0) { if (res.changes > 0) {
n++; n++;
@@ -136,7 +134,7 @@ export async function seedServerRegistry(): Promise<number> {
} }
if (parsed && !parsed.mapCanonical) { if (parsed && !parsed.mapCanonical) {
console.warn( 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`
); );
} }
} }
+15 -23
View File
@@ -6,16 +6,14 @@
import Input from './primitives/Input.svelte'; import Input from './primitives/Input.svelte';
import Select from './primitives/Select.svelte'; import Select from './primitives/Select.svelte';
interface T5MapEntry { import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
friendly: string;
slug: string;
canonical: string;
}
let { open = $bindable(false) }: { open?: boolean } = $props(); let { open = $bindable(false) }: { open?: boolean } = $props();
let dialog: HTMLDialogElement | undefined = $state(); let dialog: HTMLDialogElement | undefined = $state();
let maps = $state<T5MapEntry[]>([]); let game = $state<ZombieGame>('t5');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let maps = $derived(catalogs[game]);
let mapsError = $state<string | null>(null); let mapsError = $state<string | null>(null);
let mapsLoaded = $state(false); let mapsLoaded = $state(false);
@@ -31,8 +29,9 @@
onMount(async () => { onMount(async () => {
try { try {
const res = await fetch('/api/maps'); const res = await fetch('/api/maps');
const body = (await res.json()) as { t5Zombie: T5MapEntry[] }; if (!res.ok) throw new Error('Could not load map catalog');
maps = body.t5Zombie; 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; if (maps.length > 0) mapSlug = maps[0].slug;
} catch (err) { } catch (err) {
mapsError = (err as Error).message; mapsError = (err as Error).message;
@@ -78,12 +77,15 @@
} }
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1) 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 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)); 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 modeOptions = [{ value: 'zombie', label: 'Zombies' }];
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly }))); let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
@@ -99,7 +101,7 @@
const res = await fetch('/api/servers', { const res = await fetch('/api/servers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 }; 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) { if (!res.ok || !body.ok || !body.deploymentId) {
@@ -131,7 +133,7 @@
{:else} {:else}
<div class="fields"> <div class="fields">
<Field label="Game"> <Field label="Game">
<div class="readonly">Black Ops 1 (t5)</div> <Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} />
</Field> </Field>
<Field label="Mode"> <Field label="Mode">
@@ -243,16 +245,6 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.readonly {
padding: 9px 12px;
color: var(--text-secondary);
font-family: var(--font-body);
font-size: var(--text-sm);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.preview { .preview {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+4 -4
View File
@@ -1,15 +1,15 @@
import { parseServerName, T5_ZOMBIE_MAPS } from '../map-aliases.js'; import { parseServerName, T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS, zombieMaps } from '../map-aliases.js';
export function mapTitle(map: string | null | undefined): string { export function mapTitle(map: string | null | undefined): string {
return T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—'; return [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—';
} }
export function serverTitle(name: string): string { export function serverTitle(name: string): string {
const parsed = parseServerName(name); const parsed = parseServerName(name);
if (!parsed || parsed.game.toLowerCase() !== 't5' || if (!parsed || !['t5', 't6'].includes(parsed.game.toLowerCase()) ||
(parsed.gameMode !== null && parsed.gameMode !== 'zombie') || !parsed.mapCanonical) { (parsed.gameMode !== null && parsed.gameMode !== 'zombie') || !parsed.mapCanonical) {
return name; return name;
} }
const title = mapTitle(parsed.mapCanonical); const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`; return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { T5_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js'; import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js';
export function GET() { export function GET() {
return json({ t5Zombie: T5_ZOMBIE_MAPS }); return json({ t5Zombie: T5_ZOMBIE_MAPS, t6Zombie: T6_ZOMBIE_MAPS });
} }
+5 -3
View File
@@ -6,7 +6,7 @@ import { classifyHealth } from '$lib/server/fleet/health.js';
import { queueCreateServer } from '$lib/server/fleet/create.js'; import { queueCreateServer } from '$lib/server/fleet/create.js';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js'; import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import type { RconPlayer } from '$lib/server/integrations/rcon.js'; import type { RconPlayer } from '$lib/server/integrations/rcon.js';
import type { GameMode } from '$lib/server/fleet/map-aliases.js'; import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
function safeJson<T>(raw: string | null): T | null { function safeJson<T>(raw: string | null): T | null {
if (!raw) return null; if (!raw) return null;
@@ -52,18 +52,20 @@ export async function GET() {
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { const body = (await request.json().catch(() => ({}))) as {
game?: string;
gameMode?: string; gameMode?: string;
mapSlug?: string; mapSlug?: string;
plutoKey?: string; plutoKey?: string;
expectedName?: string; expectedName?: string;
}; };
if (!body || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) { if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
return json( return json(
{ ok: false, error: 'gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' }, { ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
{ status: 400 } { status: 400 }
); );
} }
const result = queueCreateServer({ const result = queueCreateServer({
game: body.game as ZombieGame | undefined,
gameMode: body.gameMode as GameMode, gameMode: body.gameMode as GameMode,
mapSlug: body.mapSlug, mapSlug: body.mapSlug,
plutoKey: body.plutoKey, plutoKey: body.plutoKey,
+3 -3
View File
@@ -196,15 +196,15 @@
<h2 class="panel-title">Upload a baseline <span class="dim">(one per game)</span></h2> <h2 class="panel-title">Upload a baseline <span class="dim">(one per game)</span></h2>
<form onsubmit={createBaseline} class="upload-form"> <form onsubmit={createBaseline} class="upload-form">
<Field label="Name"> <Field label="Name">
<Input bind:value={baselineName} placeholder="t5 baseline" disabled={uploading} /> <Input bind:value={baselineName} placeholder="Game baseline" disabled={uploading} />
</Field> </Field>
<Field label="Game"> <Field label="Game">
<Select bind:value={baselineScopeGame} options={gameOptions} disabled={uploading} /> <Select bind:value={baselineScopeGame} options={gameOptions} disabled={uploading} />
</Field> </Field>
<Field label="Notes" hint="Optional. Describe what this baseline covers."> <Field label="Notes" hint="Optional. Describe what this baseline covers.">
<Input bind:value={baselineNotes} placeholder="dedicated_sp.cfg baseline" disabled={uploading} /> <Input bind:value={baselineNotes} placeholder="Server config baseline" disabled={uploading} />
</Field> </Field>
<Field label="Cfg file (dedicated_sp.cfg)"> <Field label="Server cfg file">
<Input type="file" accept=".cfg,text/plain" onchange={pickBaselineFile} disabled={uploading} /> <Input type="file" accept=".cfg,text/plain" onchange={pickBaselineFile} disabled={uploading} />
</Field> </Field>
<div class="form-actions"> <div class="form-actions">
+1 -1
View File
@@ -56,7 +56,7 @@
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p> <p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
<form onsubmit={save}> <form onsubmit={save}>
<h2>{editing ? 'Update key' : 'Add key'}</h2> <h2>{editing ? 'Update key' : 'Add key'}</h2>
<Field label="Server ID" hint="Use a map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2."> <Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1.">
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono /> <Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field> </Field>
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed."> <Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
+1 -1
View File
@@ -296,7 +296,7 @@
server={server.name} server={server.name}
tabs={[ tabs={[
{ key: 'console', label: 'console.log' }, { 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' }
]} ]}
/> />
</Card> </Card>
+7 -1
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path';
export interface Fixture { export interface Fixture {
root: string; root: string;
t5: string; t5: string;
t6: string;
betaArchive: string; betaArchive: string;
dbPath: string; dbPath: string;
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
@@ -18,16 +19,20 @@ export interface Fixture {
export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> { export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'cherry-')); const root = await mkdtemp(join(tmpdir(), 'cherry-'));
const t5 = join(root, 't5'); const t5 = join(root, 't5');
const t6 = join(root, 't6');
const betaArchive = join(root, 'beta-archive'); const betaArchive = join(root, 'beta-archive');
const dbPath = join(root, 'cherry.db'); const dbPath = join(root, 'cherry.db');
await mkdir(t5, { recursive: true }); await mkdir(t5, { recursive: true });
await mkdir(t6, { recursive: true });
await mkdir(betaArchive, { recursive: true }); await mkdir(betaArchive, { recursive: true });
for (const s of servers) { 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.SERVERS_ROOT = t5;
process.env.T6_SERVERS_ROOT = t6;
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp'; process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
process.env.BETA_ARCHIVE_DIR = betaArchive; process.env.BETA_ARCHIVE_DIR = betaArchive;
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
@@ -37,6 +42,7 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P
return { return {
root, root,
t5, t5,
t6,
betaArchive, betaArchive,
dbPath, dbPath,
cleanup: async () => { cleanup: async () => {
+115
View File
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { readFile, rm } from 'node:fs/promises'; import { readFile, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js'; 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', () => ({ vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
komodoCreateDeployment: vi.fn(async () => ({ ok: true })), 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', () => { describe('server creation map selection', () => {
it('returns a queued creation before provisioning and reserves its name and port', async () => { it('returns a queued creation before provisioning and reserves its name and port', async () => {
const { POST } = await import('../../src/routes/api/servers/+server.js'); const { POST } = await import('../../src/routes/api/servers/+server.js');
+17 -1
View File
@@ -2,8 +2,24 @@ import { describe, expect, it } from 'vitest';
import { import {
parseServerName, parseServerName,
t5ZombieMapBySlug, t5ZombieMapBySlug,
T5_ZOMBIE_MAPS T5_ZOMBIE_MAPS,
T6_ZOMBIE_MAPS
} from '../../src/lib/server/fleet/map-aliases.js'; } 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_<mode>_<slug>_<n>)', () => { describe('parseServerName (new naming: t5_<mode>_<slug>_<n>)', () => {
it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => { it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => {
+91
View File
@@ -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();
});
});