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>; 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): boolean { const options = new Map>(); 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; }