Apply selected server maps automatically without config overlays
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user