Apply selected server maps automatically without config overlays

This commit is contained in:
2026-09-17 11:05:01 -04:00
parent 1cd4eb6f09
commit 3f0d6dd87d
12 changed files with 431 additions and 59 deletions
+29 -34
View File
@@ -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) };
}