Add T6 Zombies fleet support with Plutainer configuration
This commit is contained in:
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
export interface Fixture {
|
||||
root: string;
|
||||
t5: string;
|
||||
t6: string;
|
||||
betaArchive: string;
|
||||
dbPath: string;
|
||||
cleanup: () => Promise<void>;
|
||||
@@ -18,16 +19,20 @@ export interface Fixture {
|
||||
export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
||||
const t5 = join(root, 't5');
|
||||
const t6 = join(root, 't6');
|
||||
const betaArchive = join(root, 'beta-archive');
|
||||
const dbPath = join(root, 'cherry.db');
|
||||
await mkdir(t5, { recursive: true });
|
||||
await mkdir(t6, { recursive: true });
|
||||
await mkdir(betaArchive, { recursive: true });
|
||||
|
||||
for (const s of servers) {
|
||||
await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||
await mkdir(s.startsWith('t6_') ? join(t6, s, 'runtime/plutonium/storage/t6/scripts/zm')
|
||||
: join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||
}
|
||||
|
||||
process.env.SERVERS_ROOT = t5;
|
||||
process.env.T6_SERVERS_ROOT = t6;
|
||||
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||
process.env.BETA_ARCHIVE_DIR = betaArchive;
|
||||
process.env.DB_PATH = dbPath;
|
||||
@@ -37,6 +42,7 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P
|
||||
return {
|
||||
root,
|
||||
t5,
|
||||
t6,
|
||||
betaArchive,
|
||||
dbPath,
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { T6_ZOMBIE_MAPS } from '../../src/lib/map-aliases.js';
|
||||
|
||||
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
||||
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
|
||||
@@ -57,6 +58,120 @@ async function promotedConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
describe('T6 Zombies provisioning', () => {
|
||||
it('keeps T6 storage and config overrides separate from T5 settings', async () => {
|
||||
vi.stubEnv('T6_SERVERS_ROOT', join(fixture.root, 'custom-t6'));
|
||||
vi.stubEnv('T6_CONFIG_TARGET_FILE', 'custom_zm.cfg');
|
||||
vi.stubEnv('T6_REL_DEST_SCRIPT', 'custom/scripts/zm');
|
||||
vi.stubEnv('PLUTAINER_T6_GAMEFILES_VOLUME', 'bo2-files');
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const baseline = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', content: Buffer.from('sv_voice 1\n') });
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baseline.version.id, baseline.script.id);
|
||||
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { destOf, configFileOf } = await import('../../src/lib/server/config.js');
|
||||
const created = await createServer({ ...input, game: 't6', mapSlug: 'town' });
|
||||
if (!created.ok) throw new Error(created.message);
|
||||
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
|
||||
expect(spec.volumes).toContain('bo2-files:/home/plutainer/gamefiles:ro');
|
||||
expect(spec.environment).toContainEqual({ variable: 'PLUTAINER_CONFIG_FILE', value: 'custom_zm.cfg' });
|
||||
expect(existsSync(join(fixture.root, 'custom-t6', created.name, 'configs/custom_zm.cfg'))).toBe(true);
|
||||
expect(destOf(created.name)).toBe(`${join(fixture.root, 'custom-t6')}/${created.name}/custom/scripts/zm`);
|
||||
expect(configFileOf(serverName)).toBe('dedicated_sp.cfg');
|
||||
expect(destOf(serverName)).toBe(`${fixture.t5}/${serverName}/runtime/plutonium/storage/t5/raw/scripts/sp`);
|
||||
});
|
||||
|
||||
it.each(T6_ZOMBIE_MAPS)('provisions $friendly with the T6 mount, config, and ruleset', async (map) => {
|
||||
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const name = `t6_zombie_${map.slug}_1`;
|
||||
expect(await createServer({ ...input, game: 't6', mapSlug: map.slug })).toMatchObject({ ok: true, name });
|
||||
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
|
||||
const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value]));
|
||||
expect(env).toMatchObject({ PLUTAINER_GAME: 't6zm', PLUTAINER_CONFIG_FILE: 'dedicated_zm.cfg',
|
||||
PLUTAINER_RCON_PASSWORD: 'test-rcon', PLUTAINER_MAP_ROTATE: 'false',
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${name} +map_rotate` });
|
||||
expect(env.PLUTAINER_SKIP_SEED).toBeUndefined();
|
||||
expect(spec.volumes).toEqual([`${fixture.t6}/${name}:/home/plutainer/app`, 't6_game_files:/home/plutainer/gamefiles:ro']);
|
||||
expect(getServer(name)).toMatchObject({ game: 't6', game_id: 't6zm', map: map.canonical,
|
||||
map_slug: map.slug, config_file: 'dedicated_zm.cfg' });
|
||||
expect(await readFile(join(fixture.t6, name, 'configs/electric_cherry_map.cfg'), 'utf8'))
|
||||
.toContain(`set sv_maprotation "execgts ${map.gameSettings} map ${map.canonical}"`);
|
||||
expect(existsSync(join(fixture.t5, name))).toBe(false);
|
||||
expect(existsSync(join(fixture.t6, name, 'configs/dedicated_zm.cfg'))).toBe(false);
|
||||
});
|
||||
|
||||
it('persists T6 queued work, reuses its key, and allocates ports across both games', async () => {
|
||||
const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { saveServerKey, validServerId } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
||||
saveServerKey('t6_zombie_town_1', 't6-key');
|
||||
expect(validServerId('t6_zombie_town_20')).toBe(true);
|
||||
expect(validServerId('t5_zombie_town_1')).toBe(false);
|
||||
expect(validServerId('t6_zombie_theater_1')).toBe(false);
|
||||
const t5 = queueCreateServer(input);
|
||||
const t6 = queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town' });
|
||||
if (!t5.ok || !t6.ok) throw new Error('queue failed');
|
||||
expect(t6.port).not.toBe(t5.port);
|
||||
closeDb();
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
vi.mocked(rconStatusResult).mockImplementation(async (server) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||
status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : 'zombie_cosmodrome', gametype: null, roster: [] } }));
|
||||
await processCreations();
|
||||
expect(getDeployment(t6.deploymentId)?.deployment).toMatchObject({ state: 'done' });
|
||||
const content = await readFile(join(fixture.t6, t6.name, 'configs/electric_cherry_map.cfg'), 'utf8');
|
||||
expect(content).toContain('execgts zm_standard_town.cfg map zm_transit');
|
||||
});
|
||||
|
||||
it('installs only matching promoted artifacts into T6 paths and removes only its directory on destruction', async () => {
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const game of ['t5', 't6']) {
|
||||
for (const kind of ['script', 'baseline'] as const) {
|
||||
const { script, version } = createScript({ name: `${game}-${kind}`, kind, scopeType: 'game', scopeValue: game,
|
||||
targetRelPath: kind === 'script' ? `${game}.gsc` : undefined,
|
||||
content: Buffer.from(kind === 'script' ? 'main(){}' : 'sv_voice 1\nsv_maprotation "map wrong"\n') });
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
}
|
||||
}
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
|
||||
await createServer(input);
|
||||
const t6 = await createServer({ ...input, game: 't6', mapSlug: 'tomb' });
|
||||
if (!t6.ok) throw new Error(t6.message);
|
||||
expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t6.gsc'))).toBe(true);
|
||||
expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t5.gsc'))).toBe(false);
|
||||
expect(await readFile(join(fixture.t6, t6.name, 'configs/dedicated_zm.cfg'), 'utf8'))
|
||||
.toContain('execgts zm_classic_tomb.cfg map zm_tomb');
|
||||
expect(await scanServerDrift(t6.name)).toEqual([]);
|
||||
expect(destroyServer(t6.name).ok).toBe(true);
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
|
||||
await processDestructions();
|
||||
expect(existsSync(join(fixture.t6, t6.name))).toBe(false);
|
||||
expect(existsSync(join(fixture.t5, serverName))).toBe(true);
|
||||
expect(getServerKey(t6.name)).toBe('test-key');
|
||||
});
|
||||
|
||||
it('validates game/map combinations at the API boundary', async () => {
|
||||
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||
for (const game of ['t7', null, 6]) {
|
||||
const res = await POST({ request: new Request('http://localhost/api/servers', {
|
||||
method: 'POST', body: JSON.stringify({ ...input, game })
|
||||
}) });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
expect(queueCreateServer({ ...input, game: 't6' })).toMatchObject({ ok: false, kind: 'unknown-map' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('server creation map selection', () => {
|
||||
it('returns a queued creation before provisioning and reserves its name and port', async () => {
|
||||
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||
|
||||
@@ -2,8 +2,24 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseServerName,
|
||||
t5ZombieMapBySlug,
|
||||
T5_ZOMBIE_MAPS
|
||||
T5_ZOMBIE_MAPS,
|
||||
T6_ZOMBIE_MAPS
|
||||
} from '../../src/lib/server/fleet/map-aliases.js';
|
||||
import { serverTitle } from '../../src/lib/ui/server-labels.js';
|
||||
|
||||
describe('T6 map identity', () => {
|
||||
it.each(T6_ZOMBIE_MAPS)('parses and labels $friendly without losing its variant', (map) => {
|
||||
const name = `t6_zombie_${map.slug}_2`;
|
||||
expect(parseServerName(name)).toMatchObject({ game: 't6', gameMode: 'zombie', mapCanonical: map.canonical, mapSlug: map.slug, lobby: 2 });
|
||||
expect(serverTitle(name)).toBe(`${map.friendly} · Lobby 2`);
|
||||
});
|
||||
|
||||
it('does not interpret another game or mode through the T5 catalog', () => {
|
||||
for (const name of ['t6_zombie_theater_1', 't5_zombie_tomb_1', 't5_mp_theater_1', 'iw5_theater_1']) {
|
||||
expect(parseServerName(name)?.mapCanonical).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServerName (new naming: t5_<mode>_<slug>_<n>)', () => {
|
||||
it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
const town = 't6_zombie_town_1';
|
||||
const farm = 't6_zombie_farm_1';
|
||||
const t5 = 't5_zombie_theater_1';
|
||||
const cfgPath = (server: string) => join(fixture.t6, server, 'configs/dedicated_zm.cfg');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([town, farm, t5]);
|
||||
vi.stubEnv('KOMODO_ADDRESS', '');
|
||||
vi.stubEnv('RCON_PASSWORD', '');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
for (const name of [town, farm]) await mkdir(join(fixture.t6, name, 'configs'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('T6 config lifecycle in a mixed fleet', () => {
|
||||
it('preserves variants through beta, scope promotion, drift repair, and campaign rollback', async () => {
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
|
||||
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const previous = '// prior operator settings\r\nsv_voice 0\r\n';
|
||||
await writeFile(cfgPath(town), previous);
|
||||
const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6',
|
||||
content: Buffer.from('sv_voice 1\nsv_maprotation "map zm_buried"\n') });
|
||||
expect(script.target_rel_path).toBe('dedicated_zm.cfg');
|
||||
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
|
||||
servers: [town], waitForHealth: false, waitForMatch: false });
|
||||
expect(await readFile(cfgPath(town), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
|
||||
db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), betaId);
|
||||
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId);
|
||||
const promoted = await promoteBeta(betaId);
|
||||
expect(new Set(promoted.landed)).toEqual(new Set([town, farm]));
|
||||
const farmConfig = await readFile(cfgPath(farm), 'utf8');
|
||||
expect(farmConfig).toContain('execgts zm_standard_farm.cfg map zm_transit');
|
||||
expect(existsSync(join(fixture.t5, t5, 'configs/dedicated_sp.cfg'))).toBe(false);
|
||||
expect(existsSync(join(fixture.t6, farm, 'configs/dedicated_sp.cfg'))).toBe(false);
|
||||
expect(await scanServerDrift(farm)).toEqual([]);
|
||||
await rm(cfgPath(farm));
|
||||
expect((await runDriftDeployment({ servers: [farm] })).totalRestored).toBe(1);
|
||||
expect(await readFile(cfgPath(farm), 'utf8')).toBe(farmConfig);
|
||||
await rollbackPromoteDeployment(promoted.deploymentId);
|
||||
expect(await readFile(cfgPath(town), 'utf8')).toBe(previous);
|
||||
expect(existsSync(cfgPath(farm))).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the exact pre-beta T6 config and preserves seeded rulesets during drift repair', async () => {
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
|
||||
const previous = 'sv_voice 1\n';
|
||||
await writeFile(cfgPath(town), previous);
|
||||
const settings = join(fixture.t6, town, 'runtime/plutonium/storage/t6/gamesettings');
|
||||
await mkdir(settings, { recursive: true });
|
||||
await writeFile(join(settings, 'zm_standard_town.cfg'), 'ui_zm_mapstartlocation town\n');
|
||||
await writeFile(join(fixture.t6, town, 'configs/dedicated.cfg'), 'sv_voice 1\n');
|
||||
const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6',
|
||||
content: Buffer.from('sv_voice 0\n') });
|
||||
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
|
||||
servers: [town], waitForHealth: false, waitForMatch: false });
|
||||
await rollbackBeta(betaId);
|
||||
expect(await readFile(cfgPath(town), 'utf8')).toBe(previous);
|
||||
expect(await scanServerDrift(town)).toEqual([]);
|
||||
await runDriftDeployment({ servers: [town] });
|
||||
expect(await readFile(join(settings, 'zm_standard_town.cfg'), 'utf8')).toBe('ui_zm_mapstartlocation town\n');
|
||||
});
|
||||
|
||||
it('reads the T6 game log and keeps log path traversal checks', async () => {
|
||||
const { resolveLogPath, gameLogKey } = await import('../../src/lib/server/fleet/logs.js');
|
||||
const { scanMatchState } = await import('../../src/lib/server/fleet/match-log.js');
|
||||
const logs = join(fixture.t6, town, 'logs');
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(join(logs, 'games_zm.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n');
|
||||
expect(resolveLogPath(town, gameLogKey(town))).toBe(resolve(logs, 'games_zm.log'));
|
||||
expect(await scanMatchState(town)).toMatchObject({ kind: 'in-progress' });
|
||||
expect(resolveLogPath('t6_/../../escape', 'games_zm')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user