Apply selected server maps automatically without config overlays

This commit is contained in:
2026-09-17 11:05:01 -04:00
parent 1cd4eb6f09
commit 3f0d6dd87d
12 changed files with 431 additions and 59 deletions
+21
View File
@@ -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 `manual` after a sweep — the UI still works, you just don't get real
restarts. Enough to develop the push/beta flow. 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 ## Tests
```bash ```bash
+2 -2
View File
@@ -16,7 +16,7 @@ import {
type ScriptRow, type ScriptRow,
type ScriptVersionRow type ScriptVersionRow
} from './scripts.js'; } from './scripts.js';
import { composeCfg } from './config-compose.js'; import { composeServerCfg } from './config-compose.js';
import { recordScript, removeScript, getServerScript } from './inventory.js'; import { recordScript, removeScript, getServerScript } from './inventory.js';
import { emit } from '../pipeline/bus.js'; import { emit } from '../pipeline/bus.js';
import type { BetaStage } 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 })) .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 = composeCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays]); const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap);
return { text: composed, baselineScriptId }; return { text: composed, baselineScriptId };
} }
+37
View File
@@ -14,6 +14,8 @@
// - 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';
interface CfgLine { interface CfgLine {
kind: 'set' | 'raw'; kind: 'set' | 'raw';
key?: string; key?: string;
@@ -122,3 +124,38 @@ export function composeCfg(baselineText: string, overlays: OverlayInput[]): stri
// matches upstream cfg conventions. // matches upstream cfg conventions.
return bodyLines.join(parsed.eol) + parsed.eol; 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;
}
+47 -18
View File
@@ -1,5 +1,6 @@
import { mkdir, chmod, rm } from 'node:fs/promises'; import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
import { config } from '../config.js'; import { dirname, join } from 'node:path';
import { config, destOfKind } from '../config.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { getServer } from './servers.js'; import { getServer } from './servers.js';
import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js'; import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js';
@@ -15,10 +16,13 @@ import {
type DeploymentEnvVar type DeploymentEnvVar
} from '../integrations/komodo.js'; } from '../integrations/komodo.js';
import { kick } from '../pipeline/worker.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 // Currently every t5 zombie server uses the same config file + game id. If we
// ever add MP or SP, split these based on mode. // 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'; const T5_ZOMBIE_GAME_ID = 't5sp';
export type CreateServerFailure = export type CreateServerFailure =
@@ -92,6 +96,11 @@ function buildEnv(args: {
{ 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: T5_ZOMBIE_GAME_ID },
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE }, { 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: 'PLUTO_SERVER_KEY', value: args.plutoKey },
{ variable: 'PLUTAINER_RCON_PASSWORD', value: config.rcon.password } { variable: 'PLUTAINER_RCON_PASSWORD', value: config.rcon.password }
]; ];
@@ -155,12 +164,26 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
} }
const hostDir = `${config.serversRoot}/${name}`; const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
const mapConfig = getServerMapConfig('t5', 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.
try { try {
await mkdir(hostDir, { recursive: true, mode: 0o777 }); await mkdir(hostDir, { recursive: true, mode: 0o777 });
await chmod(hostDir, 0o777); await chmod(hostDir, 0o777);
const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE);
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 });
await chmod(dirname(mapConfigPath), 0o777);
await writeFile(mapConfigPath, mapConfig);
await chmod(mapConfigPath, 0o666);
if (initialConfig) {
const configPath = join(destOfKind(name, 'config'), T5_ZOMBIE_CONFIG_FILE);
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
await chmod(dirname(configPath), 0o777);
await writeFile(configPath, initialConfig);
await chmod(configPath, 0o666);
}
} catch (err) { } catch (err) {
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` }; return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
} }
@@ -176,21 +199,27 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
// Step 3: DB row — insert *before* execute/Deploy so the sweep sees the // Step 3: DB row — insert *before* execute/Deploy so the sweep sees the
// server immediately even if Deploy hangs. // server immediately even if Deploy hangs.
try { try {
db().prepare( db().transaction(() => {
`INSERT INTO servers db().prepare(
(name, komodo_type, komodo_name, game, map, `INSERT INTO servers
game_mode, map_slug, host_dir, config_file, game_id, image, (name, komodo_type, komodo_name, game, map,
game_port, created_by) game_mode, map_slug, host_dir, config_file, game_id, image,
VALUES game_port, created_by)
(?, 'deployment', ?, 't5', ?, VALUES
?, ?, ?, ?, ?, ?, (?, 'deployment', ?, 't5', ?,
?, ?)` ?, ?, ?, ?, ?, ?,
).run( ?, ?)`
name, name, mapEntry.canonical, ).run(
input.gameMode, mapEntry.slug, hostDir, name, name, mapEntry.canonical,
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage, input.gameMode, mapEntry.slug, hostDir,
port, input.createdBy ?? 'ui' 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) { } catch (err) {
// Roll back Komodo + FS if the DB insert fails (very unlikely — most // Roll back Komodo + FS if the DB insert fails (very unlikely — most
// often a race where the same name got inserted twice). // often a race where the same name got inserted twice).
+29 -34
View File
@@ -14,12 +14,11 @@ import {
import { import {
getBaselineForScope, getBaselineForScope,
getVersion, getVersion,
listPromotedOverlaysForScope,
listScripts, listScripts,
type ScriptRow,
type ScriptVersionRow type ScriptVersionRow
} from './scripts.js'; } 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'; import { allServers, getServer, listServers } from './servers.js';
// Destinations drift scans, in a stable order so the UI shows scripts first. // Destinations drift scans, in a stable order so the UI shows scripts first.
@@ -92,6 +91,12 @@ async function walkFiles(root: string): Promise<string[]> {
export async function scanServerDrift(server: string): Promise<DriftEntry[]> { export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
const tracked = new Set(listServerScripts(server).map((r) => r.rel_path)); const tracked = new Set(listServerScripts(server).map((r) => r.rel_path));
const drift: DriftEntry[] = []; 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. // Pass 1 — untracked files on disk in each managed destination.
for (const kind of DRIFT_DESTS) { for (const kind of DRIFT_DESTS) {
@@ -101,6 +106,9 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
for (const relPath of onDisk) { for (const relPath of onDisk) {
if (tracked.has(relPath)) continue; 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); const abs = join(dest, relPath);
try { try {
const st = await stat(abs); const st = await stat(abs);
@@ -133,6 +141,19 @@ async function scanMissingForServer(server: string): Promise<DriftEntry[]> {
if (!srv) return []; if (!srv) return [];
const out: DriftEntry[] = []; 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 => { const scopeApplies = (r: { scope_type: string | null; scope_value: string | null }): boolean => {
if (!r.scope_type || !r.scope_value) return false; if (!r.scope_type || !r.scope_value) return false;
const field = r.scope_type === 'game' ? srv.game : srv.map; const field = r.scope_type === 'game' ? srv.game : srv.map;
@@ -170,7 +191,7 @@ async function scanMissingForServer(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(server, srv.game, srv.map); const composed = composeExpectedCfg(srv.game, srv.map);
if (composed) { if (composed) {
const dest = destOfKind(server, 'config'); const dest = destOfKind(server, 'config');
const targetRel = config.configTargetFile; const targetRel = config.configTargetFile;
@@ -205,7 +226,8 @@ async function writeMissingEntry(server: string, entry: DriftEntry): Promise<Buf
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;
const composed = composeExpectedCfg(server, srv.game, srv.map); if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map);
const composed = composeExpectedCfg(srv.game, srv.map);
return composed?.content ?? null; return composed?.content ?? null;
} }
if (!entry.scriptVersionId) return null; if (!entry.scriptVersionId) return null;
@@ -214,38 +236,11 @@ async function writeMissingEntry(server: string, entry: DriftEntry): Promise<Buf
} }
function composeExpectedCfg( function composeExpectedCfg(
server: string,
game: string | null, game: string | null,
map: string | null map: string | null
): { content: Buffer; sha256: string } | null { ): { content: Buffer; sha256: string } | null {
if (!game) return null; const content = getPromotedServerConfig(game, map);
const baseline = getBaselineForScope('game', game); if (!content) return null;
if (!baseline?.current_version_id) return null;
const baselineVer = getVersion(baseline.current_version_id);
if (!baselineVer) return null;
const scopeApplies = (r: ScriptRow): boolean => {
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<number>();
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');
return { content, sha256: sha256Buffer(content) }; return { content, sha256: sha256Buffer(content) };
} }
+6 -1
View File
@@ -32,6 +32,11 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
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());
@@ -81,6 +86,6 @@ 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)?.canonical ?? null; const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null;
return { game, gameMode, mapSlug, mapCanonical, lobby }; return { game, gameMode, mapSlug, mapCanonical, lobby };
} }
+29
View File
@@ -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');
}
@@ -117,7 +117,7 @@
<Select bind:value={gameMode} options={modeOptions} disabled={deploying} /> <Select bind:value={gameMode} options={modeOptions} disabled={deploying} />
</Field> </Field>
<Field label="Map"> <Field label="Map" hint="Loads automatically when the server starts. No map overlay needed.">
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} /> <Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field> </Field>
+2 -2
View File
@@ -181,7 +181,7 @@
<div class="page"> <div class="page">
<SectionHeader <SectionHeader
title="Configs" title="Configs"
subtitle="One baseline per game, plus scoped overlays that patch it." subtitle="One baseline per game, plus scoped overlays. Each server's selected map is applied automatically."
/> />
{#if successBanner} {#if successBanner}
@@ -237,7 +237,7 @@
</div> </div>
</Field> </Field>
<div class="full"> <div class="full">
<Field label="Overlay lines" hint="One or more `set KEY &quot;VALUE&quot;` lines. Replaces baseline lines with the same KEY."> <Field label="Overlay lines" hint="One or more `set KEY &quot;VALUE&quot;` lines. The server's selected map takes precedence over sv_maprotation; no map-choice overlay is needed.">
<Textarea <Textarea
bind:value={overlayText} bind:value={overlayText}
rows={4} rows={4}
+41 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { composeCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js'; import { composeCfg, composeServerCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js';
import { T5_ZOMBIE_MAPS } from '../../src/lib/server/fleet/map-aliases.js';
const BASELINE = [ const BASELINE = [
'// GENERAL', '// GENERAL',
@@ -86,3 +87,42 @@ describe('composeCfg', () => {
expect(result.trim()).toBe(BASELINE.trim()); expect(result.trim()).toBe(BASELINE.trim());
}); });
}); });
describe('automatic map selection', () => {
it.each(T5_ZOMBIE_MAPS)('selects $friendly without a map overlay', ({ canonical }) => {
const result = composeServerCfg(BASELINE, [], 't5', canonical);
expect(result).toContain(`set sv_maprotation "map ${canonical}"`);
expect(result.match(/^set sv_maprotation /gm)).toHaveLength(1);
expect(result).toContain('set sv_voice "1"');
});
it('overrides legacy map-choice overlays while keeping other settings', () => {
const result = composeServerCfg(BASELINE, [{
id: 5, text: 'set sv_maprotation "map zombie_moon"\nset g_inactivity "0"'
}], 't5', 'zombie_cosmodrome');
expect(result).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(result).not.toContain('map zombie_moon');
expect(result).toContain('set g_inactivity "0"');
});
it('appends the rotation when absent and preserves CRLF', () => {
expect(composeServerCfg('set sv_voice "1"\r\n', [], 't5', 'zombietron'))
.toBe('set sv_voice "1"\r\n// Map selected by Electric Cherry\r\nset sv_maprotation "map zombietron"\r\n');
});
it('removes conflicting duplicate assignments regardless of case or command form', () => {
const result = composeServerCfg([
'// keep this comment',
'set sv_maprotation "map zombie_theater"',
'seta SV_MAPROTATION "map zombie_moon"',
'sv_mapRotation "map zombie_pentagon"'
].join('\n'), [], 't5', 'zombie_cod5_sumpf');
expect(result).toBe('// keep this comment\nset sv_maprotation "map zombie_cod5_sumpf"\n');
});
it.each([
[null, null], ['t5', null], ['t5', 'custom_map'], ['t6', 'zombie_theater']
])('preserves existing behavior for unsupported game/map %s/%s', (game, map) => {
expect(composeServerCfg(BASELINE, [], game, map)).toBe(composeCfg(BASELINE, []));
});
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
komodoDeploy: vi.fn(async () => ({ ok: true })),
komodoDestroyDeployment: vi.fn(async () => ({ ok: true })),
komodoDeleteDeployment: vi.fn(async () => ({ ok: true })),
komodoInspectHealthy: vi.fn(),
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
}));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture;
const serverName = 't5_zombie_cosmodrome_1';
const input = { gameMode: 'zombie' as const, mapSlug: 'cosmodrome', plutoKey: 'test-key' };
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
fixture = await newFixture([]);
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
vi.stubEnv('KOMODO_API_KEY', 'test');
vi.stubEnv('KOMODO_API_SECRET', 'test');
vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host');
vi.stubEnv('RCON_PASSWORD', 'test-rcon');
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
async function promotedConfig() {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
for (const artifact of [
{ name: 'baseline', kind: 'baseline' as const, scopeType: 'game' as const, scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\nset sv_maprotation "map zombie_theater"\n') },
{ name: 'map settings', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_cosmodrome',
content: Buffer.from('set sv_maprotation "map zombie_moon"\nset g_inactivity "0"\n') },
{ name: 'other map', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_theater',
content: Buffer.from('set sv_voice "0"\n') }
]) {
const { script } = createScript(artifact);
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(getLatestVersion(script.id)!.id, script.id);
}
}
describe('server creation map selection', () => {
it('keeps Plutainer defaults without a baseline and overrides the map before rotation', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const result = await createServer(input);
expect(result).toMatchObject({ ok: true, name: serverName });
expect(existsSync(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'))).toBe(false);
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value]));
expect(env.PLUTAINER_MAP_ROTATE).toBe('false');
expect(env.PLUTAINER_EXTRA_ARGS).toBe('+exec electric_cherry_map.cfg +map_rotate');
expect(await readFile(join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg'), 'utf8'))
.toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(env.PLUTAINER_CONFIG_FILE).toBe('dedicated_sp.cfg');
expect(env.PLUTAINER_SKIP_SEED).toBeUndefined();
expect(komodoDeploy).toHaveBeenCalledWith(serverName);
expect(getServer(serverName)?.map).toBe('zombie_cosmodrome');
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
expect(await scanServerDrift(serverName)).toEqual([]);
const mapPath = join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg');
await rm(mapPath);
expect((await runDriftDeployment({ servers: [serverName] })).totalRestored).toBe(1);
expect(await readFile(mapPath, 'utf8')).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(await scanServerDrift(serverName)).toEqual([]);
});
it('writes promoted settings and selected map before first boot and tracks the file', async () => {
await promotedConfig();
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
const config = await readFile(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'), 'utf8');
expect(config).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(config).toContain('set sv_voice "1"');
expect(config).toContain('set g_inactivity "0"');
expect(config).not.toContain('map zombie_moon');
expect(getServerScript(serverName, 'dedicated_sp.cfg')?.source).toBe('create');
return { ok: true };
});
expect(await createServer(input)).toMatchObject({ ok: true });
expect(await scanServerDrift(serverName)).toEqual([]);
});
it('removes the generated config and inventory if deployment fails', async () => {
await promotedConfig();
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
vi.mocked(komodoDeploy).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'test failure' });
expect(await createServer(input)).toMatchObject({ ok: false, kind: 'komodo' });
expect(getServer(serverName)).toBeUndefined();
expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined();
expect(existsSync(join(fixture.t5, serverName))).toBe(false);
});
it('rejects unknown maps before creating a deployment', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
expect(await createServer({ ...input, mapSlug: 'unknown' })).toMatchObject({ ok: false, kind: 'unknown-map' });
expect(komodoCreateDeployment).not.toHaveBeenCalled();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1'];
const cfgPath = (server: string) => join(fixture.t5, server, 'configs', 'dedicated_sp.cfg');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(servers);
vi.stubEnv('KOMODO_ADDRESS', '');
vi.stubEnv('RCON_PASSWORD', '');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
for (const server of servers) await mkdir(join(fixture.t5, server, 'configs'), { recursive: true });
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
describe('map selection through config lifecycle', () => {
it('uses each server map on beta, promotion, and drift repair without map overlays', async () => {
const { createScript, getLatestVersion } = 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 { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
name: 'baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\nset sv_maprotation "map zombie_moon"\n')
});
const version = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
servers: [servers[0]], waitForHealth: false, waitForMatch: false
});
expect(await readFile(cfgPath(servers[0]), 'utf8')).toContain('set sv_maprotation "map zombie_theater"');
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(servers));
const ascension = await readFile(cfgPath(servers[1]), 'utf8');
expect(ascension).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(ascension).toContain('set sv_voice "1"');
for (const server of servers) expect(await scanServerDrift(server)).toEqual([]);
await rm(cfgPath(servers[1]));
const repaired = await runDriftDeployment({ servers: [servers[1]] });
expect(repaired.totalRestored).toBe(1);
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(ascension);
expect(await scanServerDrift(servers[1])).toEqual([]);
});
it('preserves unrelated legacy overlay settings and restores exact prior config on rollback', async () => {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const baseline = createScript({
name: 'baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\n')
}).script;
const baselineVersion = getLatestVersion(baseline.id)!;
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baselineVersion.id, baseline.id);
const overlay = createScript({
name: 'legacy map choice', kind: 'config', scopeType: 'map', scopeValue: 'zombie_cosmodrome',
content: Buffer.from('set sv_maprotation "map zombie_moon"\nset g_inactivity "0"\n')
}).script;
const oldConfig = '// operator config\r\nset sv_maprotation "map zombie_cosmodrome"\r\n';
await writeFile(cfgPath(servers[1]), oldConfig);
const { betaId } = await openBeta({
scripts: [{ scriptId: overlay.id, scriptVersionId: getLatestVersion(overlay.id)!.id }],
servers: [servers[1]], waitForHealth: false, waitForMatch: false
});
const preview = await readFile(cfgPath(servers[1]), 'utf8');
expect(preview).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(preview).toContain('set g_inactivity "0"');
expect(preview).not.toContain('map zombie_moon');
await rollbackBeta(betaId);
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(oldConfig);
});
it('keeps Plutainer default config during reconciliation until a baseline is promoted', async () => {
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
const defaults = 'set sv_voice "1"\nset sv_maprotation "map zombie_theater"\n';
await writeFile(cfgPath(servers[1]), defaults);
await writeFile(join(fixture.t5, servers[1], 'configs', 'stray.cfg'), 'set stray "1"');
const drift = await scanServerDrift(servers[1]);
expect(drift.map((entry) => entry.relPath)).toEqual(['stray.cfg']);
await runDriftDeployment({ servers: [servers[1]] });
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(defaults);
});
});