import { randomUUID } from 'node:crypto'; import type { CollectionDetail, CollectionMode, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js'; import { db } from '../db/index.js'; import { config, komodoConfigured, rconConfigured } from '../config.js'; import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js'; import { creationQueue, destroyServer, nextAvailablePort, nextLobbyNumber, queueCreateServer } from '../provisioning/index.js'; import { deploymentStage, finishDeployment, recordDeploymentEvent } from './deployments.js'; import { housePartyTargets } from './house-party.js'; import { getServerKey } from './server-keys.js'; import { classifyHealth } from './health.js'; import { getServer } from './servers.js'; import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js'; function coverage(entries: BlueprintEntry[]) { type Server = { name: string; game: string | null; map_slug: string | null; map: string | null }; const registered = db().prepare('SELECT name, game, map_slug, map FROM servers WHERE destroyed_at IS NULL').all() as Server[]; const queued = db().prepare(`SELECT j.server AS name, substr(j.server, 1, 2) AS game, j.map_slug, j.expected_map AS map FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')`).all() as Server[]; const removing = new Set((db().prepare("SELECT server FROM server_deployments WHERE action = 'destroy' AND state IN ('waiting', 'running')").all() as { server: string }[]).map(row => row.server)); // A provisioning server can appear in both tables; count its identity once. const servers = [...new Map([...queued, ...registered].map(server => [server.name, server])).values()]; return entries.map(entry => { const existingServers = servers.filter(server => { if (removing.has(server.name)) return false; const parsed = parseServerName(server.name); const game = server.game ?? parsed?.game; if (game !== entry.recipe.game) return false; if (server.map_slug) return server.map_slug === entry.recipe.mapSlug; const namedMap = parsed?.mapCanonical ? zombieMapBySlug(game, parsed.mapSlug) : undefined; if (namedMap && (!server.map || server.map === namedMap.canonical)) return namedMap.slug === entry.recipe.mapSlug; // Legacy canonical maps are usable only when they identify one variant. const matches = zombieMaps(game).filter(map => map.canonical === server.map); return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug; }).map(server => server.name).sort(); const coverage = existingServers.map(name => { const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action IN ('create','rebuild') AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined; const server = getServer(name); return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting' : server?.container_state && !['running', 'starting', 'restarting'].includes(server.container_state) ? 'stopped' : classifyHealth(server?.last_seen_at ?? null, server?.last_error ?? null, server?.container_state), href: operation ? `/provisioning/${encodeURIComponent(operation.id)}` : `/servers/${encodeURIComponent(name)}` }; }); return { existingServers, coverage, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) }; }); } function fleetSnapshot() { return housePartyTargets().map(name => ({ name, server: db().prepare('SELECT game, map_slug, map, game_port FROM servers WHERE name = ?').get(name) ?? null, creation: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action IN ('create','rebuild') ORDER BY rowid DESC LIMIT 1").get(name) ?? null, removal: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) ?? null })); } function collectionPlan(entries: BlueprintEntry[], mode: CollectionMode) { const counts = coverage(entries); // Preserve deployed lobbies before queued ones, then the lowest lobby IDs. const retainedServers = counts.flatMap((entry, index) => [...entry.existingServers].sort((a, b) => Number(!!getServer(b)) - Number(!!getServer(a)) || a.localeCompare(b, 'en', { numeric: true }) ).slice(0, mode === 'exact' ? entries[index].count : undefined)); const retained = new Set(retainedServers); const matching = new Set(counts.flatMap(entry => entry.existingServers)); const removals = mode === 'exact' ? housePartyTargets().filter(name => !retained.has(name)).map(name => ({ name, reason: matching.has(name) ? 'Exceeds the target lobby count' : 'Outside this collection or already awaiting removal' })) : []; return { counts, retainedServers, removals }; } function allocate(entries: BlueprintEntry[], releasingServers: string[] = []): CollectionPreview['servers'] { const names: string[] = []; const ports: number[] = []; const counts = coverage(entries); return entries.flatMap((entry, entryIndex) => Array.from({ length: counts[entryIndex].missing }, () => { const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`; const lobby = nextLobbyNumber(base, names); const name = lobby === null ? null : `${base}_${lobby}`; const port = nextAvailablePort(ports, releasingServers); if (name) names.push(name); if (port !== null) ports.push(port); return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) }; })); } function blockersFor(servers: CollectionPreview['servers']): string[] { const blockers: string[] = []; if (!servers.length) return blockers; if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.'); if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.'); if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.'); if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`); return blockers; } export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview { if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a collection to launch.'); if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact collection mode.'); return db().transaction((): CollectionPreview => { const { entries, blueprintName } = blueprintEntries(blueprintId); const { counts, retainedServers, removals } = collectionPlan(entries, mode); const servers = allocate(entries, removals.map(server => server.name)); const blockers = blockersFor(servers); if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.'); const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString()); db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers, mode, fleet_snapshot) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers), mode, mode === 'exact' ? JSON.stringify(fleetSnapshot()) : null); return { id, expiresAt, blueprintName, mode, retainedServers, removals, launchConcurrency: config.createConcurrency, servers, blockers, ready: blockers.length === 0, entries: entries.map((entry, index) => ({ count: entry.count, ...counts[index], recipe: summarizeRecipe(entry.recipe) })) }; })(); } /** Reserve and enqueue the entire collection atomically. No external operations here. */ export function queueCollection(input: { previewId?: unknown; keys?: unknown }): { ok: true; collectionId: string | null } { if (typeof input.previewId !== 'string' || !input.previewId) throw new Error('Review a collection preview before launching.'); if (input.keys !== undefined && (!input.keys || typeof input.keys !== 'object' || Array.isArray(input.keys))) throw new Error('Provide keys indexed by the exact server ID.'); const keys = (input.keys ?? {}) as Record; return db().transaction(() => { const preview = db().prepare('SELECT * FROM server_collection_previews WHERE id = ?').get(input.previewId) as { id: string; expires_at: string; blueprint_name: string; entries: string; servers: string; collection_id: string | null; mode: CollectionMode; fleet_snapshot: string | null; } | undefined; if (!preview) throw new Error('Collection preview expired or was not found. Refresh the preview.'); if (preview.collection_id) return { ok: true as const, collectionId: preview.collection_id }; if (preview.expires_at <= new Date().toISOString()) throw new Error('Collection preview expired. Refresh it before launching.'); const entries: BlueprintEntry[] = JSON.parse(preview.entries); const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers); if (preview.mode === 'exact' && preview.fleet_snapshot !== JSON.stringify(fleetSnapshot())) { throw new Error('The fleet changed. Nothing was queued. Refresh the preview and review the servers to keep and remove.'); } const { retainedServers, removals } = collectionPlan(entries, preview.mode); const releasingServers = removals.map(server => server.name); const servers = allocate(entries, releasingServers); const blockers = blockersFor(servers); if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.'); if (blockers.length) throw new Error(blockers.join(' ')); if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) { throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.'); } const expected = new Set(servers.map(server => server.name)); for (const [name, value] of Object.entries(keys)) { if (!expected.has(name) || typeof value !== 'string') throw new Error('Keys must be strings for server IDs shown in this preview.'); } if (!servers.length && !removals.length) return { ok: true as const, collectionId: null }; const collectionId = randomUUID(); db().prepare('INSERT INTO server_launch_collections (id, name, created_at, mode, retained_servers) VALUES (?, ?, ?, ?, ?)') .run(collectionId, preview.blueprint_name, new Date().toISOString(), preview.mode, JSON.stringify(retainedServers)); const removalJobs = removals.map((server, position) => { const ports = (db().prepare(`SELECT game_port AS port FROM servers WHERE name = ? UNION SELECT j.port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE j.server = ? AND d.state IN ('queued', 'running', 'awaiting-health')`).all(server.name, server.name) as { port: number | null }[]).map(row => row.port); const queued = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'create' AND state = 'queued'").get(server.name) as { id: string } | undefined; let id: string; if (queued && !getServer(server.name)) { // No container or files exist yet: cancel the reservation instead of booting just to remove it. const result = destroyServer(server.name, 'blueprint-exact'); if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name); id = result.deploymentId; db().prepare("UPDATE server_deployments SET state = 'cancelled', stage = 'Cancelled by exact collection', finished_at = ? WHERE id = ?").run(new Date().toISOString(), queued.id); recordDeploymentEvent(queued.id, 'Queued creation cancelled by exact collection; saved key retained.'); deploymentStage(id, 'Cancelled queued creation; no server was started'); finishDeployment(id, { ok: true }); } else { const result = destroyServer(server.name, 'blueprint-exact'); if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name); id = result.deploymentId; } db().prepare('INSERT INTO server_collection_removals (collection_id, deployment_id, position) VALUES (?, ?, ?)').run(collectionId, id, position); return { id, ports }; }); servers.forEach((server, index) => { const recipe = entries[server.entryIndex].recipe; const childPreviewId = randomUUID(); db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)') .run(childPreviewId, preview.expires_at, server.name, server.port, JSON.stringify(recipe), preview.blueprint_name); const result = queueCreateServer({ game: recipe.game, gameMode: 'zombie', mapSlug: recipe.mapSlug, expectedName: server.name!, previewId: childPreviewId, plutoKey: keys[server.name!] as string | undefined, createdBy: 'ui' }, releasingServers); // Throw so the outer transaction also reverses all earlier keys and children. if (!result.ok) throw new Error(`${server.name}: ${result.message} Nothing was queued.`); db().prepare('INSERT INTO server_collection_members (collection_id, deployment_id, position) VALUES (?, ?, ?)') .run(collectionId, result.deploymentId, index); for (const removal of removalJobs.filter(removal => removal.ports.includes(server.port))) { db().prepare('INSERT INTO server_creation_dependencies (deployment_id, removal_id) VALUES (?, ?)').run(result.deploymentId, removal.id); } }); db().prepare('UPDATE server_collection_previews SET collection_id = ? WHERE id = ?').run(collectionId, preview.id); return { ok: true as const, collectionId }; })(); } const membershipSql = `SELECT collection_id, deployment_id, position FROM server_collection_members UNION ALL SELECT collection_id, deployment_id, position FROM server_collection_removals`; const summarySql = `SELECT c.id, c.name, c.created_at, c.mode, COUNT(d.id) AS total, COALESCE(SUM(d.state IN ('done', 'warning')), 0) AS done, COALESCE(SUM(d.state IN ('failed', 'interrupted', 'cancelled')), 0) AS failed, COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active, COALESCE(SUM(d.state IN ('queued', 'waiting')), 0) AS queued, COALESCE(SUM(d.state = 'running'), 0) AS provisioning, COALESCE(SUM(d.state = 'awaiting-health'), 0) AS verifying FROM server_launch_collections c LEFT JOIN (${membershipSql}) m ON m.collection_id = c.id LEFT JOIN server_deployments d ON d.id = m.deployment_id`; export function listCollections(): CollectionSummary[] { return db().prepare(summarySql + ' GROUP BY c.id ORDER BY c.created_at DESC, c.rowid DESC').all() as CollectionSummary[]; } export function getCollection(id: string): CollectionDetail | null { const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined; if (!collection) return null; const servers = db().prepare(`SELECT d.id, d.server, d.action, d.state, d.stage, d.started_at, d.finished_at, d.error, j.port, j.map_slug, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists FROM (${membershipSql}) m JOIN server_deployments d ON d.id = m.deployment_id LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE m.collection_id = ? ORDER BY d.action DESC, m.position`).all(id) as CollectionDetail['servers']; const queue = creationQueue(); const positions = new Map(queue.queued.map((job, index) => [job.id, index + 1])); const stored = db().prepare('SELECT retained_servers FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string }; return { collection, retainedServers: JSON.parse(stored.retained_servers), queue: { concurrency: config.createConcurrency, active: queue.active, queued: queue.queued.length }, servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) }; }