158 lines
6.9 KiB
TypeScript
158 lines
6.9 KiB
TypeScript
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 });
|
|
});
|