Add T6 Zombies fleet support with Plutainer configuration
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { env } from 'node:process';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
function req(name: string, fallback?: string): string {
|
||||
const v = env[name] ?? fallback;
|
||||
@@ -22,13 +23,16 @@ function positiveInt(name: string, fallback: number): number {
|
||||
|
||||
export const config = {
|
||||
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||
t6ServersRoot: env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
|
||||
t6RelDestScript: env.T6_REL_DEST_SCRIPT ?? 'runtime/plutonium/storage/t6/scripts/zm',
|
||||
t6ConfigTargetFile: env.T6_CONFIG_TARGET_FILE ?? 'dedicated_zm.cfg',
|
||||
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
|
||||
// Per-kind destination sub-paths under each server's directory. `relDest`
|
||||
// above stays as the script fallback so existing deploys keep working.
|
||||
relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp',
|
||||
relDestConfig: env.REL_DEST_CONFIG ?? 'configs',
|
||||
relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins',
|
||||
// Filename the baseline+overlays compose into. Same for every scope today.
|
||||
// Legacy T5 config filename. T6 has its own target above.
|
||||
configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg',
|
||||
// Where on-disk copies of active beta files live for operator visibility.
|
||||
// Cleared per-beta on promote/rollback.
|
||||
@@ -45,12 +49,13 @@ export const config = {
|
||||
// deploy flow — Komodo needs to know which machine to schedule the
|
||||
// container onto. Look up the exact name in Komodo's UI (Servers page).
|
||||
targetServer: env.KOMODO_TARGET_SERVER ?? '',
|
||||
// Default image + config for freshly-provisioned t5 zombie servers.
|
||||
// Default image for freshly-provisioned T5 and T6 zombie servers.
|
||||
// Overridable per-deploy in the UI later if we ever need to.
|
||||
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
|
||||
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
|
||||
// Named docker volume that holds the read-only game files layer.
|
||||
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files'
|
||||
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files',
|
||||
t6GameFilesVolume: env.PLUTAINER_T6_GAMEFILES_VOLUME ?? 't6_game_files'
|
||||
},
|
||||
// Shared across every newly-provisioned server. Kept in sync with the
|
||||
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond
|
||||
@@ -83,17 +88,24 @@ export function rconConfigured(): boolean {
|
||||
|
||||
export type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
|
||||
export function relDestForKind(kind: ArtifactKind): string {
|
||||
export function relDestForKind(kind: ArtifactKind, game = 't5'): string {
|
||||
switch (kind) {
|
||||
case 'script': return config.relDestScript;
|
||||
case 'script': return game === 't6' ? config.t6RelDestScript : config.relDestScript;
|
||||
case 'config': return config.relDestConfig;
|
||||
case 'baseline': return config.relDestConfig;
|
||||
case 'plugin': return config.relDestPlugin;
|
||||
}
|
||||
}
|
||||
|
||||
// Server IDs keep the game prefix even while creation is queued and no registry
|
||||
// row exists yet. Legacy IDs retain the original T5 filesystem defaults.
|
||||
export const gameOfServer = (server: string) => server.startsWith('t6_') ? 't6' : 't5';
|
||||
export const rootOfServer = (server: string) => gameOfServer(server) === 't6' ? config.t6ServersRoot : config.serversRoot;
|
||||
export const serverDir = (server: string) => `${rootOfServer(server)}/${server}`;
|
||||
export const configFileForGame = (game: string | null) => game === 't6' ? config.t6ConfigTargetFile : config.configTargetFile;
|
||||
export const configFileOf = (server: string) => configFileForGame(gameOfServer(server));
|
||||
export const destOfKind = (server: string, kind: ArtifactKind) =>
|
||||
`${config.serversRoot}/${server}/${relDestForKind(kind)}`;
|
||||
`${serverDir(server)}/${relDestForKind(kind, gameOfServer(server))}`;
|
||||
|
||||
// Back-compat: existing call sites written before the multi-kind refactor
|
||||
// resolve to the script destination.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||
@@ -29,7 +29,7 @@ function isCollapsingKind(kind: ArtifactKind): boolean {
|
||||
|
||||
// The path we should write to for the composed cfg on this server.
|
||||
function composedCfgPath(server: string): string {
|
||||
return join(destOfKind(server, 'config'), config.configTargetFile);
|
||||
return join(destOfKind(server, 'config'), configFileOf(server));
|
||||
}
|
||||
|
||||
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
|
||||
@@ -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 = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap);
|
||||
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
|
||||
return { text: composed, baselineScriptId };
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
}
|
||||
const dest = destOfKind(name, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetRel = configFileOf(name);
|
||||
const targetPath = join(dest, targetRel);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorBytes = await readFile(targetPath);
|
||||
@@ -504,7 +504,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
`beta:${betaId}`
|
||||
);
|
||||
// Archive the composed preview for operator visibility.
|
||||
await writeFile(join(archive, `${name}.${config.configTargetFile}`), composedBuf);
|
||||
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
|
||||
} catch (err) {
|
||||
firstErr = `configs: ${(err as Error).message}`;
|
||||
}
|
||||
@@ -603,7 +603,7 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
// Collapsing config layer: single restore for the shared cfg file.
|
||||
if (hasCollapsing) {
|
||||
const dest = destOfKind(bs.server, 'config');
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetRel = configFileOf(bs.server);
|
||||
const targetPath = join(dest, targetRel);
|
||||
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||
@@ -828,7 +828,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
const composedSha = sha256Buffer(composedBuf);
|
||||
const dest = destOfKind(name, 'config');
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetRel = configFileOf(name);
|
||||
const targetPath = join(dest, targetRel);
|
||||
|
||||
let priorContent: Buffer | null = null;
|
||||
@@ -862,7 +862,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
try {
|
||||
const dest = destOfKind(cp.server, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetRel = configFileOf(cp.server);
|
||||
const targetPath = join(dest, targetRel);
|
||||
// Beta server already has composed content at targetPath; overwrite
|
||||
// idempotently. Scope-expansion server: write in place.
|
||||
@@ -942,7 +942,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
depId,
|
||||
cp.server,
|
||||
verb,
|
||||
config.configTargetFile,
|
||||
configFileOf(cp.server),
|
||||
cp.priorSha,
|
||||
cp.composedSha,
|
||||
cp.priorContent,
|
||||
@@ -950,7 +950,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
);
|
||||
recordScript(
|
||||
cp.server,
|
||||
config.configTargetFile,
|
||||
configFileOf(cp.server),
|
||||
null,
|
||||
cp.composedSha,
|
||||
cp.composedContent.byteLength,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// - 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';
|
||||
import { selectedZombieMap, mapRotation } from './map-aliases.js';
|
||||
|
||||
interface CfgLine {
|
||||
kind: 'set' | 'raw';
|
||||
@@ -130,17 +130,18 @@ export function composeServerCfg(
|
||||
baselineText: string,
|
||||
overlays: OverlayInput[],
|
||||
game: string | null,
|
||||
map: string | null
|
||||
map: string | null,
|
||||
mapSlug?: string | null
|
||||
): string {
|
||||
const composed = composeCfg(baselineText, overlays);
|
||||
const selected = game === 't5' ? T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map) : undefined;
|
||||
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 "map ${selected.canonical}"`;
|
||||
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;
|
||||
|
||||
@@ -2,11 +2,11 @@ import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config, destOfKind } from '../config.js';
|
||||
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { getServerKey, saveServerKey } from './server-keys.js';
|
||||
import { getServer } from './servers.js';
|
||||
import { t5ZombieMapBySlug, type GameMode, type T5MapEntry } from './map-aliases.js';
|
||||
import { zombieMapBySlug, type GameMode, type ZombieMapEntry, type ZombieGame } from './map-aliases.js';
|
||||
import {
|
||||
komodoCreateDeployment,
|
||||
komodoDeploy,
|
||||
@@ -24,11 +24,6 @@ import { recordScript } from './inventory.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { promotedArtifacts } from './promoted-artifacts.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 = config.configTargetFile;
|
||||
const T5_ZOMBIE_GAME_ID = 't5sp';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
| 'unconfigured' // Komodo creds / target server missing
|
||||
@@ -48,6 +43,7 @@ export type DestroyServerResult =
|
||||
| { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||
|
||||
export interface CreateServerInput {
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // saved for this server ID when supplied
|
||||
@@ -98,6 +94,7 @@ function nextAvailablePort(): number | null {
|
||||
}
|
||||
|
||||
function buildEnv(args: {
|
||||
game: ZombieGame;
|
||||
name: string;
|
||||
port: number;
|
||||
plutoKey: string;
|
||||
@@ -105,8 +102,8 @@ function buildEnv(args: {
|
||||
return [
|
||||
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name },
|
||||
{ variable: 'PLUTAINER_PORT', value: String(args.port) },
|
||||
{ variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID },
|
||||
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE },
|
||||
{ variable: 'PLUTAINER_GAME', value: args.game === 't6' ? 't6zm' : 't5sp' },
|
||||
{ variable: 'PLUTAINER_CONFIG_FILE', value: configFileForGame(args.game) },
|
||||
// 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.
|
||||
@@ -119,6 +116,7 @@ function buildEnv(args: {
|
||||
}
|
||||
|
||||
function buildSpec(args: {
|
||||
game: ZombieGame;
|
||||
name: string;
|
||||
port: number;
|
||||
hostDir: string;
|
||||
@@ -137,7 +135,7 @@ function buildSpec(args: {
|
||||
],
|
||||
volumes: [
|
||||
`${args.hostDir}:/home/plutainer/app`,
|
||||
`${config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
|
||||
`${args.game === 't6' ? config.komodo.t6GameFilesVolume : config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
|
||||
],
|
||||
environment: buildEnv(args)
|
||||
};
|
||||
@@ -145,7 +143,7 @@ function buildSpec(args: {
|
||||
|
||||
let creating = false;
|
||||
interface PreparedCreation {
|
||||
ok: true; name: string; port: number; plutoKey: string; mapEntry: T5MapEntry;
|
||||
ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry;
|
||||
}
|
||||
|
||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||
@@ -170,6 +168,10 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
|
||||
}
|
||||
|
||||
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
|
||||
const game = input.game ?? 't5';
|
||||
if (game !== 't5' && game !== 't6') {
|
||||
return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' };
|
||||
}
|
||||
if (input.gameMode !== 'zombie') {
|
||||
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
||||
}
|
||||
@@ -180,12 +182,12 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
||||
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
|
||||
}
|
||||
|
||||
const mapEntry = t5ZombieMapBySlug(input.mapSlug);
|
||||
const mapEntry = zombieMapBySlug(game, input.mapSlug);
|
||||
if (!mapEntry) {
|
||||
return { ok: false, kind: 'unknown-map', message: `no t5 zombie map with slug '${input.mapSlug}'` };
|
||||
return { ok: false, kind: 'unknown-map', message: `no ${game} zombie map with slug '${input.mapSlug}'` };
|
||||
}
|
||||
|
||||
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
|
||||
const baseName = `${game}_${input.gameMode}_${mapEntry.slug}`;
|
||||
const lobby = nextLobbyNumber(baseName);
|
||||
if (lobby === null) {
|
||||
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
|
||||
@@ -215,11 +217,13 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
||||
|
||||
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
||||
const { name, port, plutoKey, mapEntry } = prepared;
|
||||
const game = gameOfServer(name);
|
||||
const configFile = configFileForGame(game);
|
||||
deploymentStage(operation, 'Preparing server files');
|
||||
const hostDir = `${config.serversRoot}/${name}`;
|
||||
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
|
||||
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
|
||||
const initialArtifacts = promotedArtifacts('t5', mapEntry.canonical);
|
||||
const hostDir = serverDir(name);
|
||||
const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
|
||||
const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
|
||||
const initialArtifacts = promotedArtifacts(game, 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.
|
||||
@@ -244,7 +248,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
await writeFile(mapConfigPath, mapConfig);
|
||||
await chmod(mapConfigPath, 0o666);
|
||||
if (initialConfig) {
|
||||
const configPath = join(destOfKind(name, 'config'), T5_ZOMBIE_CONFIG_FILE);
|
||||
const configPath = join(destOfKind(name, 'config'), configFile);
|
||||
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
|
||||
await chmod(dirname(configPath), 0o777);
|
||||
await writeFile(configPath, initialConfig);
|
||||
@@ -256,7 +260,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
|
||||
// Step 2: Komodo CreateDeployment.
|
||||
deploymentStage(operation, 'Creating Komodo deployment');
|
||||
const spec = buildSpec({ name, port, hostDir, plutoKey });
|
||||
const spec = buildSpec({ game, name, port, hostDir, plutoKey });
|
||||
const createRes = await komodoCreateDeployment(spec);
|
||||
if (!createRes.ok) {
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
@@ -273,17 +277,17 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
game_mode, map_slug, host_dir, config_file, game_id, image,
|
||||
game_port, created_by, container_state)
|
||||
VALUES
|
||||
(?, 'deployment', ?, 't5', ?,
|
||||
(?, 'deployment', ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, 'starting')`
|
||||
).run(
|
||||
name, name, mapEntry.canonical,
|
||||
name, name, game, mapEntry.canonical,
|
||||
input.gameMode, mapEntry.slug, hostDir,
|
||||
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage,
|
||||
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage,
|
||||
port, input.createdBy ?? 'ui'
|
||||
);
|
||||
if (initialConfig) {
|
||||
recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
||||
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
||||
}
|
||||
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
|
||||
for (const { script, version } of initialArtifacts) {
|
||||
@@ -303,7 +307,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
// by game/map. Best-effort — a tag failure doesn't roll back the server;
|
||||
// the deployment is fully functional without cosmetic tags. Fires in
|
||||
// parallel to keep the create latency down.
|
||||
void applyDeploymentTags(name, 't5', mapEntry.canonical);
|
||||
void applyDeploymentTags(name, game, mapEntry.canonical);
|
||||
|
||||
// Step 5: execute/Deploy — actually start the container.
|
||||
deploymentStage(operation, 'Starting container');
|
||||
@@ -440,7 +444,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
||||
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
||||
// legacy rows.
|
||||
deploymentStage(operation, 'Removing server files and registry');
|
||||
const hostDir = row.host_dir ?? `${config.serversRoot}/${name}`;
|
||||
const hostDir = row.host_dir ?? serverDir(name);
|
||||
try {
|
||||
await rm(hostDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
@@ -481,7 +485,7 @@ async function provisionCreation(id: string): Promise<CreateServerResult> {
|
||||
creating = true;
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id);
|
||||
try {
|
||||
const mapEntry = t5ZombieMapBySlug(job.map_slug);
|
||||
const mapEntry = zombieMapBySlug(gameOfServer(job.server), job.map_slug);
|
||||
const plutoKey = getServerKey(job.server);
|
||||
if (!mapEntry || !plutoKey) {
|
||||
const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||
import { config, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './inventory.js';
|
||||
import { getBaselineForScope, getVersion } from './scripts.js';
|
||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { T5_ZOMBIE_MAPS } from './map-aliases.js';
|
||||
import { selectedZombieMap } from './map-aliases.js';
|
||||
import { serverDirectories, getServer, listServers } from './servers.js';
|
||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||
|
||||
@@ -92,8 +92,8 @@ export async function scanServerDrift(server: string): Promise<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)
|
||||
const keepDefaultConfig = srv?.game
|
||||
&& selectedZombieMap(srv.game, srv.map, srv.map_slug)
|
||||
&& !getBaselineForScope('game', srv.game)?.current_version_id;
|
||||
|
||||
// Scan for untracked files only after resolving managed targets.
|
||||
@@ -104,9 +104,9 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
||||
|
||||
for (const relPath of onDisk) {
|
||||
if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue;
|
||||
if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue;
|
||||
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) continue;
|
||||
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
|
||||
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue;
|
||||
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) continue;
|
||||
const abs = join(dest, relPath);
|
||||
try {
|
||||
const st = await stat(abs);
|
||||
@@ -138,7 +138,7 @@ async function scanManagedForServer(server: string): Promise<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;
|
||||
? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null;
|
||||
if (mapConfig) {
|
||||
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
||||
const content = await readFile(abs).catch(() => null);
|
||||
@@ -168,10 +168,10 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
||||
if (srv.game) {
|
||||
const baseline = getBaselineForScope('game', srv.game);
|
||||
if (baseline?.current_version_id) {
|
||||
const composed = composeExpectedCfg(srv.game, srv.map);
|
||||
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||
if (composed) {
|
||||
const dest = destOfKind(server, 'config');
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetRel = configFileOf(server);
|
||||
const abs = join(dest, targetRel);
|
||||
const inv = getServerScript(server, targetRel);
|
||||
const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256;
|
||||
@@ -203,8 +203,8 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
||||
if (entry.kind === 'config') {
|
||||
const srv = getServer(server);
|
||||
if (!srv?.game) return null;
|
||||
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map);
|
||||
const composed = composeExpectedCfg(srv.game, srv.map);
|
||||
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug);
|
||||
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||
return composed?.content ?? null;
|
||||
}
|
||||
if (!entry.scriptVersionId) return null;
|
||||
@@ -214,9 +214,10 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
||||
|
||||
function composeExpectedCfg(
|
||||
game: string | null,
|
||||
map: string | null
|
||||
map: string | null,
|
||||
mapSlug?: string | null
|
||||
): { content: Buffer; sha256: string } | null {
|
||||
const content = getPromotedServerConfig(game, map);
|
||||
const content = getPromotedServerConfig(game, map, mapSlug);
|
||||
if (!content) return null;
|
||||
return { content, sha256: sha256Buffer(content) };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { rootOfServer, gameOfServer } from '../config.js';
|
||||
|
||||
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||
// to each server's root directory. Adding a new supported log = one entry
|
||||
// here + one tab in LogPanel.svelte.
|
||||
export const KNOWN_LOGS = {
|
||||
console: 'logs/console.log',
|
||||
games_sp: 'logs/games_sp.log'
|
||||
games_sp: 'logs/games_sp.log',
|
||||
games_zm: 'logs/games_zm.log'
|
||||
} as const;
|
||||
|
||||
export type LogKey = keyof typeof KNOWN_LOGS;
|
||||
export const gameLogKey = (server: string): LogKey => gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp';
|
||||
|
||||
export function isLogKey(v: string): v is LogKey {
|
||||
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v);
|
||||
@@ -26,7 +28,7 @@ export function isLogKey(v: string): v is LogKey {
|
||||
* Returns null on any escape.
|
||||
*/
|
||||
export function resolveLogPath(serverName: string, key: LogKey): string | null {
|
||||
const serversRoot = resolve(config.serversRoot);
|
||||
const serversRoot = resolve(rootOfServer(serverName));
|
||||
const serverDir = resolve(serversRoot, serverName);
|
||||
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveLogPath, tailLog } from './logs.js';
|
||||
import { resolveLogPath, tailLog, gameLogKey } from './logs.js';
|
||||
|
||||
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
|
||||
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
|
||||
@@ -26,7 +26,7 @@ export async function scanForMatchEnd(
|
||||
serverName: string,
|
||||
sinceIso: string
|
||||
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
|
||||
const path = resolveLogPath(serverName, 'games_sp');
|
||||
const path = resolveLogPath(serverName, gameLogKey(serverName));
|
||||
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
|
||||
|
||||
const tail = await tailLog(path, 2000);
|
||||
@@ -68,7 +68,7 @@ export interface MatchScanResult {
|
||||
* - `no-signal`: log missing / unreadable / no InitGame in tail.
|
||||
*/
|
||||
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
|
||||
const path = resolveLogPath(serverName, 'games_sp');
|
||||
const path = resolveLogPath(serverName, gameLogKey(serverName));
|
||||
if (!path) return { kind: 'no-signal' };
|
||||
|
||||
const tail = await tailLog(path, 2000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { config, type ArtifactKind } from '../config.js';
|
||||
import { configFileForGame, type ArtifactKind } from '../config.js';
|
||||
|
||||
export type { ArtifactKind };
|
||||
|
||||
@@ -172,7 +172,8 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
||||
// the same on-disk cfg file, so the operator never picks it.
|
||||
let targetRelPath = input.targetRelPath?.trim() ?? '';
|
||||
if (!targetRelPath && (kind === 'config' || kind === 'baseline')) {
|
||||
targetRelPath = config.configTargetFile;
|
||||
targetRelPath = configFileForGame(input.scopeType === 'game' ? input.scopeValue ?? null
|
||||
: input.scopeValue?.startsWith('zm_') ? 't6' : 't5');
|
||||
}
|
||||
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { composeServerCfg } from './config-compose.js';
|
||||
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
|
||||
import { T5_ZOMBIE_MAPS } from './map-aliases.js';
|
||||
import { selectedZombieMap, mapRotation } 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;
|
||||
export function getServerMapConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
|
||||
const entry = selectedZombieMap(game, map, mapSlug);
|
||||
return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "${mapRotation(entry)}"\n`) : null;
|
||||
}
|
||||
|
||||
/** The promoted config used for first boot and drift reconciliation. */
|
||||
export function getPromotedServerConfig(game: string | null, map: string | null): Buffer | null {
|
||||
export function getPromotedServerConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
|
||||
if (!game) return null;
|
||||
const baseline = getBaselineForScope('game', game);
|
||||
if (!baseline?.current_version_id) return null;
|
||||
@@ -25,5 +25,5 @@ export function getPromotedServerConfig(game: string | null, map: string | null)
|
||||
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');
|
||||
return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map, mapSlug), 'utf8');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { t5ZombieMapBySlug } from './map-aliases.js';
|
||||
import { zombieMapBySlug } from './map-aliases.js';
|
||||
|
||||
export function validServerId(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const match = /^t5_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
|
||||
return !!match && !!t5ZombieMapBySlug(match[1]);
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
|
||||
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||
}
|
||||
|
||||
export function listServerKeys() {
|
||||
@@ -21,7 +21,7 @@ export function getServerKey(serverId: string): string | undefined {
|
||||
}
|
||||
|
||||
export function saveServerKey(serverId: unknown, key: unknown): void {
|
||||
if (!validServerId(serverId)) throw new Error('Choose a valid T5 zombie server ID with a lobby number from 1 to 20');
|
||||
if (!validServerId(serverId)) throw new Error('Choose a valid T5 or T6 zombie server ID with a lobby number from 1 to 20');
|
||||
if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) {
|
||||
throw new Error('Enter a server key without whitespace (maximum 512 characters)');
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { readdir, mkdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { config, destOf } from '../config.js';
|
||||
import { config, destOf, serverDir, rootOfServer } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { parseServerName } from './map-aliases.js';
|
||||
|
||||
/**
|
||||
* All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill
|
||||
* All server folders under the game roots. Used by seedServerRegistry to backfill
|
||||
* the servers table for any on-disk server directory that isn't yet tracked.
|
||||
*/
|
||||
export async function serverDirectories(): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(config.serversRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
const names = new Set<string>();
|
||||
for (const root of new Set([config.serversRoot, config.t6ServersRoot])) {
|
||||
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name);
|
||||
}
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
/** Fleet membership is owned by the database, independent of mounted files. */
|
||||
@@ -41,7 +39,7 @@ export function matchServers(all: string[], patterns: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a server has an sp/ destination. Creates it when create=true.
|
||||
* Ensure a server has a script destination. Creates it when create=true.
|
||||
*/
|
||||
export async function ensureDest(
|
||||
server: string,
|
||||
@@ -57,7 +55,7 @@ export async function ensureDest(
|
||||
}
|
||||
return { ok: true, path: dest };
|
||||
}
|
||||
if (!create) return { ok: false, reason: 'no sp folder — use create=true' };
|
||||
if (!create) return { ok: false, reason: 'no script folder — use create=true' };
|
||||
await mkdir(dest, { recursive: true });
|
||||
return { ok: true, path: dest };
|
||||
}
|
||||
@@ -127,7 +125,7 @@ export async function seedServerRegistry(): Promise<number> {
|
||||
const map = parsed?.mapCanonical ?? null;
|
||||
const gameMode = parsed?.gameMode ?? null;
|
||||
const mapSlug = parsed?.mapSlug ?? null;
|
||||
const hostDir = `${config.serversRoot}/${name}`;
|
||||
const hostDir = serverDir(name);
|
||||
const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir);
|
||||
if (res.changes > 0) {
|
||||
n++;
|
||||
@@ -136,7 +134,7 @@ export async function seedServerRegistry(): Promise<number> {
|
||||
}
|
||||
if (parsed && !parsed.mapCanonical) {
|
||||
console.warn(
|
||||
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to T5_ZOMBIE_MAPS`
|
||||
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to the game map catalog`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user