Conform blueprint launches to desired fleet counts

This commit is contained in:
2026-09-18 00:28:17 -04:00
parent 989931a0f2
commit 586133c711
11 changed files with 183 additions and 44 deletions
+4 -2
View File
@@ -71,8 +71,10 @@ the sweep marks pending rows `manual` so nothing hangs forever.
## Deployment blueprints and launch previews
Deployments includes a blueprint builder for whole server collections: choose
multiple T5/T6 maps, set a lobby count for each, and launch the lineup in one action.
The collection preview shows every server ID, port, and saved-key requirement.
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet**
counts existing and queued servers and creates only the shortfall. The preview
shows target/existing/missing counts and IDs, ports, and key requirements for new
servers only. Already-satisfied targets create nothing; excess servers are kept.
All jobs and capacity reservations are committed together before provisioning;
one collection page tracks each server's independent progress and failures.
Existing single-server blueprints can be expanded with **Edit**. Single-server
+29 -8
View File
@@ -2,16 +2,31 @@
The Deployments page is the starting point for reusable server collections. A
blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
and two Town lobbies, launched together from one preview.
and two Town lobbies. Counts are desired fleet totals: existing and queued
servers count toward each target, and only missing servers are created.
For example, a blueprint targeting four Kino and two Der Riese lobbies, with
two Kino already registered, creates two Kino and two Der Riese lobbies.
The preview shows **Target / Existing or queued / To create** for each map.
Matching is by game and exact map variant; T6 Town and Farm are distinct even
though they share an engine map. Provisioning identities are counted once when
they appear in both the server registry and active creation queue.
Registered servers count even if stopped or unhealthy; conforming does not
restart or reconfigure them. Failed creation jobs without a registered server,
destroyed records, and servers awaiting removal do not count. Excess servers and
maps outside the blueprint are kept. This action fills deficits; it does not
destroy servers or change existing files to match saved recipes.
## Build and launch a collection
1. Choose **Create blueprint** on Deployments and name the lineup.
2. Add maps from Black Ops I or II and set the number of lobbies for each.
2. Add maps from Black Ops I or II and set the desired total lobbies for each.
3. Choose **Save & preview** to review every new server ID and game port.
4. Enter each lobby's Plutonium key, or leave its field blank to reuse the key
saved for that exact server ID.
5. Choose **Launch all N servers** and follow the collection progress page.
5. Choose **Create N missing servers** and follow their collection progress page.
If every target is already met, choose **Done**; no operation is created.
Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map,
and 100 servers total. The host's configured port range and existing fleet may
@@ -33,7 +48,11 @@ the preview clears typed keys so they cannot be applied to changed server IDs.
Submitting the same preview again returns the same collection, including after
a lost response or an application restart. Reusing the blueprint with a new
preview deliberately creates another collection with fresh available capacity.
preview recomputes the shortfall, including servers queued by earlier launches.
It does not add another full copy of the lineup. If the shortfall or assigned
capacity changes before submission, the request is rejected without changes and
requires a fresh preview. Previously saved additive previews are checked against
the current shortfall too.
The worker provisions servers sequentially; health verification and failures are
recorded separately for each. One failure does not undo servers already created
@@ -72,7 +91,7 @@ Each map in a blueprint retains the game, map variant, image reference, composed
when available, generated map configuration, and matching promoted script/plugin
bytes. Credentials, lobby IDs, and ports are not copied from the source server.
Use **Launch N servers** on a blueprint card to review fresh capacity. Remove a
Use **Conform fleet** on a blueprint card to review missing servers. Remove a
blueprint from the library when it is no longer needed; its existing servers,
reviewed previews, and deployment history remain intact.
@@ -133,11 +152,13 @@ require inspection rather than replaying provisioning automatically.
- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`.
- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`,
where `keys` maps exact preview server IDs to optional replacement keys.
A satisfied preview returns `{ ok: true, collectionId: null }` with HTTP 200,
without creating a collection, saving keys, or kicking the worker.
- `GET /api/deployment-collections`: list collection progress summaries.
- `GET /api/deployment-collections/:id`: collection and per-server progress.
- `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`,
or `{ deploymentId }` for a single-server recipe. Multi-server blueprints must
use the collection endpoint; they cannot silently launch only one entry.
- `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }`
for an explicitly new single-server recipe. All blueprints, including one-server
targets, must use the collection endpoint so existing servers count correctly.
Returns an ID, expiry, recipe summary, capacity, key
availability (never its value), and prerequisite checks.
- `POST /api/servers`: existing request plus optional `previewId`; the game and
+1 -1
View File
@@ -37,7 +37,7 @@ export interface CollectionPreview {
id: string;
expiresAt: string;
blueprintName: string;
entries: { count: number; recipe: LaunchRecipeSummary }[];
entries: { count: number; existing: number; missing: number; existingServers: string[]; recipe: LaunchRecipeSummary }[];
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
blockers: string[];
ready: boolean;
+37 -6
View File
@@ -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());
@@ -66,7 +66,7 @@
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="builder">
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
<p class="muted">Choose your maps and how many lobbies to launch for each. Mix Black Ops I and II in the same blueprint.</p>
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same blueprint.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else}
@@ -79,14 +79,14 @@
<span class="number">{String(index + 1).padStart(2, '0')}</span>
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
<label>Lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
</div>
{/each}
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
</div>
<p class="muted">{blueprint ? 'Changing lobby counts keeps the saved files for each map. Newly added maps use their current promoted setup.' : 'Each map saves its current promoted setup. Before launch, you’ll review every new server ID, port, and key.'}</p>
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview {total} server{total === 1 ? '' : 's'} →</Button></div>
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
</form>
{/if}
</div>
@@ -12,7 +12,7 @@
let error = $state('');
let wasOpen = false;
let session = 0;
let canLaunch = $derived(preview?.ready && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => {
if (!dialog) return;
@@ -40,7 +40,7 @@
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
open = false;
await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
if (body.collectionId) await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
@@ -52,10 +52,14 @@
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
{:else}
<div class="lineup"><strong>{preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} · one launch</strong><ul>{#each preview.entries as entry}<li><span class="quantity">{entry.count}×</span> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul></div>
<div class="section-title"><h3>Review every lobby</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
<p class="muted">Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.</p>
</div>
<div class="section-title"><h3>{preview.servers.length ? 'Review the missing lobbies' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
<p class="muted">Each server needs its own key. Saved keys are reused for the exact IDs below. All slots are checked again before anything is queued.</p>
{#if preview.servers.length}
<p class="muted">Only the missing servers below need keys. Saved keys are reused for these exact IDs. Fleet counts and slots are checked again before anything is queued.</p>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="servers">
{#each preview.servers as server, index}
@@ -66,9 +70,10 @@
{/each}
</div>
<p class="muted">The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>
<div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Launch all {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div>
<div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Create {preview.servers.length} missing server{preview.servers.length === 1 ? '' : 's'} →</Button></div>
<p class="caption">Your collection is queued in one action. Servers provision in sequence and verify independently. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>
</form>
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Review saved files for each map</summary>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if}
</div>
@@ -77,7 +82,7 @@
<style>
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: 11px; }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.lineup ul { display: flex; gap: 10px 24px; flex-wrap: wrap; margin: 16px 0 0; padding: 0; list-style: none; font-size: 13px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); margin-left: 6px; }
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: 11px; }
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
</style>
@@ -56,7 +56,7 @@
async function review() {
if (busy) return;
if (!sourceDeploymentId && selectedBlueprint && selectedBlueprint.totalServers > 1) {
if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return;
}
const current = session;
@@ -9,7 +9,7 @@ export async function POST({ request }: { request: Request }) {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
const result = queueCollection(body);
void kick().catch(err => console.error('[collection] worker kick failed:', err));
return json(result, { status: 202 });
if (result.collectionId) void kick().catch(err => console.error('[collection] worker kick failed:', err));
return json(result, { status: result.collectionId ? 202 : 200 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
@@ -5,6 +5,7 @@ export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined) throw new Error('Blueprints use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
return json(previewCreation(body));
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
+6 -6
View File
@@ -84,9 +84,9 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Deployments" subtitle="Build a lineup of maps and lobbies. Launch them together and follow every server online." />
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." />
<div class="launch-banner">
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps, set the lobby counts, and save the lineup. Preview every server and launch the whole collection together.</p></div>
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps and target lobby counts. Existing and queued servers count toward the blueprint; preview and create only the shortfall.</p></div>
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
</div>
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
@@ -96,20 +96,20 @@
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading blueprints…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose how many lobbies each needs, and launch them together from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
{:else}
<div class="blueprints">
{#each blueprints as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</span>
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Your saved lineup, ready to launch together.'}</p>
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Launch {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'} →</Button>
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
</div>
+87 -8
View File
@@ -102,6 +102,77 @@ describe('multi-server blueprints', () => {
});
describe('atomic collection launches', () => {
it('fills the target shortfall and counts registered/in-flight identities only once', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
for (let i = 0; i < 2; i++) expect((await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing-key' })).ok).toBe(true);
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const blueprint = saveBlueprint({ name: 'Friday targets', entries: [
{ game: 't5', mapSlug: 'theater', count: 4 }, { game: 't5', mapSlug: 'factory', count: 2 }
] });
const preview = previewCollection(blueprint.id);
expect(preview.entries.map(({ count, existing, missing }) => ({ count, existing, missing }))).toEqual([
{ count: 4, existing: 2, missing: 2 }, { count: 2, existing: 0, missing: 2 }
]);
expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_factory_1', 't5_zombie_factory_2']);
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
expect(getCollection(queued.collectionId!)?.collection.total).toBe(4);
expect(previewCollection(blueprint.id).servers).toEqual([]);
});
it('keeps excess and unrelated servers, including stopped servers, without requiring launch settings for a no-op', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
for (let i = 0; i < 2; i++) await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing-key' });
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE servers SET container_state = 'stopped'").run();
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const { config } = await import('../../src/lib/server/config.js');
// Fixture-only configuration changes simulate missing integrations after creation.
Object.assign(config.rcon, { password: '' });
Object.assign(config.komodo, { targetServer: '' });
const blueprint = saveBlueprint({ name: 'One Kino', entries: [{ game: 't5', mapSlug: 'theater', count: 1 }] });
const preview = previewCollection(blueprint.id);
expect(preview.entries[0]).toMatchObject({ existing: 2, missing: 0 });
expect(preview).toMatchObject({ ready: true, blockers: [], servers: [] });
expect(queueCollection({ previewId: preview.id })).toEqual({ ok: true, collectionId: null });
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
expect(db().prepare('SELECT * FROM servers').all()).toHaveLength(2);
});
it('rechecks shrinking deficits at submission and rejects an older additive preview', async () => {
const { preview, blueprint } = await previewLineup();
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { queueCollection, previewCollection } = await import('../../src/lib/server/fleet/collections.js');
const { db } = await import('../../src/lib/server/db/index.js');
queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing-key' });
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/changed/);
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1);
const fresh = previewCollection(blueprint.id);
expect(fresh.servers).toHaveLength(4);
const extra = [...fresh.servers, { ...fresh.servers[0], name: 't5_zombie_theater_20', port: 28980 }];
db().prepare('UPDATE server_collection_previews SET servers = ? WHERE id = ?').run(JSON.stringify(extra), fresh.id);
expect(() => queueCollection({ previewId: fresh.id, keys: keysFor(fresh) })).toThrow(/changed/);
});
it('matches exact T6 variants and excludes failed creations and pending destructions', async () => {
const { createServer, queueCreateServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { finishDeployment } = await import('../../src/lib/server/fleet/deployments.js');
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
const farm = await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'farm', plutoKey: 'farm-key' });
const town = await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town', plutoKey: 'town-key' });
if (!farm.ok || !town.ok) throw new Error('Fixture creation failed');
destroyServer(town.name);
const failed = queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town', plutoKey: 'failed-key' });
if (!failed.ok) throw new Error(failed.message);
finishDeployment(failed.deploymentId, { ok: false, message: 'Fixture failure' });
const blueprint = saveBlueprint({ name: 'Town target', entries: [{ game: 't6', mapSlug: 'town', count: 1 }] });
const preview = previewCollection(blueprint.id);
expect(preview.entries[0]).toMatchObject({ existing: 0, missing: 1 });
expect(preview.servers[0].name).toBe('t6_zombie_town_2');
});
it('queues all five servers, persists one group, and completes each independently', async () => {
const { preview } = await previewLineup();
const { queueCollection, getCollection, listCollections } = await import('../../src/lib/server/fleet/collections.js');
@@ -111,10 +182,10 @@ describe('atomic collection launches', () => {
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
closeDb();
await processCreations();
const detail = getCollection(queued.collectionId)!;
const detail = getCollection(queued.collectionId!)!;
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 5, failed: 0 });
expect(listCollections()).toHaveLength(1);
expect(komodoCreateDeployment).toHaveBeenCalledTimes(5);
@@ -167,11 +238,13 @@ describe('atomic collection launches', () => {
db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(ready.id);
closeDb();
expect(queueCollection({ previewId: ready.id })).toEqual(queued);
expect(getCollection(queued.collectionId)?.servers).toHaveLength(5);
expect(getCollection(queued.collectionId!)?.servers).toHaveLength(5);
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(5);
const next = previewCollection(blueprint.id);
expect(next.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_moon_2', 't6_zombie_town_3', 't6_zombie_town_4']);
expect(next.servers.map(server => server.port)).toEqual([28965, 28966, 28967, 28968, 28969]);
expect(next.servers).toEqual([]);
expect(next.entries.map(entry => entry.existing)).toEqual([2, 1, 2]);
expect(queueCollection({ previewId: next.id })).toEqual({ ok: true, collectionId: null });
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toHaveLength(1);
});
it('keeps the reviewed lineup and bytes after blueprint edits, deletion, and promotion', async () => {
@@ -189,7 +262,7 @@ describe('atomic collection launches', () => {
deleteBlueprint(blueprint.id);
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
await processCreations();
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
for (const name of ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1']) {
expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('original');
}
@@ -203,14 +276,20 @@ describe('atomic collection launches', () => {
vi.mocked(komodoCreateDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Host rejected this container' });
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
await processCreations();
const detail = getCollection(queued.collectionId)!;
const detail = getCollection(queued.collectionId!)!;
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 4, failed: 1 });
expect(detail.servers[0]).toMatchObject({ state: 'failed', error: expect.stringContaining('Host rejected') });
expect(detail.servers.slice(1).every(server => server.state === 'done')).toBe(true);
});
it('validates collection API payloads and rejects an unused expired preview', async () => {
const { preview } = await previewLineup();
const { preview, blueprint } = await previewLineup();
const { POST: singlePreview } = await import('../../src/routes/api/deployments/preview/+server.js');
const oldEndpoint = await singlePreview({ request: new Request('http://localhost/api/deployments/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId: blueprint.id })
}) });
expect(oldEndpoint.status).toBe(400);
expect((await oldEndpoint.json()).error).toContain('target counts');
const { db } = await import('../../src/lib/server/db/index.js');
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const { POST } = await import('../../src/routes/api/deployment-collections/+server.js');