import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { prepareNativeHost } from '../helpers/native-host.js'; import { gameEvent } from '../helpers/telemetry.js'; import { scalingSettings, type ScalingPolicy } from '../../src/lib/autoscaling.js'; import type { GameEvent } from '../../src/lib/telemetry.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); }); const town = 't6_zombie_town_1', farm = 't6_zombie_farm_1'; const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destroy' }), id: 'policy', revision: 1, pausedReason: null, updatedAt: '' }; it('separates variants, deduplicates pending identities, and initializes additive schema', async () => { await prepareNativeHost([town, farm]); const { db } = await import('../../src/lib/server/db/index.js'); const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js'); const { ensureAutoscalingSchema } = await import('../../src/lib/server/autoscaling/schema.js'); ensureAutoscalingSchema(db()); ensureAutoscalingSchema(db()); const at = new Date().toISOString(); for (const server of [town, farm]) db() .prepare( `INSERT INTO servers(name,container_state,container_checked_at,last_seen_at,last_checked_at,last_players) VALUES(?,'running',?,?,?,0)` ) .run(server, at, at, at); expect(scalingSnapshot(policy).members.map((m) => [m.name, m.state])).toEqual([ [town, 'empty'] ]); db() .prepare( `INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('create',?,'create','queued','Awaiting placement',?,'test')` ) .run(town, at); db() .prepare( `INSERT INTO server_creation_jobs(deployment_id,server,game_mode,map_slug,expected_map) VALUES('create',?,'zombie','town','zm_transit')` ) .run(town); expect( scalingSnapshot({ ...policy, mapSlug: '*' }) .members.map((m) => m.name) .sort() ).toEqual([farm, town]); const snapshot = scalingSnapshot(policy); expect(snapshot.members).toHaveLength(1); expect(snapshot.members[0].state).toBe('creating'); expect(snapshot.members[0].round).toBeNull(); expect(snapshot.reservedNames).toEqual([town]); }); async function occupiedLobby() { vi.useFakeTimers({ toFake: ['Date'] }); await prepareNativeHost([town]); const { db } = await import('../../src/lib/server/db/index.js'); const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js'); const now = Date.now(); const iso = (offset = 0) => new Date(now + offset).toISOString(); db() .prepare( `INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_checked_at,last_players,active_since,game_started_at) VALUES(?,'t6','zm_transit','town','running',?,?,?,1,?,?)` ) .run(town, iso(), iso(), iso(), iso(-120_000), iso(-60_000)); const report = (event: GameEvent['event'], round = 2, offset = -1000, players = 1) => { const payload = { ...gameEvent(event, round, players, town, 'zm_transit'), receivedAt: iso(offset) }; db() .prepare( `INSERT OR REPLACE INTO server_telemetry(server,round,players,received_at,payload) VALUES(?,?,?,?,?)` ) .run(town, round, players, payload.receivedAt, JSON.stringify(payload)); }; return { db, now, iso, report, member: () => scalingSnapshot(policy, now).members[0] }; } it.each(['round_change', 'heartbeat'] as const)( 'exposes the current occupied round from fresh %s reports', async (event) => { const { member, report } = await occupiedLobby(); expect(member()).toMatchObject({ state: 'occupied', source: 'rcon', round: null }); report(event, 1); expect(member()).toMatchObject({ state: 'occupied', round: 1 }); report(event, 2); expect(member()).toMatchObject({ state: 'occupied', round: 2 }); } ); it('does not borrow stale or future rounds while fresh RCON still proves occupancy', async () => { const { member, report } = await occupiedLobby(); for (const offset of [-91_000, 500]) { report('heartbeat', 12, offset); expect(member()).toMatchObject({ state: 'occupied', source: 'rcon', round: null }); } }); it('clears round evidence at match boundaries and when the lobby empties', async () => { const { db, member, report } = await occupiedLobby(); report('heartbeat', 12); expect(member().round).toBe(12); for (const event of ['game_start', 'game_end'] as const) { report(event, 12); expect(member()).toMatchObject({ state: 'occupied', round: null }); } report('heartbeat', 12, -1000, 0); // A positive RCON safety veto must not turn an empty report's round into demand. expect(member()).toMatchObject({ state: 'occupied', round: null }); db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(town); expect(member()).toMatchObject({ state: 'empty', round: null }); }); it('rejects round evidence from before the current occupancy, match, or instance lifecycle', async () => { const { db, iso, member, report } = await occupiedLobby(); report('heartbeat', 12); for (const field of ['active_since', 'game_started_at', 'game_ended_at']) { db().prepare(`UPDATE servers SET ${field}=? WHERE name=?`).run(iso(), town); expect(member()).toMatchObject({ state: 'occupied', round: null }); db() .prepare(`UPDATE servers SET ${field}=? WHERE name=?`) .run(field === 'game_ended_at' ? null : iso(-120_000), town); } db() .prepare('UPDATE native_instances SET generation_reconciled_at=? WHERE server=?') .run(iso(-500), town); expect(member()).toMatchObject({ state: 'occupied', source: 'rcon', round: null }); report('heartbeat', 2, 0); expect(member().round).toBe(2); }); it('does not create schema mutations or host work when planning an empty map', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js'); const { planScaling } = await import('../../src/lib/server/autoscaling/planner.js'); db() .prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('key-8','t6','private-key')") .run(); expect(planScaling(policy, scalingSnapshot(policy), Date.now()).server).toBe( 't6_zombie_town_1' ); expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 }); });