import { db } from '../db/index.js'; import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js'; import type { GameMode, ZombieGame } from '../fleet/map-aliases.js'; import { nativeInstance } from '../backends/native.js'; import { beginDeployment, deploymentStage, resumeCleanup } from './history.js'; import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js'; export type CreateServerFailure = | 'bad-request' | 'conflict' | 'unconfigured' // native host configuration missing | 'unknown-map' // slug isn't in the catalog | 'no-slot' // all lobby slots for this map are taken | 'no-port' // port range exhausted | 'fs' // couldn't create host dir | 'db'; // shouldn't happen, but wraps INSERT failures export type CreateServerResult = | { ok: true; name: string; port: number | null; deploymentId: string } | { ok: false; kind: CreateServerFailure; message: string }; export type DestroyServerResult = | { ok: true; warnings: string[] } | { ok: false; kind: 'not-found' | 'conflict' | 'fs' | 'db'; message: string; warnings: string[] }; export interface CreateServerInput { hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number; game?: ZombieGame; // omitted by older clients: T5 gameMode: GameMode; // 'zombie' — only value supported today mapSlug: string; // 'cosmodrome' plutoKey?: string; // saved for this server ID when supplied expectedName?: string; // optional preview guard; never selects a lobby previewId?: string; createdBy?: string; // 'ui' by default } export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null { const rows = db() .prepare(`SELECT name FROM servers WHERE name LIKE ? UNION SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.state = 'cancelled' AND d.submitted_at IS NOT NULL) OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`) .all(`${baseName}_%`) as Array<{ name: string }>; const taken = new Set(); const prefix = `${baseName}_`; for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) { if (!r.name.startsWith(prefix)) continue; const tail = r.name.slice(prefix.length); if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10)); } for (let n = 1; n <= 20; n++) { if (!taken.has(n)) return n; } return null; } export function previewCreation(input: Parameters[0]) { return previewNativeCreation(input); } export function queueCreateServer(input: CreateServerInput): CreateServerResult { return queueNativeCreation(input); } export const queueRebuildServer = (name: string, triggeredBy = 'ui') => queueNativeRebuild(name, triggeredBy); // Persist the request before returning so navigation and client disconnects cannot lose it. function hasActiveCreation(name: string): boolean { return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name); } export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } { const cancelled = cancelQueuedNativeCreation(name, triggeredBy); if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled }; const blocker = activeBetaRemovalBlocker(name); if (blocker) return { ok: false, kind: 'conflict', message: blocker, warnings: [] }; if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild') AND state IN ('waiting','queued','running')`).get(name)) { return { ok: false, kind: 'conflict', message: 'Wait for the current provisioning operation before destroying this server.', warnings: [] }; } const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined; if (existing) return { ok: true, warnings: [], deploymentId: existing.id }; const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined; if (previous && resumeCleanup(previous.id)) return { ok: true, warnings: [], deploymentId: previous.id }; if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] }; if (!nativeInstance(name)) return { ok: false, kind: 'conflict', message: 'Server has no native host instance.', warnings: [] }; const operation = db().transaction(() => { const id = beginDeployment(name, 'destroy', triggeredBy); db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id); deploymentStage(id, 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null, blocker: { code: 'eligibility', message: 'Waiting for safe removal eligibility checks.', automatic: true } }); return id; })(); return { ok: true, warnings: [], deploymentId: operation }; } /** One host-wide FIFO, shared by collections and individual launches. */ export function creationQueue() { type Entry = { id: string; server: string; state: string; stage: string }; const jobs = db().prepare(`SELECT id, server, state, stage FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state IN ('queued', 'running', 'awaiting-health') ORDER BY started_at, rowid`).all() as Entry[]; return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') }; }