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-only' }; const maps = zombieMaps('t6'); 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'); for (const map of maps) for (const n of [1, 2]) db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_${map.slug}_${n}`, `key-${map.slug}-${n}`); 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), keyedNames: members.map(m => m.name), 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(server_id,pluto_key) VALUES(?,?)').run('t5_zombie_theater_1', 'other-game'); const preview = scaling.previewScaling(settings); expect(preview.snapshot.keyedNames).toHaveLength(maps.length * 2); 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('requires live key coverage on each map even when the game has enough keys in total', async () => { const { db, scaling } = await setup(); db().prepare("DELETE FROM server_keys WHERE server_id='t6_zombie_town_2'").run(); const preview = scaling.previewScaling(settings); expect(preview.blockers.join(' ')).toContain('Town Survival'); expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('Town Survival'); });