Files
electric-cherry/tests/unit/autoscaling-game.test.ts
T

90 lines
6.4 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 { zombieMaps } from '../../src/lib/map-aliases.js';
import { scalingSettings, type ScalingMember, type ScalingPolicy } from '../../src/lib/autoscaling.js';
import { planScaling } from '../../src/lib/server/autoscaling/planner.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const settings = { game: 't6', mapSlug: '*', maximum: 2, mode: 'create-destroy' };
const maps = zombieMaps('t6');
it('satisfies a T5 game-wide policy with the ten standard Zombies maps', () => {
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings({ ...settings, game: 't5' }),
id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const members: ScalingMember[] = zombieMaps('t5').map(map => ({ name: `t5_zombie_${map.slug}_1`, mapSlug: map.slug,
identity: map.slug, state: 'empty', players: 0, observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null }));
const snapshot = { members, reservedNames: members.map(m => m.name), keyPool: { total: 20, available: 10 }, blockers: [] };
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'wait', server: null, desired: 10, deficit: 0, empty: 10 });
expect(() => scalingSettings({ ...settings, game: 't5', mapSlug: 'zombietron' })).toThrow('supported game and map');
});
async function setup() {
await prepareNativeHost();
const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('key-1','t6','game-key')").run();
return { db, scaling };
}
it('applies targets and spare deficits independently to every map', () => {
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const members: ScalingMember[] = maps.flatMap(map => [1, 2].map(n => ({ name: `t6_zombie_${map.slug}_${n}`, mapSlug: map.slug,
identity: `${map.slug}:${n}`, state: map.slug === 'town' ? 'occupied' : 'empty', players: map.slug === 'town' ? 1 : 0,
observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null })));
const snapshot = { members, reservedNames: members.map(m => m.name), keyPool: { total: 15, available: 1 }, blockers: [] };
// Other maps' empty lobbies cannot satisfy Town's spare target or maximum.
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'destroy', server: 't6_zombie_transit_1', desired: maps.length + 1, deficit: 1 });
snapshot.members = members.filter(m => m.name !== 't6_zombie_town_2');
snapshot.reservedNames = snapshot.members.map(m => m.name);
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'create', server: 't6_zombie_town_2' });
expect(planScaling({ ...policy, nextActionAt: now + 1 }, snapshot, now).action).toBe('wait');
snapshot.members[0] = { ...snapshot.members[0], state: 'creating' };
expect(planScaling(policy, snapshot, now).action).toBe('wait');
});
it('persists a game rule, queues the selected map recipe, and protects every map', async () => {
const { db, scaling } = await setup();
db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('other-key','t5','other-game')").run();
const preview = scaling.previewScaling(settings);
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 });
const saved = scaling.saveScaling(settings, undefined, undefined, preview.token);
expect(() => scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token)).toThrow();
expect(() => scaling.previewScaling({ ...settings, mapSlug: 'town' }, saved.id, saved.revision)).toThrow('cannot change');
expect(await scaling.runAutoscaling()).toBe(true);
const job = db().prepare('SELECT deployment_id,map_slug,server FROM server_creation_jobs').get() as { deployment_id: string; map_slug: string; server: string };
expect(job).toMatchObject({ map_slug: 'transit', server: 't6_zombie_transit_1' });
const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js');
expect(scalingAdmission(job.deployment_id)).toBeNull();
await scaling.runAutoscaling();
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 });
const { scalingOwner, scalingOwnershipBlocker } = await import('../../src/lib/server/autoscaling/ownership.js');
for (const map of maps) expect(scalingOwner('t6', map.slug)).toBe(saved.id);
expect(scalingOwner('t5', 'theater')).toBeNull();
expect(scalingOwnershipBlocker('t6_zombie_farm_1')).toContain(saved.id);
expect(scaling.previewScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain('overlapping');
const off = { ...settings, mode: 'off' };
scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
expect(scalingOwner('t6', 'town')).toBeNull();
const mapSettings = { ...settings, mapSlug: 'town' };
const mapPolicy = scaling.saveScaling(mapSettings, undefined, undefined, scaling.previewScaling(mapSettings).token);
const current = scaling.getScalingPolicy(saved.id)!;
expect(scaling.previewScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain(mapPolicy.id);
});
it('holds game-wide growth when its shared pool is exhausted by another map', async () => {
const { db, scaling } = await setup();
db().prepare("INSERT INTO server_key_leases(server_id,key_id) VALUES('t6_zombie_town_2','key-1')").run();
const preview = scaling.previewScaling(settings);
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 0 });
expect(preview.blockers).toEqual([]);
expect(preview.decision).toMatchObject({ action: 'wait', reason: expect.stringContaining('keys are in use') });
scaling.saveScaling(settings, undefined, undefined, preview.token);
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 0 });
});