Conform blueprint launches to desired fleet counts
This commit is contained in:
@@ -5,11 +5,39 @@ import { config, configFileForGame, komodoConfigured, rconConfigured } from '../
|
||||
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
||||
import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
||||
import { getServerKey } from './server-keys.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();
|
||||
return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
});
|
||||
}
|
||||
|
||||
function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
|
||||
const names: string[] = [];
|
||||
const ports: number[] = [];
|
||||
return entries.flatMap((entry, entryIndex) => Array.from({ length: entry.count }, () => {
|
||||
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}`;
|
||||
@@ -22,11 +50,12 @@ function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
|
||||
|
||||
function blockersFor(entries: BlueprintEntry[], 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.`);
|
||||
if (entries.some(entry => entry.recipe.configFile !== configFileForGame(entry.recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.');
|
||||
if (servers.some(server => entries[server.entryIndex].recipe.configFile !== configFileForGame(entries[server.entryIndex].recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.');
|
||||
return blockers;
|
||||
}
|
||||
|
||||
@@ -35,6 +64,7 @@ export function previewCollection(blueprintId: unknown): CollectionPreview {
|
||||
return db().transaction(() => {
|
||||
const { entries, blueprintName } = blueprintEntries(blueprintId);
|
||||
const servers = allocate(entries);
|
||||
const counts = coverage(entries);
|
||||
const blockers = blockersFor(entries, servers);
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
@@ -42,12 +72,12 @@ export function previewCollection(blueprintId: unknown): CollectionPreview {
|
||||
db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers));
|
||||
return { id, expiresAt, blueprintName, servers, blockers, ready: blockers.length === 0,
|
||||
entries: entries.map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })) };
|
||||
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 } {
|
||||
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<string, unknown>;
|
||||
@@ -63,13 +93,14 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
|
||||
const servers = allocate(entries);
|
||||
const blockers = blockersFor(entries, servers);
|
||||
if (blockers.length) throw new Error(blockers.join(' '));
|
||||
if (servers.some((server, i) => server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
|
||||
throw new Error('Lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
|
||||
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) return { ok: true as const, collectionId: null };
|
||||
const collectionId = randomUUID();
|
||||
db().prepare('INSERT INTO server_launch_collections (id, name, created_at) VALUES (?, ?, ?)')
|
||||
.run(collectionId, preview.blueprint_name, new Date().toISOString());
|
||||
|
||||
Reference in New Issue
Block a user