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
+41 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { composeCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js';
import { composeCfg, composeServerCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js';
import { T5_ZOMBIE_MAPS } from '../../src/lib/server/fleet/map-aliases.js';
const BASELINE = [
'// GENERAL',
@@ -86,3 +87,42 @@ describe('composeCfg', () => {
expect(result.trim()).toBe(BASELINE.trim());
});
});
describe('automatic map selection', () => {
it.each(T5_ZOMBIE_MAPS)('selects $friendly without a map overlay', ({ canonical }) => {
const result = composeServerCfg(BASELINE, [], 't5', canonical);
expect(result).toContain(`set sv_maprotation "map ${canonical}"`);
expect(result.match(/^set sv_maprotation /gm)).toHaveLength(1);
expect(result).toContain('set sv_voice "1"');
});
it('overrides legacy map-choice overlays while keeping other settings', () => {
const result = composeServerCfg(BASELINE, [{
id: 5, text: 'set sv_maprotation "map zombie_moon"\nset g_inactivity "0"'
}], 't5', 'zombie_cosmodrome');
expect(result).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(result).not.toContain('map zombie_moon');
expect(result).toContain('set g_inactivity "0"');
});
it('appends the rotation when absent and preserves CRLF', () => {
expect(composeServerCfg('set sv_voice "1"\r\n', [], 't5', 'zombietron'))
.toBe('set sv_voice "1"\r\n// Map selected by Electric Cherry\r\nset sv_maprotation "map zombietron"\r\n');
});
it('removes conflicting duplicate assignments regardless of case or command form', () => {
const result = composeServerCfg([
'// keep this comment',
'set sv_maprotation "map zombie_theater"',
'seta SV_MAPROTATION "map zombie_moon"',
'sv_mapRotation "map zombie_pentagon"'
].join('\n'), [], 't5', 'zombie_cod5_sumpf');
expect(result).toBe('// keep this comment\nset sv_maprotation "map zombie_cod5_sumpf"\n');
});
it.each([
[null, null], ['t5', null], ['t5', 'custom_map'], ['t6', 'zombie_theater']
])('preserves existing behavior for unsupported game/map %s/%s', (game, map) => {
expect(composeServerCfg(BASELINE, [], game, map)).toBe(composeCfg(BASELINE, []));
});
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
komodoDeploy: vi.fn(async () => ({ ok: true })),
komodoDestroyDeployment: vi.fn(async () => ({ ok: true })),
komodoDeleteDeployment: vi.fn(async () => ({ ok: true })),
komodoInspectHealthy: vi.fn(),
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
}));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture;
const serverName = 't5_zombie_cosmodrome_1';
const input = { gameMode: 'zombie' as const, mapSlug: 'cosmodrome', plutoKey: 'test-key' };
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
fixture = await newFixture([]);
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
vi.stubEnv('KOMODO_API_KEY', 'test');
vi.stubEnv('KOMODO_API_SECRET', 'test');
vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host');
vi.stubEnv('RCON_PASSWORD', 'test-rcon');
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
async function promotedConfig() {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
for (const artifact of [
{ name: 'baseline', kind: 'baseline' as const, scopeType: 'game' as const, scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\nset sv_maprotation "map zombie_theater"\n') },
{ name: 'map settings', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_cosmodrome',
content: Buffer.from('set sv_maprotation "map zombie_moon"\nset g_inactivity "0"\n') },
{ name: 'other map', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_theater',
content: Buffer.from('set sv_voice "0"\n') }
]) {
const { script } = createScript(artifact);
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(getLatestVersion(script.id)!.id, script.id);
}
}
describe('server creation map selection', () => {
it('keeps Plutainer defaults without a baseline and overrides the map before rotation', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const result = await createServer(input);
expect(result).toMatchObject({ ok: true, name: serverName });
expect(existsSync(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'))).toBe(false);
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value]));
expect(env.PLUTAINER_MAP_ROTATE).toBe('false');
expect(env.PLUTAINER_EXTRA_ARGS).toBe('+exec electric_cherry_map.cfg +map_rotate');
expect(await readFile(join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg'), 'utf8'))
.toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(env.PLUTAINER_CONFIG_FILE).toBe('dedicated_sp.cfg');
expect(env.PLUTAINER_SKIP_SEED).toBeUndefined();
expect(komodoDeploy).toHaveBeenCalledWith(serverName);
expect(getServer(serverName)?.map).toBe('zombie_cosmodrome');
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
expect(await scanServerDrift(serverName)).toEqual([]);
const mapPath = join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg');
await rm(mapPath);
expect((await runDriftDeployment({ servers: [serverName] })).totalRestored).toBe(1);
expect(await readFile(mapPath, 'utf8')).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(await scanServerDrift(serverName)).toEqual([]);
});
it('writes promoted settings and selected map before first boot and tracks the file', async () => {
await promotedConfig();
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
const config = await readFile(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'), 'utf8');
expect(config).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(config).toContain('set sv_voice "1"');
expect(config).toContain('set g_inactivity "0"');
expect(config).not.toContain('map zombie_moon');
expect(getServerScript(serverName, 'dedicated_sp.cfg')?.source).toBe('create');
return { ok: true };
});
expect(await createServer(input)).toMatchObject({ ok: true });
expect(await scanServerDrift(serverName)).toEqual([]);
});
it('removes the generated config and inventory if deployment fails', async () => {
await promotedConfig();
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
vi.mocked(komodoDeploy).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'test failure' });
expect(await createServer(input)).toMatchObject({ ok: false, kind: 'komodo' });
expect(getServer(serverName)).toBeUndefined();
expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined();
expect(existsSync(join(fixture.t5, serverName))).toBe(false);
});
it('rejects unknown maps before creating a deployment', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
expect(await createServer({ ...input, mapSlug: 'unknown' })).toMatchObject({ ok: false, kind: 'unknown-map' });
expect(komodoCreateDeployment).not.toHaveBeenCalled();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1'];
const cfgPath = (server: string) => join(fixture.t5, server, 'configs', 'dedicated_sp.cfg');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(servers);
vi.stubEnv('KOMODO_ADDRESS', '');
vi.stubEnv('RCON_PASSWORD', '');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
for (const server of servers) await mkdir(join(fixture.t5, server, 'configs'), { recursive: true });
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
describe('map selection through config lifecycle', () => {
it('uses each server map on beta, promotion, and drift repair without map overlays', async () => {
const { createScript, getLatestVersion } = 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 { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
name: 'baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\nset sv_maprotation "map zombie_moon"\n')
});
const version = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
servers: [servers[0]], waitForHealth: false, waitForMatch: false
});
expect(await readFile(cfgPath(servers[0]), 'utf8')).toContain('set sv_maprotation "map zombie_theater"');
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(servers));
const ascension = await readFile(cfgPath(servers[1]), 'utf8');
expect(ascension).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(ascension).toContain('set sv_voice "1"');
for (const server of servers) expect(await scanServerDrift(server)).toEqual([]);
await rm(cfgPath(servers[1]));
const repaired = await runDriftDeployment({ servers: [servers[1]] });
expect(repaired.totalRestored).toBe(1);
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(ascension);
expect(await scanServerDrift(servers[1])).toEqual([]);
});
it('preserves unrelated legacy overlay settings and restores exact prior config on rollback', async () => {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const baseline = createScript({
name: 'baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\n')
}).script;
const baselineVersion = getLatestVersion(baseline.id)!;
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baselineVersion.id, baseline.id);
const overlay = createScript({
name: 'legacy map choice', kind: 'config', scopeType: 'map', scopeValue: 'zombie_cosmodrome',
content: Buffer.from('set sv_maprotation "map zombie_moon"\nset g_inactivity "0"\n')
}).script;
const oldConfig = '// operator config\r\nset sv_maprotation "map zombie_cosmodrome"\r\n';
await writeFile(cfgPath(servers[1]), oldConfig);
const { betaId } = await openBeta({
scripts: [{ scriptId: overlay.id, scriptVersionId: getLatestVersion(overlay.id)!.id }],
servers: [servers[1]], waitForHealth: false, waitForMatch: false
});
const preview = await readFile(cfgPath(servers[1]), 'utf8');
expect(preview).toContain('set sv_maprotation "map zombie_cosmodrome"');
expect(preview).toContain('set g_inactivity "0"');
expect(preview).not.toContain('map zombie_moon');
await rollbackBeta(betaId);
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(oldConfig);
});
it('keeps Plutainer default config during reconciliation until a baseline is promoted', async () => {
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
const defaults = 'set sv_voice "1"\nset sv_maprotation "map zombie_theater"\n';
await writeFile(cfgPath(servers[1]), defaults);
await writeFile(join(fixture.t5, servers[1], 'configs', 'stray.cfg'), 'set stray "1"');
const drift = await scanServerDrift(servers[1]);
expect(drift.map((entry) => entry.relPath)).toEqual(['stray.cfg']);
await runDriftDeployment({ servers: [servers[1]] });
expect(await readFile(cfgPath(servers[1]), 'utf8')).toBe(defaults);
});
});