163 lines
6.1 KiB
TypeScript
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;
|
|
}
|