Support game-wide autoscaling rules with per-map targets
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { zombieMaps } from '../../src/lib/map-aliases.js';
|
||||
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0) }));
|
||||
@@ -9,24 +10,30 @@ afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); vi.restoreA
|
||||
const names = ['t6_zombie_town_1', 't6_zombie_town_2'];
|
||||
const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'create-destroy', emptyMinutes: 1, cooldownMinutes: 1 };
|
||||
|
||||
async function setup() {
|
||||
const host = await prepareNativeHost(names);
|
||||
async function setup(gameWide = false) {
|
||||
const servers = gameWide ? [...names, ...zombieMaps('t6').filter(m => m.slug !== 'town').map(m => `t6_zombie_${m.slug}_1`)] : names;
|
||||
const policySettings = { ...settings, mapSlug: gameWide ? '*' : settings.mapSlug };
|
||||
const host = await prepareNativeHost(servers);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const scaling = await import('../../src/lib/server/autoscaling/index.js');
|
||||
const authorization = await import('../../src/lib/server/autoscaling/authorization.js');
|
||||
db().prepare("UPDATE ec_hosts SET inventory=json_set(inventory,'$.capabilities',json(?)) WHERE id=?").run(JSON.stringify([authorization.autoscalingRemovalCapability]), host.host.id);
|
||||
const base = Date.now(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(base);
|
||||
for (const name of names) {
|
||||
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,last_players) VALUES(?,'t6','zm_transit','town','running',0)").run(name);
|
||||
for (const name of servers) {
|
||||
const map = zombieMaps('t6').find(m => name === `t6_zombie_${m.slug}_1` || name === `t6_zombie_${m.slug}_2`)!;
|
||||
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,last_players) VALUES(?,'t6',?,?,'running',0)").run(name, map.canonical, map.slug);
|
||||
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(name, `key-${name}`);
|
||||
}
|
||||
if (gameWide) for (const map of zombieMaps('t6').filter(m => m.slug !== 'town')) {
|
||||
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_${map.slug}_2`, `key-${map.slug}-2`);
|
||||
}
|
||||
const fresh = (offset: number) => {
|
||||
vi.setSystemTime(base + offset); const at = new Date().toISOString();
|
||||
db().prepare('UPDATE servers SET container_checked_at=?,last_seen_at=?,last_checked_at=?').run(at, at, at);
|
||||
db().prepare('UPDATE ec_hosts SET last_heartbeat=?').run(at);
|
||||
};
|
||||
fresh(0);
|
||||
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
|
||||
const saved = scaling.saveScaling(policySettings, undefined, undefined, scaling.previewScaling(policySettings).token);
|
||||
for (const offset of [0, 30_000, 60_000]) { fresh(offset); await scaling.runAutoscaling(); }
|
||||
const intent = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
|
||||
expect(intent?.operation_id).toBeTruthy();
|
||||
@@ -103,3 +110,10 @@ it('rejects expired, changed revision, and newly protected removal authorization
|
||||
s.fresh(130_000);
|
||||
expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(false);
|
||||
});
|
||||
|
||||
it('authorizes a game-wide removal without trimming any map below its targets', async () => {
|
||||
const s = await setup(true);
|
||||
expect(s.db().prepare("SELECT server FROM server_deployments WHERE id=?").get(s.operationId)).toEqual({ server: names[0] });
|
||||
const command = await s.dispatch();
|
||||
expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(true);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ it('separates variants, deduplicates pending identities, and initializes additiv
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user