167 lines
7.0 KiB
TypeScript
167 lines
7.0 KiB
TypeScript
import { db } from '../db/index.js';
|
|
import { listHosts } from '../hosts/index.js';
|
|
import { protectedWork } from '../autoscaling/snapshot.js';
|
|
import { freshStatusTimestamp } from '../../server-freshness.js';
|
|
import { listServers, type ServerRow } from './servers.js';
|
|
import { observationReader } from './status.js';
|
|
import type { ScriptRow } from './scripts.js';
|
|
|
|
const games = ['t5', 't6'] as const;
|
|
type Game = (typeof games)[number];
|
|
type Counts = Partial<Record<Game, number>>;
|
|
|
|
export function parseBetaInstanceCounts(value: unknown): Counts {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length) {
|
|
throw new Error('instancesPerGame must contain at least one game and instance count');
|
|
}
|
|
const counts: Counts = {};
|
|
for (const [game, count] of Object.entries(value)) {
|
|
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;
|
|
}
|
|
return 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
|
|
);
|
|
}
|
|
|
|
/** 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
|
|
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));
|
|
|
|
for (const game of games) {
|
|
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.`
|
|
);
|
|
}
|
|
}
|
|
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.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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!)
|
|
);
|
|
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;
|
|
}
|
|
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)
|
|
);
|
|
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)
|
|
);
|
|
}
|
|
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."
|
|
);
|
|
}
|
|
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;
|
|
if (!slots[game]) continue;
|
|
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;
|
|
}
|
|
return selected;
|
|
}
|