Apply selected server maps automatically without config overlays
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CreateServ
|
||||
}
|
||||
|
||||
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
|
||||
// after — plutainer expects to be able to write to /home/plutainer/app.
|
||||
try {
|
||||
await mkdir(hostDir, { recursive: true, mode: 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) {
|
||||
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
|
||||
// server immediately even if Deploy hangs.
|
||||
try {
|
||||
db().prepare(
|
||||
`INSERT INTO servers
|
||||
(name, komodo_type, komodo_name, game, map,
|
||||
game_mode, map_slug, host_dir, config_file, game_id, image,
|
||||
game_port, created_by)
|
||||
VALUES
|
||||
(?, 'deployment', ?, 't5', ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?)`
|
||||
).run(
|
||||
name, name, mapEntry.canonical,
|
||||
input.gameMode, mapEntry.slug, hostDir,
|
||||
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage,
|
||||
port, input.createdBy ?? 'ui'
|
||||
);
|
||||
db().transaction(() => {
|
||||
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).
|
||||
|
||||
@@ -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<string[]> {
|
||||
export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
||||
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<DriftEntry[]> {
|
||||
|
||||
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<DriftEntry[]> {
|
||||
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<DriftEntry[]> {
|
||||
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<Buf
|
||||
if (entry.kind === 'config') {
|
||||
const srv = getServer(server);
|
||||
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;
|
||||
}
|
||||
if (!entry.scriptVersionId) return null;
|
||||
@@ -214,38 +236,11 @@ async function writeMissingEntry(server: string, entry: DriftEntry): Promise<Buf
|
||||
}
|
||||
|
||||
function composeExpectedCfg(
|
||||
server: string,
|
||||
game: string | null,
|
||||
map: string | null
|
||||
): { content: Buffer; sha256: string } | null {
|
||||
if (!game) return null;
|
||||
const baseline = getBaselineForScope('game', game);
|
||||
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');
|
||||
const content = getPromotedServerConfig(game, map);
|
||||
if (!content) return null;
|
||||
return { content, sha256: sha256Buffer(content) };
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
||||
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = 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<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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</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} />
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
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}
|
||||
@@ -237,7 +237,7 @@
|
||||
</div>
|
||||
</Field>
|
||||
<div class="full">
|
||||
<Field label="Overlay lines" hint="One or more `set KEY "VALUE"` lines. Replaces baseline lines with the same KEY.">
|
||||
<Field label="Overlay lines" hint="One or more `set KEY "VALUE"` lines. The server's selected map takes precedence over sv_maprotation; no map-choice overlay is needed.">
|
||||
<Textarea
|
||||
bind:value={overlayText}
|
||||
rows={4}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 = [
|
||||
'// GENERAL',
|
||||
@@ -86,3 +87,42 @@ describe('composeCfg', () => {
|
||||
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, []));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user