config and plugin
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { composeCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js';
|
||||
|
||||
const BASELINE = [
|
||||
'// GENERAL',
|
||||
'set g_inactivity "190"',
|
||||
'set sv_voice "1"',
|
||||
'',
|
||||
'// GAMEPLAY',
|
||||
'set g_playerCollision "0"',
|
||||
'set sv_maprotation "map zombie_theater"'
|
||||
].join('\n');
|
||||
|
||||
describe('parseCfg', () => {
|
||||
it('captures set keys and preserves comments/blanks', () => {
|
||||
const p = parseCfg(BASELINE);
|
||||
expect(p.entries.get('g_inactivity')).toBe(1);
|
||||
expect(p.entries.get('sv_voice')).toBe(2);
|
||||
expect(p.entries.get('sv_maprotation')).toBe(6);
|
||||
expect(p.order[0]).toEqual({ kind: 'raw', line: '// GENERAL' });
|
||||
expect(p.order[3]).toEqual({ kind: 'raw', line: '' });
|
||||
});
|
||||
|
||||
it('detects CRLF line endings', () => {
|
||||
const crlf = 'set a "1"\r\nset b "2"\r\n';
|
||||
expect(parseCfg(crlf).eol).toBe('\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOverlay', () => {
|
||||
it('reads set lines and ignores comments/blanks', () => {
|
||||
const overlay = '// disable AFK\nset g_inactivity "0"\n';
|
||||
const kv = parseOverlay(overlay);
|
||||
expect(kv.size).toBe(1);
|
||||
expect(kv.get('g_inactivity')).toBe('set g_inactivity "0"');
|
||||
});
|
||||
|
||||
it('handles bare (unquoted) values', () => {
|
||||
const overlay = 'set g_logSync 1';
|
||||
const kv = parseOverlay(overlay);
|
||||
expect(kv.get('g_logSync')).toBe('set g_logSync 1');
|
||||
});
|
||||
|
||||
it('later duplicate wins within an overlay', () => {
|
||||
const overlay = 'set x "1"\nset x "2"';
|
||||
expect(parseOverlay(overlay).get('x')).toBe('set x "2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeCfg', () => {
|
||||
it('replaces matching keys in place', () => {
|
||||
const result = composeCfg(BASELINE, [{ id: 1, text: 'set g_inactivity "0"' }]);
|
||||
expect(result).toContain('set g_inactivity "0"');
|
||||
expect(result).not.toContain('set g_inactivity "190"');
|
||||
// Structure preserved: sv_voice should still be at its original spot.
|
||||
const lines = result.split('\n');
|
||||
expect(lines[1]).toBe('set g_inactivity "0"');
|
||||
expect(lines[2]).toBe('set sv_voice "1"');
|
||||
});
|
||||
|
||||
it('appends new keys not present in baseline', () => {
|
||||
const result = composeCfg(BASELINE, [{ id: 1, text: 'set new_flag "yes"' }]);
|
||||
expect(result).toContain('set new_flag "yes"');
|
||||
expect(result).toContain('// --- appended by electric-cherry overlays ---');
|
||||
});
|
||||
|
||||
it('applies multiple overlays deterministically by id', () => {
|
||||
const result = composeCfg(BASELINE, [
|
||||
{ id: 2, text: 'set g_inactivity "60"' },
|
||||
{ id: 1, text: 'set g_inactivity "0"' }
|
||||
]);
|
||||
// id=2 is later → wins
|
||||
expect(result).toContain('set g_inactivity "60"');
|
||||
expect(result).not.toContain('set g_inactivity "0"');
|
||||
});
|
||||
|
||||
it('preserves inline comments on the overlay line', () => {
|
||||
const result = composeCfg(BASELINE, [
|
||||
{ id: 1, text: 'set g_inactivity "0" // AFK disabled' }
|
||||
]);
|
||||
expect(result).toContain('set g_inactivity "0" // AFK disabled');
|
||||
});
|
||||
|
||||
it('is a no-op when no overlays are given', () => {
|
||||
const result = composeCfg(BASELINE, []);
|
||||
expect(result.trim()).toBe(BASELINE.trim());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFile, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(['t5_kino_1']);
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
const BASELINE_CFG = [
|
||||
'// GENERAL',
|
||||
'set g_inactivity "190"',
|
||||
'set sv_voice "1"',
|
||||
'',
|
||||
'set sv_maprotation "map zombie_theater"'
|
||||
].join('\n');
|
||||
|
||||
async function seedFleet(): Promise<void> {
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
// Pre-create the configs directory so the config write path finds a
|
||||
// hospitable server tree.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'configs'), { recursive: true });
|
||||
}
|
||||
|
||||
describe('config baseline + overlay flow', () => {
|
||||
it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores .OLD on rollback', async () => {
|
||||
await seedFleet();
|
||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
|
||||
// 1. Upload baseline for game=t5.
|
||||
const { script: baselineScript } = createScript({
|
||||
name: 't5 baseline',
|
||||
kind: 'baseline',
|
||||
scopeType: 'game',
|
||||
scopeValue: 't5',
|
||||
content: Buffer.from(BASELINE_CFG, 'utf8')
|
||||
});
|
||||
const bVer = getLatestVersion(baselineScript.id)!;
|
||||
|
||||
// 2. Upload an overlay for game=t5.
|
||||
const { script: overlayScript } = createScript({
|
||||
name: 'disable afk',
|
||||
kind: 'config',
|
||||
scopeType: 'game',
|
||||
scopeValue: 't5',
|
||||
content: Buffer.from('set g_inactivity "0"', 'utf8')
|
||||
});
|
||||
const oVer = getLatestVersion(overlayScript.id)!;
|
||||
|
||||
// 3. Bundle both into one beta.
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [
|
||||
{ scriptId: baselineScript.id, scriptVersionId: bVer.id },
|
||||
{ scriptId: overlayScript.id, scriptVersionId: oVer.id }
|
||||
],
|
||||
servers: ['t5_kino_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
// 4. Verify: composed cfg lands at configs/dedicated_sp.cfg (in place,
|
||||
// NO -beta suffix — the game reads the exact filename).
|
||||
const cfgPath = join(fixture.t5, 't5_kino_1', 'configs', 'dedicated_sp.cfg');
|
||||
expect(existsSync(cfgPath)).toBe(true);
|
||||
const composed = (await readFile(cfgPath)).toString('utf8');
|
||||
expect(composed).toContain('set g_inactivity "0"');
|
||||
expect(composed).not.toContain('set g_inactivity "190"');
|
||||
// Baseline lines that weren't overridden are preserved.
|
||||
expect(composed).toContain('set sv_voice "1"');
|
||||
|
||||
// 5. Rollback restores the pre-beta state. There was no cfg on disk
|
||||
// beforehand, so rollback should just remove the beta-written file.
|
||||
await rollbackBeta(betaId);
|
||||
expect(existsSync(cfgPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('requires an existing baseline before overlays can compose', async () => {
|
||||
await seedFleet();
|
||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
|
||||
const { script: overlayScript } = createScript({
|
||||
name: 'lonely overlay',
|
||||
kind: 'config',
|
||||
scopeType: 'game',
|
||||
scopeValue: 't5',
|
||||
content: Buffer.from('set g_inactivity "0"', 'utf8')
|
||||
});
|
||||
const oVer = getLatestVersion(overlayScript.id)!;
|
||||
|
||||
// openBeta returns success at the SQL level but the deploy step
|
||||
// marks the server 'failed' with a "no baseline" error because no
|
||||
// baseline is available.
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: overlayScript.id, scriptVersionId: oVer.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
const row = db()
|
||||
.prepare(`SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, 't5_kino_1') as { stage: string; error: string | null };
|
||||
expect(row.stage).toBe('failed');
|
||||
expect(row.error ?? '').toMatch(/no baseline available/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin kind writes to plugin destination', () => {
|
||||
it('deploys a plugin .dll under runtime/plutonium/plugins with -beta suffix', async () => {
|
||||
await seedFleet();
|
||||
// Pre-create the plugins dir so the write can succeed.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
|
||||
const { script } = createScript({
|
||||
name: 'demo plugin',
|
||||
kind: 'plugin',
|
||||
targetRelPath: 'demo.dll',
|
||||
scopeType: 'game',
|
||||
scopeValue: 't5',
|
||||
content: Buffer.from([0x00, 0x01, 0x02, 0x03])
|
||||
});
|
||||
const v = getLatestVersion(script.id)!;
|
||||
|
||||
const { betaId } = await openBeta({
|
||||
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
|
||||
servers: ['t5_kino_1'],
|
||||
waitForHealth: false,
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo-beta.dll');
|
||||
expect(existsSync(pluginPath)).toBe(true);
|
||||
const bytes = await readFile(pluginPath);
|
||||
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
||||
expect(betaId).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user