Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -2,98 +2,220 @@ 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 {
|
||||
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(); });
|
||||
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, 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');
|
||||
const policy: ScalingPolicy = {
|
||||
...scalingSettings({ ...settings, game: 't5' }),
|
||||
id: 'policy',
|
||||
revision: 1,
|
||||
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();
|
||||
db()
|
||||
.prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('key-1','t6','game-key')")
|
||||
.run();
|
||||
return { db, scaling };
|
||||
}
|
||||
|
||||
it('satisfies a T6 game-wide policy with nine co-op maps and rejects Grief and Turned selections', () => {
|
||||
const now = 2_000_000;
|
||||
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, pausedReason: null, updatedAt: '' };
|
||||
const members: ScalingMember[] = maps.map(map => ({ name: `t6_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: 11 }, blockers: [] };
|
||||
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'wait', server: null, desired: 9, deficit: 0, empty: 9 });
|
||||
for (const mapSlug of ['towngrief', 'farmgrief', 'busdepotgrief', 'cellblock', 'buriedgrief', 'diner', 'buriedturned']) {
|
||||
const policy: ScalingPolicy = {
|
||||
...scalingSettings(settings),
|
||||
id: 'policy',
|
||||
revision: 1,
|
||||
pausedReason: null,
|
||||
updatedAt: ''
|
||||
};
|
||||
const members: ScalingMember[] = maps.map((map) => ({
|
||||
name: `t6_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: 11 },
|
||||
blockers: []
|
||||
};
|
||||
expect(planScaling(policy, snapshot, now)).toMatchObject({
|
||||
action: 'wait',
|
||||
server: null,
|
||||
desired: 9,
|
||||
deficit: 0,
|
||||
empty: 9
|
||||
});
|
||||
for (const mapSlug of [
|
||||
'towngrief',
|
||||
'farmgrief',
|
||||
'busdepotgrief',
|
||||
'cellblock',
|
||||
'buriedgrief',
|
||||
'diner',
|
||||
'buriedturned'
|
||||
]) {
|
||||
expect(() => scalingSettings({ ...settings, mapSlug })).toThrow('supported game and map');
|
||||
}
|
||||
});
|
||||
|
||||
it('applies targets and spare deficits independently to every map', () => {
|
||||
const now = 2_000_000;
|
||||
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, 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: [] };
|
||||
const policy: ScalingPolicy = {
|
||||
...scalingSettings(settings),
|
||||
id: 'policy',
|
||||
revision: 1,
|
||||
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_2', 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, snapshot, now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: 't6_zombie_transit_2',
|
||||
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'
|
||||
});
|
||||
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();
|
||||
db()
|
||||
.prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('other-key','t5','other-game')")
|
||||
.run();
|
||||
const evaluation = scaling.evaluateScaling(settings);
|
||||
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 1 });
|
||||
const saved = scaling.saveScaling(settings);
|
||||
expect(() => scaling.saveScaling(settings)).toThrow();
|
||||
expect(() => scaling.evaluateScaling({ ...settings, mapSlug: 'town' }, saved.id, saved.revision)).toThrow('cannot change');
|
||||
expect(() =>
|
||||
scaling.evaluateScaling({ ...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 };
|
||||
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');
|
||||
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.evaluateScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain('overlapping');
|
||||
expect(scaling.evaluateScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain(
|
||||
'overlapping'
|
||||
);
|
||||
const off = { ...settings, mode: 'off' };
|
||||
scaling.saveScaling(off, saved.id, saved.revision);
|
||||
expect(scalingOwner('t6', 'town')).toBeNull();
|
||||
const mapSettings = { ...settings, mapSlug: 'town' };
|
||||
const mapPolicy = scaling.saveScaling(mapSettings);
|
||||
const current = scaling.getScalingPolicy(saved.id)!;
|
||||
expect(scaling.evaluateScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain(mapPolicy.id);
|
||||
expect(
|
||||
scaling.evaluateScaling(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();
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO server_key_leases(server_id,key_id) VALUES('t6_zombie_town_2','key-1')"
|
||||
)
|
||||
.run();
|
||||
const evaluation = scaling.evaluateScaling(settings);
|
||||
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 0 });
|
||||
expect(evaluation.blockers).toEqual([]);
|
||||
expect(evaluation.decision).toMatchObject({ action: 'wait', reason: expect.stringContaining('keys are in use') });
|
||||
expect(evaluation.decision).toMatchObject({
|
||||
action: 'wait',
|
||||
reason: expect.stringContaining('keys are in use')
|
||||
});
|
||||
scaling.saveScaling(settings);
|
||||
expect(await scaling.runAutoscaling()).toBe(false);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 0 });
|
||||
@@ -104,27 +226,49 @@ it('moves to the next map as soon as the prior creation settles, without waiting
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { config } = await import('../../src/lib/server/config.js');
|
||||
const scaling = await import('../../src/lib/server/autoscaling/index.js');
|
||||
for (const n of [1, 2]) db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)").run(`key-${n}`, `game-key-${n}`);
|
||||
for (const n of [1, 2])
|
||||
db()
|
||||
.prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)")
|
||||
.run(`key-${n}`, `game-key-${n}`);
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
const at = new Date().toISOString();
|
||||
scaling.saveScaling(settings);
|
||||
expect(await scaling.runAutoscaling()).toBe(true);
|
||||
const job = db().prepare('SELECT deployment_id,server FROM server_creation_jobs').get() as { deployment_id: string; server: string };
|
||||
const job = db().prepare('SELECT deployment_id,server FROM server_creation_jobs').get() as {
|
||||
deployment_id: string;
|
||||
server: string;
|
||||
};
|
||||
expect(job.server).toBe('t6_zombie_transit_1');
|
||||
expect(await scaling.runAutoscaling()).toBe(false);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 });
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({
|
||||
n: 1
|
||||
});
|
||||
|
||||
// Record the first lobby becoming ready without advancing the clock.
|
||||
db().prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id=?").run(at, job.deployment_id);
|
||||
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('ready-instance',?,?,?,?,28960,?)`).run(job.server, host.id, versions.get('t6'), config.defaultRuntimeImage, at);
|
||||
db().prepare(`INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_checked_at,last_players)
|
||||
VALUES(?,'t6','zm_transit','transit','running',?,?,?,0)`).run(job.server, at, at, at);
|
||||
db()
|
||||
.prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id=?")
|
||||
.run(at, job.deployment_id);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('ready-instance',?,?,?,?,28960,?)`
|
||||
)
|
||||
.run(job.server, host.id, versions.get('t6'), config.defaultRuntimeImage, at);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_checked_at,last_players)
|
||||
VALUES(?,'t6','zm_transit','transit','running',?,?,?,0)`
|
||||
)
|
||||
.run(job.server, at, at, at);
|
||||
expect(await scaling.runAutoscaling()).toBe(true);
|
||||
expect(db().prepare('SELECT server FROM server_creation_jobs ORDER BY rowid').all()).toEqual([
|
||||
{ server: 't6_zombie_transit_1' }, { server: 't6_zombie_town_1' }
|
||||
]);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_decisions WHERE resolved=0').get()).toEqual({ n: 1 });
|
||||
} finally { vi.useRealTimers(); }
|
||||
expect(
|
||||
db().prepare('SELECT server FROM server_creation_jobs ORDER BY rowid').all()
|
||||
).toEqual([{ server: 't6_zombie_transit_1' }, { server: 't6_zombie_town_1' }]);
|
||||
expect(
|
||||
db().prepare('SELECT COUNT(*) AS n FROM autoscaling_decisions WHERE resolved=0').get()
|
||||
).toEqual({ n: 1 });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user