Add T6 Zombies fleet support with Plutainer configuration

This commit is contained in:
2026-09-17 18:03:12 -04:00
parent 1350e6df7c
commit 9dcf4ac1d3
28 changed files with 492 additions and 146 deletions
+31 -27
View File
@@ -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' };