// 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; // KEY → index in `order` eol: '\n' | '\r\n'; } // Matches `set KEY `. 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(); 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 { const out = new Map(); 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(); 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; }