Files
electric-cherry/src/lib/server/fleet/config-compose.ts
T

163 lines
6.1 KiB
TypeScript

// Kino-style dedicated_sp.cfg files are line-oriented: `set KEY VALUE` lines
// interleaved with `//` comments and blank lines. This module parses a
// baseline, applies overlays (each a bag of `set KEY ...` lines that should
// override the baseline's line for that KEY), and renders the composed file
// back to text while preserving structure.
//
// Semantics:
// - Overlays are matched to baseline lines by KEY only. Overlay's full raw
// line replaces the baseline's line at that position.
// - Overlay KEYs not present in the baseline are appended at the end.
// - Multiple overlays apply in the order given. Later overlays override
// earlier ones on the same KEY.
// - Comments (// ...) and blank lines are preserved as-is.
// - Line endings default to \n for the composed output. Baseline CRLF is
// preserved if the baseline uses CRLF throughout.
import { selectedZombieMap, mapRotation } from './map-aliases.js';
interface CfgLine {
kind: 'set' | 'raw';
key?: string;
line: string; // full line text without trailing newline
}
interface ParsedCfg {
order: CfgLine[];
entries: Map<string, number>; // KEY → index in `order`
eol: '\n' | '\r\n';
}
// Matches `set KEY <rest of line>`. KEY is any non-whitespace token. Leading
// whitespace and inline `//` comments after the value are preserved via the
// captured full line.
const SET_LINE_RE = /^\s*set\s+(\S+)\s+/;
export function parseCfg(text: string): ParsedCfg {
// Detect CRLF vs LF. If either \r\n appears we treat the file as CRLF.
const eol: '\n' | '\r\n' = /\r\n/.test(text) ? '\r\n' : '\n';
const rawLines = text.split(/\r?\n/);
// Trailing newline in the source produces an empty final split element —
// drop it so we don't emit a spurious blank line on the round-trip.
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop();
const order: CfgLine[] = [];
const entries = new Map<string, number>();
for (const line of rawLines) {
const m = SET_LINE_RE.exec(line);
if (m) {
const key = m[1];
// If the same KEY appears twice in the baseline, later occurrence
// wins (matches how the Plutonium runtime reads the cfg top-down).
entries.set(key, order.length);
order.push({ kind: 'set', key, line });
} else {
order.push({ kind: 'raw', line });
}
}
return { order, entries, eol };
}
/**
* Parse an overlay's text. Returns KEY → full replacement line. Blank/comment
* lines are ignored. If the overlay lists the same KEY twice, the last wins.
*/
export function parseOverlay(text: string): Map<string, string> {
const out = new Map<string, string>();
const lines = text.split(/\r?\n/);
for (const line of lines) {
const m = SET_LINE_RE.exec(line);
if (!m) continue;
out.set(m[1], line);
}
return out;
}
export interface OverlayInput {
/** Stable identifier used to determine application order (asc). */
id: number;
text: string;
}
/**
* Compose a baseline cfg with a set of overlays. Overlays apply in `id`
* ascending order — deterministic across scopes.
*/
export function composeCfg(baselineText: string, overlays: OverlayInput[]): string {
const parsed = parseCfg(baselineText);
// Determine application order deterministically.
const sorted = [...overlays].sort((a, b) => a.id - b.id);
// Merge overlay entries: later overlays win per KEY.
const merged = new Map<string, string>();
for (const overlay of sorted) {
const kvs = parseOverlay(overlay.text);
for (const [k, line] of kvs) merged.set(k, line);
}
// Apply: replace in-place where the KEY exists in the baseline, else
// append. Appended lines preserve overlay order (Map iteration order is
// insertion order — which matches overlay id ascending).
const appended: string[] = [];
for (const [k, line] of merged) {
const idx = parsed.entries.get(k);
if (idx !== undefined) {
parsed.order[idx] = { kind: 'set', key: k, line };
} else {
appended.push(line);
}
}
const bodyLines = parsed.order.map((l) => l.line);
if (appended.length > 0) {
// Ensure a visual separator before appended lines if the baseline
// doesn't already end with a blank line — cheap ergonomics for
// operators inspecting the composed file.
const last = bodyLines[bodyLines.length - 1];
if (last !== undefined && last.trim() !== '') bodyLines.push('');
bodyLines.push('// --- appended by electric-cherry overlays ---');
for (const line of appended) bodyLines.push(line);
}
// Preserve trailing newline: Plutonium tolerates either, but adding one
// 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,
mapSlug?: string | null
): string {
const composed = composeCfg(baselineText, overlays);
const selected = selectedZombieMap(game, map, mapSlug);
// 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 "${mapRotation(selected)}"`;
// 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;
}