diff --git a/README.md b/README.md index ccda30f..59ddf85 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,27 @@ Without RCON_PASSWORD / KOMODO_* set, the pipeline marks pending rows `manual` after a sweep — the UI still works, you just don't get real restarts. Enough to develop the push/beta flow. +## Map selection and configs + +The map selected when deploying a T5 zombies server controls its startup +rotation automatically. No map-choice config overlay is required. New servers +use the promoted game baseline and matching overlays when available; otherwise +Plutainer seeds its usual defaults and Electric Cherry applies the selected map +through a generated `configs/electric_cherry_map.cfg`, loaded after the main +config and before map rotation. This file is tracked and restored by drift +reconciliation; operators do not need to create or promote it. The launch order +uses Plutainer's [extra arguments and map-rotation controls](https://github.com/Ayymoss/Plutainer/blob/main/docs/configuration.md). +Plutainer's +default main config is preserved until a game baseline is promoted. + +Beta previews, promotion, and config drift repair all apply the server's stored +catalog map after composing the baseline and overlays. An old `sv_maprotation` +overlay cannot override that selection, but its other settings still apply. +Existing servers receive this behavior on their next config deployment or drift +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. + ## Tests ```bash diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index e5cac32..d8db1bb 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -16,7 +16,7 @@ import { type ScriptRow, type ScriptVersionRow } from './scripts.js'; -import { composeCfg } from './config-compose.js'; +import { composeServerCfg } from './config-compose.js'; import { recordScript, removeScript, getServerScript } from './inventory.js'; import { emit } from '../pipeline/bus.js'; import type { BetaStage } from '../pipeline/bus.js'; @@ -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 = composeCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays]); + const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap); return { text: composed, baselineScriptId }; } diff --git a/src/lib/server/fleet/config-compose.ts b/src/lib/server/fleet/config-compose.ts index d9416a4..abf2930 100644 --- a/src/lib/server/fleet/config-compose.ts +++ b/src/lib/server/fleet/config-compose.ts @@ -14,6 +14,8 @@ // - 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'; + interface CfgLine { kind: 'set' | 'raw'; key?: string; @@ -122,3 +124,38 @@ export function composeCfg(baselineText: string, overlays: OverlayInput[]): stri // matches upstream cfg conventions. return bodyLines.join(parsed.eol) + parsed.eol; } + +/** Compose a server config, with its catalog map taking precedence over presets. */ +export function composeServerCfg( + baselineText: string, + overlays: OverlayInput[], + game: string | null, + map: string | null +): string { + const composed = composeCfg(baselineText, overlays); + const selected = game === 't5' ? T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map) : undefined; + // 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}"`; + // 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; + let applied = false; + const result: string[] = []; + for (const line of lines) { + if (!isRotation.test(line)) { + result.push(line); + } else if (!applied) { + result.push(rotation); + applied = true; + } + } + if (!applied) { + result.push('// Map selected by Electric Cherry', rotation); + } + return result.join(eol) + eol; +} diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 330a5b6..8b8ccf9 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -1,5 +1,6 @@ -import { mkdir, chmod, rm } from 'node:fs/promises'; -import { config } from '../config.js'; +import { mkdir, chmod, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { config, destOfKind } from '../config.js'; import { db } from '../db/index.js'; import { getServer } from './servers.js'; import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js'; @@ -15,10 +16,13 @@ import { type DeploymentEnvVar } from '../integrations/komodo.js'; import { kick } from '../pipeline/worker.js'; +import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; +import { recordScript } from './inventory.js'; +import { sha256Buffer } from './files.js'; // Currently every t5 zombie server uses the same config file + game id. If we // ever add MP or SP, split these based on mode. -const T5_ZOMBIE_CONFIG_FILE = 'dedicated_sp.cfg'; +const T5_ZOMBIE_CONFIG_FILE = config.configTargetFile; const T5_ZOMBIE_GAME_ID = 't5sp'; export type CreateServerFailure = @@ -92,6 +96,11 @@ function buildEnv(args: { { variable: 'PLUTAINER_PORT', value: String(args.port) }, { variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID }, { variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE }, + // 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. + { variable: 'PLUTAINER_MAP_ROTATE', value: 'false' }, + { variable: 'PLUTAINER_EXTRA_ARGS', value: `+exec ${MAP_CONFIG_FILE} +map_rotate` }, { variable: 'PLUTO_SERVER_KEY', value: args.plutoKey }, { variable: 'PLUTAINER_RCON_PASSWORD', value: config.rcon.password } ]; @@ -155,12 +164,26 @@ export async function createServer(input: CreateServerInput): Promise { + db().prepare( + `INSERT INTO servers + (name, komodo_type, komodo_name, game, map, + game_mode, map_slug, host_dir, config_file, game_id, image, + game_port, created_by) + VALUES + (?, 'deployment', ?, 't5', ?, + ?, ?, ?, ?, ?, ?, + ?, ?)` + ).run( + name, name, mapEntry.canonical, + input.gameMode, mapEntry.slug, hostDir, + T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage, + port, input.createdBy ?? 'ui' + ); + if (initialConfig) { + recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create'); + } + recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map'); + })(); } catch (err) { // Roll back Komodo + FS if the DB insert fails (very unlikely — most // often a race where the same name got inserted twice). diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index 835317d..ec27dd4 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -14,12 +14,11 @@ import { import { getBaselineForScope, getVersion, - listPromotedOverlaysForScope, listScripts, - type ScriptRow, type ScriptVersionRow } from './scripts.js'; -import { composeCfg } from './config-compose.js'; +import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; +import { T5_ZOMBIE_MAPS } from './map-aliases.js'; import { allServers, getServer, listServers } from './servers.js'; // Destinations drift scans, in a stable order so the UI shows scripts first. @@ -92,6 +91,12 @@ async function walkFiles(root: string): Promise { export async function scanServerDrift(server: string): Promise { const tracked = new Set(listServerScripts(server).map((r) => r.rel_path)); const drift: DriftEntry[] = []; + 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) + && !getBaselineForScope('game', srv.game)?.current_version_id; // Pass 1 — untracked files on disk in each managed destination. for (const kind of DRIFT_DESTS) { @@ -101,6 +106,9 @@ export async function scanServerDrift(server: string): Promise { for (const relPath of onDisk) { if (tracked.has(relPath)) continue; + if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue; + if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by + && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue; const abs = join(dest, relPath); try { const st = await stat(abs); @@ -133,6 +141,19 @@ async function scanMissingForServer(server: string): Promise { if (!srv) return []; const out: DriftEntry[] = []; + // 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; + if (mapConfig) { + const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE); + const content = await readFile(abs).catch(() => null); + if (!content?.equals(mapConfig)) { + out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: 'missing', + size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) }); + } + } + const scopeApplies = (r: { scope_type: string | null; scope_value: string | null }): boolean => { if (!r.scope_type || !r.scope_value) return false; const field = r.scope_type === 'game' ? srv.game : srv.map; @@ -170,7 +191,7 @@ async function scanMissingForServer(server: string): Promise { if (srv.game) { const baseline = getBaselineForScope('game', srv.game); if (baseline?.current_version_id) { - const composed = composeExpectedCfg(server, srv.game, srv.map); + const composed = composeExpectedCfg(srv.game, srv.map); if (composed) { const dest = destOfKind(server, 'config'); const targetRel = config.configTargetFile; @@ -205,7 +226,8 @@ async function writeMissingEntry(server: string, entry: DriftEntry): Promise { - if (!r.scope_type || !r.scope_value) return false; - const field = r.scope_type === 'game' ? game : map; - return field === r.scope_value; - }; - - const overlayInputs: Array<{ id: number; text: string }> = []; - const seen = new Set(); - const collect = (rows: ScriptRow[]): void => { - for (const r of rows) { - if (seen.has(r.id) || !scopeApplies(r) || !r.current_version_id) continue; - const v = getVersion(r.current_version_id); - if (!v) continue; - overlayInputs.push({ id: r.id, text: v.content.toString('utf8') }); - seen.add(r.id); - } - }; - collect(listPromotedOverlaysForScope('game', game)); - if (map) collect(listPromotedOverlaysForScope('map', map)); - - const text = composeCfg(baselineVer.content.toString('utf8'), overlayInputs); - const content = Buffer.from(text, 'utf8'); + const content = getPromotedServerConfig(game, map); + if (!content) return null; return { content, sha256: sha256Buffer(content) }; } diff --git a/src/lib/server/fleet/map-aliases.ts b/src/lib/server/fleet/map-aliases.ts index a8ad70d..d794a77 100644 --- a/src/lib/server/fleet/map-aliases.ts +++ b/src/lib/server/fleet/map-aliases.ts @@ -32,6 +32,11 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([ 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()); @@ -81,6 +86,6 @@ export function parseServerName(name: string): ParsedServerName | null { if (slugParts.length === 0) return null; const mapSlug = slugParts.join('').toLowerCase(); - const mapCanonical = BY_SLUG.get(mapSlug)?.canonical ?? null; + const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null; return { game, gameMode, mapSlug, mapCanonical, lobby }; } diff --git a/src/lib/server/fleet/server-config.ts b/src/lib/server/fleet/server-config.ts new file mode 100644 index 0000000..fb68264 --- /dev/null +++ b/src/lib/server/fleet/server-config.ts @@ -0,0 +1,29 @@ +import { composeServerCfg } from './config-compose.js'; +import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js'; +import { T5_ZOMBIE_MAPS } 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; +} + +/** The promoted config used for first boot and drift reconciliation. */ +export function getPromotedServerConfig(game: string | null, map: string | null): Buffer | null { + if (!game) return null; + const baseline = getBaselineForScope('game', game); + if (!baseline?.current_version_id) return null; + const version = getVersion(baseline.current_version_id); + if (!version) return null; + + const overlays = [ + ...listPromotedOverlaysForScope('game', game), + ...(map ? listPromotedOverlaysForScope('map', map) : []) + ].flatMap((overlay) => { + 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'); +} diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 6cf9da4..77901d7 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -117,7 +117,7 @@ diff --git a/src/routes/configs/+page.svelte b/src/routes/configs/+page.svelte index 6c9ef26..6df6b5b 100644 --- a/src/routes/configs/+page.svelte +++ b/src/routes/configs/+page.svelte @@ -181,7 +181,7 @@
{#if successBanner} @@ -237,7 +237,7 @@
- +