Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -7,7 +7,7 @@ import { observationReader } from './status.js';
|
||||
import type { ScriptRow } from './scripts.js';
|
||||
|
||||
const games = ['t5', 't6'] as const;
|
||||
type Game = typeof games[number];
|
||||
type Game = (typeof games)[number];
|
||||
type Counts = Partial<Record<Game, number>>;
|
||||
|
||||
export function parseBetaInstanceCounts(value: unknown): Counts {
|
||||
@@ -16,7 +16,12 @@ export function parseBetaInstanceCounts(value: unknown): Counts {
|
||||
}
|
||||
const counts: Counts = {};
|
||||
for (const [game, count] of Object.entries(value)) {
|
||||
if (!games.includes(game as Game) || !Number.isSafeInteger(count) || count < 1 || count > 1000) {
|
||||
if (
|
||||
!games.includes(game as Game) ||
|
||||
!Number.isSafeInteger(count) ||
|
||||
count < 1 ||
|
||||
count > 1000
|
||||
) {
|
||||
throw new Error('Instance counts must be integers from 1 to 1000 for t5 or t6');
|
||||
}
|
||||
counts[game as Game] = count;
|
||||
@@ -25,79 +30,137 @@ export function parseBetaInstanceCounts(value: unknown): Counts {
|
||||
}
|
||||
|
||||
function matches(script: ScriptRow, server: ServerRow): boolean {
|
||||
return !script.scope_type || !script.scope_value
|
||||
|| (script.scope_type === 'game' ? server.game : server.map) === script.scope_value;
|
||||
return (
|
||||
!script.scope_type ||
|
||||
!script.scope_value ||
|
||||
(script.scope_type === 'game' ? server.game : server.map) === script.scope_value
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose at submission time using the same fresh observations shown in Fleet. */
|
||||
export function selectBetaServers(scripts: ScriptRow[], instancesPerGame: unknown): string[] {
|
||||
const counts = parseBetaInstanceCounts(instancesPerGame), observe = observationReader(), now = Date.now();
|
||||
const hosts = new Map(listHosts().map(host => [host.id, host]));
|
||||
const busy = new Set((db().prepare(`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
|
||||
const counts = parseBetaInstanceCounts(instancesPerGame),
|
||||
observe = observationReader(),
|
||||
now = Date.now();
|
||||
const hosts = new Map(listHosts().map((host) => [host.id, host]));
|
||||
const busy = new Set(
|
||||
(
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
|
||||
AND (state IN ('queued','waiting','running','awaiting-health','interrupted')
|
||||
OR (action IN ('destroy','rebuild') AND state = 'failed'
|
||||
AND stage IN ('Stopping and removing container','Removing server files and registry')))`)
|
||||
.all() as { server: string }[]).map(row => row.server));
|
||||
const candidates = listServers().flatMap(server => {
|
||||
if (!counts[server.game as Game] || server.desired_state === 'stopped' || busy.has(server.name)
|
||||
|| !scripts.some(script => matches(script, server)) || protectedWork(server.name)) return [];
|
||||
const host = server.host_id ? hosts.get(server.host_id) : null;
|
||||
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission)) return [];
|
||||
const observation = observe(server);
|
||||
if (observation.health !== 'online' || observation.last_players === null
|
||||
|| !freshStatusTimestamp(observation.last_seen_at, now)) return [];
|
||||
// An occupied RCON observation must not rank as empty just because a
|
||||
// competing telemetry report claims zero players.
|
||||
const rconPlayers = freshStatusTimestamp(server.last_seen_at, now)
|
||||
&& (!observation.telemetry_after || Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
|
||||
? server.last_players ?? 0 : 0;
|
||||
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
|
||||
}).sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
|
||||
AND stage IN ('Stopping and removing container','Removing server files and registry')))`
|
||||
)
|
||||
.all() as { server: string }[]
|
||||
).map((row) => row.server)
|
||||
);
|
||||
const candidates = listServers()
|
||||
.flatMap((server) => {
|
||||
if (
|
||||
!counts[server.game as Game] ||
|
||||
server.desired_state === 'stopped' ||
|
||||
busy.has(server.name) ||
|
||||
!scripts.some((script) => matches(script, server)) ||
|
||||
protectedWork(server.name)
|
||||
)
|
||||
return [];
|
||||
const host = server.host_id ? hosts.get(server.host_id) : null;
|
||||
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission))
|
||||
return [];
|
||||
const observation = observe(server);
|
||||
if (
|
||||
observation.health !== 'online' ||
|
||||
observation.last_players === null ||
|
||||
!freshStatusTimestamp(observation.last_seen_at, now)
|
||||
)
|
||||
return [];
|
||||
// An occupied RCON observation must not rank as empty just because a
|
||||
// competing telemetry report claims zero players.
|
||||
const rconPlayers =
|
||||
freshStatusTimestamp(server.last_seen_at, now) &&
|
||||
(!observation.telemetry_after ||
|
||||
Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
|
||||
? (server.last_players ?? 0)
|
||||
: 0;
|
||||
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
|
||||
})
|
||||
.sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
|
||||
|
||||
for (const game of games) {
|
||||
const available = candidates.filter(candidate => candidate.server.game === game).length;
|
||||
const available = candidates.filter((candidate) => candidate.server.game === game).length;
|
||||
if ((counts[game] ?? 0) > available) {
|
||||
throw new Error(`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`);
|
||||
throw new Error(
|
||||
`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const script of scripts) {
|
||||
if (!candidates.some(candidate => matches(script, candidate.server))) {
|
||||
throw new Error(`${script.name}: no available server in the requested games matches this file's scope.`);
|
||||
if (!candidates.some((candidate) => matches(script, candidate.server))) {
|
||||
throw new Error(
|
||||
`${script.name}: no available server in the requested games matches this file's scope.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Map scopes need a representative even when the least populated servers
|
||||
// all run the same map. Reserve enough remaining slots for every map.
|
||||
const requiredMaps = new Set(scripts.filter(script => script.scope_type === 'map').map(script => script.scope_value!));
|
||||
const requiredMaps = new Set(
|
||||
scripts.filter((script) => script.scope_type === 'map').map((script) => script.scope_value!)
|
||||
);
|
||||
function canComplete(remaining: typeof candidates, slots: Counts, maps: Set<string>): boolean {
|
||||
const options = new Map<string, Set<Game>>();
|
||||
for (const game of games) {
|
||||
if (remaining.filter(candidate => candidate.server.game === game).length < (slots[game] ?? 0)) return false;
|
||||
if (
|
||||
remaining.filter((candidate) => candidate.server.game === game).length <
|
||||
(slots[game] ?? 0)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
for (const map of maps) {
|
||||
const choices = new Set(remaining.filter(candidate => candidate.server.map === map && (slots[candidate.server.game as Game] ?? 0) > 0)
|
||||
.map(candidate => candidate.server.game as Game));
|
||||
const choices = new Set(
|
||||
remaining
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.server.map === map &&
|
||||
(slots[candidate.server.game as Game] ?? 0) > 0
|
||||
)
|
||||
.map((candidate) => candidate.server.game as Game)
|
||||
);
|
||||
if (!choices.size) return false;
|
||||
options.set(map, choices);
|
||||
}
|
||||
// There are two supported games: single-game maps reserve that game's
|
||||
// slots, and maps available in either game use any remaining slot.
|
||||
return games.every(game => [...options.values()].filter(choices => choices.size === 1 && choices.has(game)).length <= (slots[game] ?? 0))
|
||||
&& maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0);
|
||||
return (
|
||||
games.every(
|
||||
(game) =>
|
||||
[...options.values()].filter(
|
||||
(choices) => choices.size === 1 && choices.has(game)
|
||||
).length <= (slots[game] ?? 0)
|
||||
) && maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0)
|
||||
);
|
||||
}
|
||||
if (!canComplete(candidates, counts, requiredMaps)) {
|
||||
throw new Error('Increase the instance counts to cover every selected file\'s map scope, or choose servers in advanced options.');
|
||||
throw new Error(
|
||||
"Increase the instance counts to cover every selected file's map scope, or choose servers in advanced options."
|
||||
);
|
||||
}
|
||||
const selected: string[] = [], slots = { ...counts };
|
||||
const selected: string[] = [],
|
||||
slots = { ...counts };
|
||||
let maps = requiredMaps;
|
||||
for (let index = 0; index < candidates.length; index++) {
|
||||
const { server } = candidates[index], game = server.game as Game;
|
||||
const { server } = candidates[index],
|
||||
game = server.game as Game;
|
||||
if (!slots[game]) continue;
|
||||
const nextSlots = { ...slots, [game]: slots[game]! - 1 }, nextMaps = new Set(maps);
|
||||
const nextSlots = { ...slots, [game]: slots[game]! - 1 },
|
||||
nextMaps = new Set(maps);
|
||||
if (server.map) nextMaps.delete(server.map);
|
||||
if (!canComplete(candidates.slice(index + 1), nextSlots, nextMaps)) continue;
|
||||
selected.push(server.name); slots[game] = nextSlots[game]; maps = nextMaps;
|
||||
if (games.every(game => !slots[game])) break;
|
||||
selected.push(server.name);
|
||||
slots[game] = nextSlots[game];
|
||||
maps = nextMaps;
|
||||
if (games.every((game) => !slots[game])) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user