Simplify telemetry to three game events

This commit is contained in:
2026-09-18 15:35:21 -04:00
parent 6d903adb31
commit 5bb49a776e
29 changed files with 658 additions and 1314 deletions
+119 -54
View File
@@ -3,6 +3,7 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
import { defaultPlan, requirement, type BetaPlan } from '../../src/lib/beta-plan.js';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoRestart: vi.fn(), komodoInspectHealthy: vi.fn() }));
@@ -77,56 +78,50 @@ describe('durable beta test plans', () => {
expect(getBeta(id)?.state).toBe('ready-promote');
});
it('ignores pre-beta log markers, handles partial lines, and persists cursors across app restarts', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches'), requirement('clean-logs', 'logs')] });
it('uses script events instead of logs and keeps match progress across app restarts', async () => {
await postTelemetry(gameEvent('game_end'));
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches')] });
await tick(); await tick();
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(getBeta(id)?.state).toBe('running');
expect(testView(id)?.servers[0].progress.map(p => p.current)).toEqual([0, 0]);
const log = join(fixture.t5, names[0], 'logs/games_sp.log');
await appendFile(log, 'InitGame:\n 0:00 J;new;1;Josh\nExitLevel: exe');
await appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'InitGame:\n 0:00 J;new;1;Josh\nExitLevel: executed\n');
await tick();
expect(getBeta(id)?.state).toBe('running');
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('game_start'));
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
await appendFile(log, 'cuted\n');
await tick(); await tick();
expect(getBeta(id)?.state).toBe('ready-promote');
await postTelemetry(gameEvent('game_end'));
await tick();
expect(testView(id)?.servers[0].progress[0].current).toBe(1);
await appendFile(join(fixture.t5, names[0], 'logs/console.log'), 'script runtime error: undefined variable\n');
await tick();
expect(getBeta(id)?.state).toBe('running');
expect(testView(id)?.outcome).toBe('failed');
});
it('accepts durable run-scoped round evidence and rejects stale runs and duplicate events', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round')] });
await tick(); await tick();
const { acceptTestSignal, currentRun, testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const run = currentRun(id, names[0])!;
const base = { bootId: 'boot', sessionId: 'session', sequence: 0, round: 0, players: 4, name: '' };
expect(acceptTestSignal(names[0], 'stale-run', 'old', { ...base, event: 'boot' }).accepted).toBe(false);
expect(acceptTestSignal(names[0], run.id, 'boot', { ...base, event: 'boot' }).accepted).toBe(true);
expect(acceptTestSignal(names[0], run.id, 'start', { ...base, event: 'session_start' }).accepted).toBe(true);
const round = { ...base, event: 'round_change' as const, sequence: 1, round: 25 };
expect(acceptTestSignal(names[0], run.id, 'round', round).accepted).toBe(true);
expect(acceptTestSignal(names[0], run.id, 'round', round).accepted).toBe(false);
await tick();
expect(testView(id)?.outcome).toBe('passed');
expect(testView(id)?.evidence.filter(e => e.kind === 'round_change')).toHaveLength(1);
});
it('resets evidence after planned recovery and only passes after testing the new process', async () => {
it('automatically credits ordinary rounds only to active deployed beta servers', async () => {
const id = await openPlan({ ...defaultPlan(), waveSize: 1, requirements: [requirement('round', 'round')] }, names);
await postTelemetry(gameEvent('round_change'));
await tick(); await tick();
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('round_change', 99, 4, names[1]));
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('round_change'));
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
expect(testView(id)?.evidence.filter(e => e.kind === 'round_change')).toHaveLength(1);
await tick();
expect(existsSync(scriptPath(names[1]))).toBe(true);
});
it('resets evidence after planned recovery and consumes the same simple reports afterward', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players'), requirement('recovery', 'recovery')] });
await tick(); await tick();
const { currentRun, testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const first = currentRun(id, names[0])!;
expect(testView(id)?.outcome).toBe('active');
await tick();
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('game_start'));
await tick(); await tick();
expect(currentRun(id, names[0])?.id).not.toBe(first.id);
expect(currentRun(id, names[0])?.phase).toBe(1);
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('game_start'));
await tick();
expect(testView(id)?.outcome).toBe('passed');
});
@@ -143,6 +138,7 @@ describe('durable beta test plans', () => {
it('revokes readiness if health fails while awaiting manual promotion', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players'), requirement('failure-budget', 'outages')] });
await tick(); await tick();
await postTelemetry(gameEvent('game_start')); await tick();
const { getBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
expect(getBeta(id)?.state).toBe('ready-promote');
await tick(15, 0, false);
@@ -152,10 +148,9 @@ describe('durable beta test plans', () => {
it('automatically rolls back only the deployed wave and queues safe rollback restarts', async () => {
await writeFile(scriptPath(names[0]), 'original');
const id = await openPlan({ ...defaultPlan(), waveSize: 1, failureAction: 'rollback', requirements: [requirement('round', 'round'), requirement('clean-logs', 'logs')] }, names);
const id = await openPlan({ ...defaultPlan(), waveSize: 1, failureAction: 'rollback', requirements: [requirement('round', 'round'), requirement('failure-budget', 'outages')] }, names);
await tick(); await tick();
await appendFile(join(fixture.t5, names[0], 'logs/console.log'), 'fatal error: test\n');
await tick();
await tick(15, 0, false);
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
expect(getBeta(id)?.state).toBe('rolled-back');
@@ -176,6 +171,7 @@ describe('durable beta test plans', () => {
it('retains artifact ownership while ready for promotion', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players')] });
await tick(); await tick();
await postTelemetry(gameEvent('game_start')); await tick();
const { heldByBeta, ownerOf } = await import('../../src/lib/server/fleet/servers.js');
expect(heldByBeta(names[0]).get('test.gsc')).toBe(id);
expect(ownerOf(names[0], 'test.gsc')).toBe(id);
@@ -185,32 +181,27 @@ describe('durable beta test plans', () => {
expect(ownerOf(names[0], 'test.gsc')).toBeNull();
});
it('does not carry joins into a truncated log or deduplicate a genuinely new match after rotation', async () => {
it('counts successive games with the same players and ignores repeated game ends', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [{ ...requirement('matches', 'matches'), target: 2 }] });
await tick(); await tick();
const log = join(fixture.t5, names[0], 'logs/games_sp.log');
await appendFile(log, 'InitGame:\n 0:00 J;first;1;Josh\nExitLevel: executed\n');
await tick();
// Same inode, contents replaced with a larger file: anchor detects truncation/regrowth.
await writeFile(log, 'InitGame:\n 0:00 J;second;1;Josh\n' + 'padding\n'.repeat(30) + 'ExitLevel: executed\n');
await tick();
await postTelemetry(gameEvent('game_start'));
await postTelemetry(gameEvent('game_end'));
await postTelemetry(gameEvent('game_end'));
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(testView(id)?.servers[0].progress[0].current).toBe(1);
await postTelemetry(gameEvent('game_start'));
await postTelemetry(gameEvent('game_end'));
await tick();
expect(testView(id)?.servers[0].progress[0].current).toBe(2);
expect(testView(id)?.outcome).toBe('passed');
});
it('exposes current run tokens, validates session payloads, and records checklist sign-off through the API', async () => {
it('records checklist sign-off and starts a fresh attempt without changing script payloads', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round'), requirement('manual', 'hud')] });
await tick(); await tick();
const telemetry = await import('../../src/routes/api/telemetry/+server.js');
const context = await (await telemetry.GET({ url: new URL(`http://test/api/telemetry?server=${names[0]}`) })).json();
expect(context.runs).toHaveLength(1);
const body = { server: names[0], runId: context.runs[0].runId, eventId: 'boot', bootId: 'boot', sequence: 0, round: 0, players: 0, event: 'boot' };
const post = (payload: unknown) => telemetry.POST({ request: new Request('http://test/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) });
expect((await post({ ...body, eventId: null })).status).toBe(400);
expect(await (await post(body)).json()).toMatchObject({ test: { accepted: true } });
await post({ ...body, eventId: 'start', event: 'session_start', sessionId: 'game', players: 4 });
await post({ ...body, eventId: 'round', event: 'round_change', sessionId: 'game', sequence: 1, round: 25, players: 4 });
expect(telemetry).not.toHaveProperty('GET');
expect(await (await postTelemetry(gameEvent('round_change'))).json()).not.toHaveProperty('test');
const controls = await import('../../src/routes/api/betas/[id]/test/+server.js');
const result = await controls.POST({ params: { id: String(id) }, request: new Request('http://test', { method: 'POST', body: JSON.stringify({ action: 'sign-off', server: names[0], requirementId: 'hud', note: 'Josh: HUD verified' }) }) });
expect(result.status).toBe(200);
@@ -219,8 +210,82 @@ describe('durable beta test plans', () => {
expect(testView(id)?.outcome).toBe('passed');
const retry = await controls.POST({ params: { id: String(id) }, request: new Request('http://test', { method: 'POST', body: JSON.stringify({ action: 'retry' }) }) });
expect(retry.status).toBe(200);
expect(await (await post({ ...body, eventId: 'late' })).json()).toMatchObject({ test: { accepted: false } });
await postTelemetry(gameEvent('round_change'));
expect(testView(id)?.servers).toHaveLength(0);
await tick(); await tick();
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
await postTelemetry(gameEvent('round_change'));
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
});
it('keeps Fleet reporting during pauses without crediting paused gameplay or elapsed time', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [{ ...requirement('populated-minutes', 'time'), target: 60 }] });
await tick(); await tick();
await postTelemetry(gameEvent('game_start'));
vi.setSystemTime(Date.now() + 600_000);
await postTelemetry(gameEvent('round_change'));
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
const controls = await import('../../src/routes/api/betas/[id]/test/+server.js');
const control = (action: string) => controls.POST({ params: { id: String(id) }, request: new Request('http://test', {
method: 'POST', body: JSON.stringify({ action })
}) });
expect((await control('pause')).status).toBe(200);
vi.setSystemTime(Date.now() + 3_600_000);
await postTelemetry(gameEvent('game_end', 50));
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()[0]).toMatchObject({ event: 'game_end', round: 50 });
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
expect((await control('resume')).status).toBe(200);
await postTelemetry(gameEvent('round_change', 1));
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
vi.setSystemTime(Date.now() + 600_000);
await postTelemetry(gameEvent('round_change', 2));
expect(testView(id)?.servers[0].progress[0].current).toBe(20);
});
it('migrates existing unsupported checks into review items and resets active progress once', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round')] });
await tick(); await tick();
await postTelemetry(gameEvent('round_change'));
await tick();
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const oldPlan = { ...defaultPlan(), requirements: [requirement('round', 'round'),
...['feature', 'restart-budget', 'clean-logs'].map((kind, i) => ({ ...requirement('manual', `old-${i}`), kind, event: 'perk.purchased' }))] };
db().prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?').run(JSON.stringify(oldPlan), id);
db().prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?)').run('Old plan', JSON.stringify(oldPlan));
db().prepare("DELETE FROM settings WHERE key = 'game_telemetry_v2'").run();
closeDb();
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
const view = testView(id)!;
expect(view.plan.requirements.map(r => r.kind)).toEqual(['round', 'manual', 'manual', 'manual']);
expect(view.plan.requirements.some(r => 'event' in r)).toBe(false);
expect(view.servers[0].progress.every(p => !p.passed)).toBe(true);
expect(view.evidence.some(e => e.kind === 'round_change')).toBe(true);
expect(getBeta(id)?.state).toBe('running');
const presets = await import('../../src/routes/api/betas/presets/+server.js');
const saved = (await presets.GET().json()).presets.find((p: { name: string }) => p.name === 'Old plan');
expect(saved.plan).toEqual(view.plan);
await postTelemetry(gameEvent('round_change'));
closeDb();
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
});
it('uses game_end for the legacy match gate without reading logs', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, getBeta, listBetaServers } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({ name: 'Legacy beta', targetRelPath: 'legacy.gsc', content: Buffer.from('main() {}') });
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [names[0]], waitForHealth: true, waitForMatch: true });
await tick(); await tick();
expect(listBetaServers(betaId)[0].stage).toBe('awaiting-match');
await appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'ExitLevel: executed\n');
await tick();
expect(getBeta(betaId)?.state).toBe('running');
await postTelemetry(gameEvent('game_end'));
await postTelemetry(gameEvent('game_start'));
await tick();
expect(getBeta(betaId)?.state).toBe('ready-promote');
});
it('stores validated presets as copies and rejects malformed plans', async () => {