Use current promoted files for blueprint launches

This commit is contained in:
2026-09-19 19:58:30 -04:00
parent 41891b4209
commit 345839fbd4
15 changed files with 236 additions and 103 deletions
+6 -1
View File
@@ -116,10 +116,15 @@ All jobs and capacity reservations are committed together before provisioning;
one collection page tracks each server's independent progress and failures. one collection page tracks each server's independent progress and failures.
Existing single-server blueprints can be expanded with **Edit**. Single-server Existing single-server blueprints can be expanded with **Edit**. Single-server
launches and launching again from recorded recipes remain available. launches and launching again from recorded recipes remain available.
Blueprints save maps and target lobby counts. Every new server uses the latest
promoted scripts, plugins, and configuration when provisioning begins, including
promotions made after preview or while queued. Existing blueprints automatically
follow this policy. Deployment history records the files selected for that launch;
**Launch again** uses the current promoted setup for the same game and map.
The progress page explains waiting conditions, shows elapsed time, and can resume The progress page explains waiting conditions, shows elapsed time, and can resume
health verification of an interrupted creation when its original server remains. health verification of an interrupted creation when its original server remains.
See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow, See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow,
reproducibility boundaries, and API details. promotion policy, and API details.
## Saved server keys ## Saved server keys
+42 -33
View File
@@ -4,6 +4,9 @@ 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, blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
and two Town lobbies. Counts are desired fleet totals: existing and queued and two Town lobbies. Counts are desired fleet totals: existing and queued
servers count toward each target, and only missing servers are created. servers count toward each target, and only missing servers are created.
Blueprints save game/map selections and lobby counts. Every new server uses the
latest promoted scripts, plugins, baseline, and matching configuration overlays
when provisioning begins.
For example, a blueprint targeting four Kino and two Der Riese lobbies, with 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. two Kino already registered, creates two Kino and two Der Riese lobbies.
@@ -16,7 +19,7 @@ Registered servers count even if stopped or unhealthy; conforming does not
restart or reconfigure them. Failed creation jobs without a registered server, restart or reconfigure them. Failed creation jobs without a registered server,
destroyed records, and servers awaiting removal do not count. Excess servers and 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 maps outside the blueprint are kept. This action fills deficits; it does not
destroy servers or change existing files to match saved recipes. destroy servers or change existing servers' files.
## Build and launch a collection ## Build and launch a collection
@@ -31,14 +34,15 @@ destroy servers or change existing files to match saved recipes.
Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map, 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 and 100 servers total. The host's configured port range and existing fleet may
allow fewer. A blueprint can be saved even when there is insufficient capacity allow fewer. A blueprint can be saved even when there is insufficient capacity
to launch it. Each map captures its promoted managed files when added. to launch it. Saving a blueprint does not capture or pin managed file versions.
Use **Edit** on a blueprint card to change its name, description, maps, or lobby Use **Edit** on a blueprint card to change its name, description, maps, or lobby
counts. Existing map recipes are retained when their counts change; newly added counts. Already reviewed launches keep their original lineup, including after
maps capture their current promoted setup. Already reviewed launches keep their blueprint edits or removal; their managed files still follow the latest promotions.
original lineup and file contents, including after blueprint edits or removal. Previously saved blueprints automatically use this policy without being resaved.
Blueprints saved by the earlier single-server release load as one-server lineups Blueprints saved by the earlier single-server release load as one-server lineups
and can be expanded directly. and can be expanded directly. Any file snapshots in older blueprints are ignored
when preparing new launches.
A collection preview does not reserve capacity. At submission, all lobby IDs, A collection preview does not reserve capacity. At submission, all lobby IDs,
ports, and keys are validated again. The entire collection is then queued in one ports, and keys are validated again. The entire collection is then queued in one
@@ -84,43 +88,48 @@ verify the engine map, not the variant's start location.
## Save and reuse a blueprint ## Save and reuse a blueprint
Expand **Make this a reusable blueprint** in a launch preview and give it a name Expand **Make this a reusable blueprint** in a launch preview and give it a name
and optional description. You can save a recipe even when host configuration or and optional description. You can save the map even when host configuration or
available capacity blocks launching. Names are unique without regard to case. available capacity blocks launching. Names are unique without regard to case.
Each map in a blueprint retains the game, map variant, image reference, composed configuration Each map in a blueprint retains the game, exact map variant, and desired lobby
when available, generated map configuration, and matching promoted script/plugin count. Image references, managed files, and composed configuration are resolved
bytes. Credentials, lobby IDs, and ports are not copied from the source server. from the current setup for each new launch. Credentials, lobby IDs, and ports are
not copied from the source server.
Use **Conform fleet** on a blueprint card to review missing servers. 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, blueprint from the library when it is no longer needed; its existing servers,
reviewed previews, and deployment history remain intact. reviewed previews, and deployment history remain intact.
On a creation detail page, **Launch again** opens its original recipe with the On a creation detail page, **Launch again** selects the original game and map
next available lobby and port. This reproduces the original managed launch files, with the next available lobby and port, using the latest promoted setup. The
not the server's later live state. The preview also lets you save that historical preview also lets you save that map as a blueprint with a target of one lobby.
recipe as a blueprint. Older deployments without a recipe retain their history The recorded files remain visible as deployment history. Older deployments
but cannot offer this action. without a recorded recipe retain their history but cannot offer this action.
## What is frozen ## Promotions and deployment history
The exact managed file bytes, file hashes, version metadata, composed config, A preview shows the currently promoted managed files, configuration, and image
selected map, and image reference are stored in SQLite. Promotion or deletion of reference for the selected map. It does not pin those versions. New launches
an artifact after preview cannot change those bytes. A submitted preview is bound resolve the promoted setup again when submitted and when provisioning begins, so
to one deployment: retrying the same submission returns the original operation, promotions made after a preview or while a job is queued apply to that server.
including after an application restart or a lost HTTP response. This applies to blueprint collections, single-server launches, and **Launch again**.
Existing API clients that omit a preview still work. Their recipes are captured The files selected at provisioning, their hashes and version metadata, composed
when queued, before provisioning begins. Queued jobs from before this update use configuration, selected map, and image reference are recorded in SQLite for
the promoted setup at provisioning time because they have no recorded recipe. deployment history. These records do not override promotions on future launches.
Existing API clients that omit a preview and jobs queued before this update
also use the promoted setup at provisioning time.
This is a launch snapshot, not a permanent version lock. Subsequent campaigns, A submitted preview is bound to one deployment: retrying the same submission
betas, and drift reconciliation continue to follow the server's game/map scopes. returns the original operation, including after an application restart or a lost
HTTP response. The reviewed game/map, lobby ID, port, and key checks are preserved.
Already running servers continue to receive updates through campaigns, betas,
and drift reconciliation using the server's game/map scopes.
Container tags can resolve to newer images; use a digest for a fixed image. Container tags can resolve to newer images; use a digest for a fixed image.
Without a promoted baseline, Plutainer seeds its own defaults on first boot, Without a promoted baseline, Plutainer seeds its own defaults on first boot,
which are not captured. Host, network, game-file volumes, destination directories, which are not captured. Host, network, game-file volumes, destination directories,
and RCON/Plutonium credentials use the environment and key store at launch time. and RCON/Plutonium credentials use the environment and key store at launch time.
A changed main config filename blocks reuse rather than silently loading the The current main config filename and launch settings apply to new deployments.
wrong configuration.
Recipes do not include the separately stored Plutonium or integration credentials. Recipes do not include the separately stored Plutonium or integration credentials.
Managed files can themselves contain sensitive configuration, so protect database Managed files can themselves contain sensitive configuration, so protect database
@@ -142,12 +151,12 @@ require inspection rather than replaying provisioning automatically.
## API ## API
- `GET /api/blueprints`: list summaries. - `GET /api/blueprints`: list lineup summaries with game/map selections and counts.
- `POST /api/blueprints`: save `{ name, description?, previewId }` or - `POST /api/blueprints`: save `{ name, description?, previewId }` or
`{ name, description?, deploymentId }` for a single-server recipe, or `{ name, description?, deploymentId }` for a single-map target of one lobby, or
`{ name, description?, entries: [{ game, mapSlug, count }] }` for a collection. `{ name, description?, entries: [{ game, mapSlug, count }] }` for a collection.
- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. Unchanged - `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. All entries
game/map selections keep their saved recipes. use the latest promoted setup when launched.
- `DELETE /api/blueprints/:id`: remove a library entry. - `DELETE /api/blueprints/:id`: remove a library entry.
- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`. - `POST /api/deployment-collections/preview`: preview `{ blueprintId }`.
- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`, - `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`,
@@ -157,7 +166,7 @@ require inspection rather than replaying provisioning automatically.
- `GET /api/deployment-collections`: list collection progress summaries. - `GET /api/deployment-collections`: list collection progress summaries.
- `GET /api/deployment-collections/:id`: collection and per-server progress. - `GET /api/deployment-collections/:id`: collection and per-server progress.
- `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }` - `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }`
for an explicitly new single-server recipe. All blueprints, including one-server for a new single server using the latest promoted setup. All blueprints, including one-server
targets, must use the collection endpoint so existing servers count correctly. targets, must use the collection endpoint so existing servers count correctly.
Returns an ID, expiry, recipe summary, capacity, key Returns an ID, expiry, recipe summary, capacity, key
availability (never its value), and prerequisite checks. availability (never its value), and prerequisite checks.
+1 -2
View File
@@ -28,8 +28,7 @@ export interface BlueprintSummary {
name: string; name: string;
description: string; description: string;
createdAt: string; createdAt: string;
recipe: LaunchRecipeSummary; entries: { count: number; game: ZombieGame; mapSlug: string; mapName: string }[];
entries: { count: number; recipe: LaunchRecipeSummary }[];
totalServers: number; totalServers: number;
} }
+4 -2
View File
@@ -416,8 +416,8 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs (
expected_map TEXT NOT NULL expected_map TEXT NOT NULL
); );
-- Launch recipes copy managed files, not credentials from the key/integration stores. -- Blueprints retain map/count selections only. The legacy column name remains
-- Independent of the artifact library so history survives version deletion. -- compatible with older snapshot-based definitions, read as selections at runtime.
CREATE TABLE IF NOT EXISTS server_blueprints ( CREATE TABLE IF NOT EXISTS server_blueprints (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL COLLATE NOCASE UNIQUE, name TEXT NOT NULL COLLATE NOCASE UNIQUE,
@@ -436,6 +436,8 @@ CREATE TABLE IF NOT EXISTS server_launch_previews (
deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE
); );
-- Historical launch files survive library deletion. New launches resolve current
-- promotions instead of replaying these snapshots; credentials stay in their stores.
CREATE TABLE IF NOT EXISTS server_launch_recipes ( CREATE TABLE IF NOT EXISTS server_launch_recipes (
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE,
recipe TEXT NOT NULL, recipe TEXT NOT NULL,
+4 -5
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js'; import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { config, configFileForGame, komodoConfigured, rconConfigured } from '../config.js'; import { config, komodoConfigured, rconConfigured } from '../config.js';
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js'; import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js'; import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
import { getServerKey } from './server-keys.js'; import { getServerKey } from './server-keys.js';
@@ -48,14 +48,13 @@ function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
})); }));
} }
function blockersFor(entries: BlueprintEntry[], servers: CollectionPreview['servers']): string[] { function blockersFor(servers: CollectionPreview['servers']): string[] {
const blockers: string[] = []; const blockers: string[] = [];
if (!servers.length) return blockers; if (!servers.length) return blockers;
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.'); 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 (!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.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 (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 (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; return blockers;
} }
@@ -65,7 +64,7 @@ export function previewCollection(blueprintId: unknown): CollectionPreview {
const { entries, blueprintName } = blueprintEntries(blueprintId); const { entries, blueprintName } = blueprintEntries(blueprintId);
const servers = allocate(entries); const servers = allocate(entries);
const counts = coverage(entries); const counts = coverage(entries);
const blockers = blockersFor(entries, servers); const blockers = blockersFor(servers);
const id = randomUUID(); const id = randomUUID();
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); 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('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
@@ -91,7 +90,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const entries: BlueprintEntry[] = JSON.parse(preview.entries); const entries: BlueprintEntry[] = JSON.parse(preview.entries);
const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers); const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers);
const servers = allocate(entries); const servers = allocate(entries);
const blockers = blockersFor(entries, servers); const blockers = blockersFor(servers);
if (blockers.length) throw new Error(blockers.join(' ')); 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)) { 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.'); throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
+15 -9
View File
@@ -167,7 +167,9 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.'); if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.');
const source = recipeForDeployment(input.deploymentId); const source = recipeForDeployment(input.deploymentId);
if (!source) throw new Error('This deployment has no recorded launch recipe.'); if (!source) throw new Error('This deployment has no recorded launch recipe.');
({ recipe, blueprintName } = source); blueprintName = source.blueprintName;
// History describes the previous launch; only its map selection is reusable.
recipe = captureRecipe(source.recipe.game, source.recipe.mapSlug);
} else { } else {
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.'); if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
recipe = captureRecipe(input.game, input.mapSlug); recipe = captureRecipe(input.game, input.mapSlug);
@@ -176,13 +178,12 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`; const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`;
const port = nextAvailablePort(); const port = nextAvailablePort();
const configured = !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret && config.komodo.targetServer); const configured = !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret && config.komodo.targetServer);
const compatible = recipe.configFile === configFileForGame(recipe.game);
const checks: LaunchPreview['checks'] = [ const checks: LaunchPreview['checks'] = [
{ label: 'Komodo settings', state: configured ? 'pass' : 'blocked', detail: configured ? 'Connection settings and target host are configured.' : 'Configure Komodo credentials and a target host before launching.' }, { label: 'Komodo settings', state: configured ? 'pass' : 'blocked', detail: configured ? 'Connection settings and target host are configured.' : 'Configure Komodo credentials and a target host before launching.' },
{ label: 'Health verification', state: config.rcon.password ? 'pass' : 'blocked', detail: config.rcon.password ? 'RCON is configured. Container, RCON, and map checks run after launch.' : 'Configure RCON_PASSWORD so the new server can be verified.' }, { label: 'Health verification', state: config.rcon.password ? 'pass' : 'blocked', detail: config.rcon.password ? 'RCON is configured. Container, RCON, and map checks run after launch.' : 'Configure RCON_PASSWORD so the new server can be verified.' },
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' }, { label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' },
{ label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` }, { label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` },
{ label: 'Configuration layout', state: compatible ? 'pass' : 'blocked', detail: compatible ? recipe.configSource : 'The configured filename has changed since this recipe was saved. Create a fresh recipe.' }, { label: 'Promoted configuration', state: 'pass', detail: recipe.configSource },
{ label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' } { label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' }
]; ];
const id = randomUUID(); const id = randomUUID();
@@ -208,15 +209,16 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) { if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) {
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' }; return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
} }
if (recipe && (recipe.configFile !== configFileForGame(recipe.game) || !config.rcon.password)) { if (recipe && !config.rcon.password) {
return { ok: false, kind: 'unconfigured', message: 'Configuration or health-check settings changed. Refresh the preview.' }; return { ok: false, kind: 'unconfigured', message: 'Health-check settings changed. Refresh the preview.' };
} }
const prepared = prepareCreation(input); const prepared = prepareCreation(input);
if (!prepared.ok) return prepared; if (!prepared.ok) return prepared;
const frozenRecipe = recipe ?? captureRecipe(input.game ?? 't5', prepared.mapEntry.slug); // A preview records choices, not version pins. Promotions remain authoritative.
const currentRecipe = captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
const id = beginDeployment(prepared.name, 'create', input.createdBy); const id = beginDeployment(prepared.name, 'create', input.createdBy);
db().prepare('INSERT INTO server_launch_recipes (deployment_id, recipe, blueprint_name) VALUES (?, ?, ?)') db().prepare('INSERT INTO server_launch_recipes (deployment_id, recipe, blueprint_name) VALUES (?, ?, ?)')
.run(id, JSON.stringify(frozenRecipe), preview?.blueprint_name ?? null); .run(id, JSON.stringify(currentRecipe), preview?.blueprint_name ?? null);
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id = ? WHERE id = ?').run(id, preview.id); if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id = ? WHERE id = ?').run(id, preview.id);
db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id); db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id);
db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)') db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)')
@@ -293,9 +295,13 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> { async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
const { name, port, plutoKey, mapEntry } = prepared; const { name, port, plutoKey, mapEntry } = prepared;
const game = gameOfServer(name); const game = gameOfServer(name);
const recipe = recipeForDeployment(operation)?.recipe ?? captureRecipe(game, mapEntry.slug); // Resolve once more at provisioning so promotions made while queued also apply.
// Persist the exact attempted files for history, never as input to future launches.
const recipe = captureRecipe(game, mapEntry.slug);
db().prepare(`INSERT INTO server_launch_recipes (deployment_id, recipe) VALUES (?, ?)
ON CONFLICT(deployment_id) DO UPDATE SET recipe = excluded.recipe`)
.run(operation, JSON.stringify(recipe));
const configFile = recipe.configFile; const configFile = recipe.configFile;
if (configFile !== configFileForGame(game)) return { ok: false, kind: 'bad-request', message: 'Server config filename changed after this launch was queued.' };
deploymentStage(operation, 'Preparing server files'); deploymentStage(operation, 'Preparing server files');
const hostDir = serverDir(name); const hostDir = serverDir(name);
+20 -14
View File
@@ -22,27 +22,33 @@ export interface LaunchRecipe {
} }
export interface BlueprintEntry { count: number; recipe: LaunchRecipe } export interface BlueprintEntry { count: number; recipe: LaunchRecipe }
interface BlueprintSelection { count: number; game: ZombieGame; mapSlug: string }
interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string } interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string }
/** Existing one-server blueprints remain usable without a destructive migration. */ /** Legacy snapshots provide the lineup only; their file versions never govern a new launch. */
function readEntries(raw: string): BlueprintEntry[] { function readEntries(raw: string): BlueprintSelection[] {
const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] }; const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] }
return saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }]; | { format: 3; entries: BlueprintSelection[] };
if (saved.format === 3) return saved.entries;
const entries = saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }];
return entries.map(({ count, recipe }) => ({ count, game: recipe.game, mapSlug: recipe.mapSlug }));
} }
function summarizeBlueprint(row: BlueprintRow): BlueprintSummary { function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
const entries = readEntries(row.recipe).map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })); const entries = readEntries(row.recipe).map(entry => ({ ...entry,
mapName: zombieMapBySlug(entry.game, entry.mapSlug)?.friendly ?? entry.mapSlug }));
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at, return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at,
recipe: entries[0].recipe, entries, totalServers: entries.reduce((total, entry) => total + entry.count, 0) }; entries, totalServers: entries.reduce((total, entry) => total + entry.count, 0) };
} }
export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } { export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.'); if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
return { entries: readEntries(row.recipe), blueprintName: row.name }; const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipe(game, mapSlug) }));
return { entries, blueprintName: row.name };
} }
function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEntry[] { function buildEntries(input: unknown): BlueprintSelection[] {
if (!Array.isArray(input) || input.length < 1 || input.length > 40) throw new Error('Add between 1 and 40 map selections.'); if (!Array.isArray(input) || input.length < 1 || input.length > 40) throw new Error('Add between 1 and 40 map selections.');
const seen = new Set<string>(); const seen = new Set<string>();
const entries = input.map(entry => { const entries = input.map(entry => {
@@ -52,8 +58,7 @@ function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEn
const key = `${entry.game}:${entry.mapSlug}`; const key = `${entry.game}:${entry.mapSlug}`;
if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.'); if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.');
seen.add(key); seen.add(key);
const previous = prior.find(item => item.recipe.game === entry.game && item.recipe.mapSlug === entry.mapSlug); return { count: entry.count as number, game: entry.game as ZombieGame, mapSlug: entry.mapSlug as string };
return { count: entry.count as number, recipe: previous?.recipe ?? captureRecipe(entry.game, entry.mapSlug) };
}); });
if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.'); if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.');
return entries; return entries;
@@ -123,10 +128,11 @@ export function saveBlueprint(input: { name: unknown; description?: unknown; pre
if (row) recipe = JSON.parse(row.recipe); if (row) recipe = JSON.parse(row.recipe);
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe; } else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe && input.entries === undefined) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.'); if (!recipe && input.entries === undefined) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
const entries = input.entries !== undefined ? buildEntries(input.entries) : [{ count: 1, recipe: recipe! }]; const entries = input.entries !== undefined ? buildEntries(input.entries)
: buildEntries([{ count: 1, game: recipe!.game, mapSlug: recipe!.mapSlug }]);
const id = randomUUID(); const id = randomUUID();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const saved = JSON.stringify({ format: 2, entries }); const saved = JSON.stringify({ format: 3, entries });
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)') db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
.run(id, name, input.description ?? '', createdAt, saved); .run(id, name, input.description ?? '', createdAt, saved);
return summarizeBlueprint({ id, name, description: (input.description as string) ?? '', created_at: createdAt, recipe: saved }); return summarizeBlueprint({ id, name, description: (input.description as string) ?? '', created_at: createdAt, recipe: saved });
@@ -137,8 +143,8 @@ export function updateBlueprint(id: string, input: { name: unknown; description?
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('Blueprint not found.'); if (!row) throw new Error('Blueprint not found.');
const name = blueprintName(input, id); const name = blueprintName(input, id);
const entries = buildEntries(input.entries, readEntries(row.recipe)); const entries = buildEntries(input.entries);
const recipe = JSON.stringify({ format: 2, entries }); const recipe = JSON.stringify({ format: 3, entries });
const description = (input.description as string) ?? ''; const description = (input.description as string) ?? '';
db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id); db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id);
return summarizeBlueprint({ ...row, name, description, recipe }); return summarizeBlueprint({ ...row, name, description, recipe });
@@ -29,7 +29,7 @@
async function initialize() { async function initialize() {
const current = ++session; const current = ++session;
name = blueprint?.name ?? ''; description = blueprint?.description ?? ''; name = blueprint?.name ?? ''; description = blueprint?.description ?? '';
rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.recipe.game, mapSlug: entry.recipe.mapSlug, count: entry.count })) ?? []; rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.game, mapSlug: entry.mapSlug, count: entry.count })) ?? [];
error = ''; loaded = false; busy = false; error = ''; loaded = false; busy = false;
try { try {
const res = await fetch('/api/maps'); const res = await fetch('/api/maps');
@@ -86,7 +86,7 @@
{/each} {/each}
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button> <Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
</div> </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> <p class="muted">Blueprints save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. 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 missing servers →</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> </form>
{/if} {/if}
@@ -60,7 +60,7 @@
<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> <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} {#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
{#if preview.servers.length} {#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> <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. Each new server uses the latest promoted setup when provisioning begins.</p>
<form onsubmit={event => { event.preventDefault(); void launch(); }}> <form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="servers"> <div class="servers">
{#each preview.servers as server, index} {#each preview.servers as server, index}
@@ -75,7 +75,7 @@
<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> <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> </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} {: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> <details class="recipes"><summary>Review the current promoted setup for each map</summary>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if} {/if}
</div> </div>
</dialog> </dialog>
@@ -124,19 +124,19 @@
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div> <div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p> <p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
</form> </form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.</p> <details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else} {#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save blueprint</Button></form> <form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save blueprint</Button></form>
{/if} {/if}
</details> </details>
{:else if sourceDeploymentId} {:else if sourceDeploymentId}
<p class="muted">{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button> <p class="muted">{busy ? 'Preparing the current promoted setup…' : 'Review the latest promoted setup for this map with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}> <form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label> <label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel">{#if selectedBlueprint.totalServers > 1}<h3>{selectedBlueprint.totalServers} servers in this blueprint</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</p>{/each}<p class="muted">Continue to review all servers and their keys together.</p>{:else}<LaunchRecipe recipe={selectedBlueprint.recipe} />{/if}{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section> {#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if} {:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a blueprint for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the exact managed files, available lobby, and launch prerequisites before creating your server.</p></div> <div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
</form> </form>
{/if} {/if}
+5 -5
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js'; import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe }: { recipe: LaunchRecipeSummary } = $props(); let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
</script> </script>
<div class="recipe"> <div class="recipe">
<div class="recipe-heading"><div><span class="eyebrow">Launch recipe</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div> <div class="recipe-heading"><div><span class="eyebrow">{historical ? 'Recorded launch files' : 'Current promoted setup'}</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<dl> <dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div> <div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div> <div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
</dl> </dl>
{#if !historical}<p>Each server uses the latest promoted scripts, plugins, and configuration when provisioning begins, including promotions made after this preview or while queued.</p>{/if}
<details> <details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe</summary> <summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul> <ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>Captured {new Date(recipe.capturedAt).toLocaleString()}. Managed file contents are saved with this recipe. Image tags and Plutainer defaults can change; use an image digest and a promoted baseline for more reproducible launches.</p> <p>{historical ? 'Recorded' : 'Previewed'} {new Date(recipe.capturedAt).toLocaleString()}. {historical ? 'This records the files selected for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
<p>Future fleet promotions and drift repair still follow the server’s game and map.</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small> <small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details> </details>
</div> </div>
+1 -1
View File
@@ -127,7 +127,7 @@
</div> </div>
<h3>{blueprint.name}</h3> <h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</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> <ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer"> <div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button> <Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button> <button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
+2 -2
View File
@@ -60,7 +60,7 @@
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.'; if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.'; if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
if (deployment.state === 'awaiting-health') return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.'; if (deployment.state === 'awaiting-health') return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.';
return deployment.action === 'create' ? 'Installing the saved launch recipe and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.'; return deployment.action === 'create' ? 'Installing the latest promoted setup and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.';
}); });
function duration(start: string, end: number) { function duration(start: string, end: number) {
const seconds = Math.max(0, Math.floor((end - new Date(start).getTime()) / 1000)); const seconds = Math.max(0, Math.floor((end - new Date(start).getTime()) / 1000));
@@ -120,7 +120,7 @@
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl> <dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
</section> </section>
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} /> <OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if} {#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch files'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Launch this map with the latest promoted scripts, plugins, and configuration using a fresh lobby and port.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
{:else if !error}<p>Loading deployment...</p>{/if} {:else if !error}<p>Loading deployment...</p>{/if}
</div> </div>
+52 -5
View File
@@ -56,6 +56,9 @@ describe('multi-server blueprints', () => {
const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
expect(blueprint.totalServers).toBe(5); expect(blueprint.totalServers).toBe(5);
expect(blueprint.entries.map(entry => entry.count)).toEqual([2, 1, 2]); expect(blueprint.entries.map(entry => entry.count)).toEqual([2, 1, 2]);
expect(blueprint).not.toHaveProperty('recipe');
const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get(blueprint.id) as { recipe: string };
expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries });
expect(preview.ready).toBe(true); expect(preview.ready).toBe(true);
expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1', 't6_zombie_town_1', 't6_zombie_town_2']); expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1', 't6_zombie_town_1', 't6_zombie_town_2']);
expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962, 28963, 28964]); expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962, 28963, 28964]);
@@ -66,21 +69,57 @@ describe('multi-server blueprints', () => {
expect(() => previewCreation({ blueprintId: blueprint.id })).toThrow(/multiple servers/); expect(() => previewCreation({ blueprintId: blueprint.id })).toThrow(/multiple servers/);
}); });
it('upgrades an existing one-server blueprint and preserves its saved bytes when counts change', async () => { it('reads a legacy one-server blueprint as a map selection and saves edits without snapshots', async () => {
const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const artifact = createScript({ name: 'Telemetry', targetRelPath: 'telemetry.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('legacy telemetry') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(artifact.version.id, artifact.script.id);
const recipe = captureRecipe('t5', 'theater'); const recipe = captureRecipe('t5', 'theater');
db().prepare('INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)').run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe)); db().prepare('INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)').run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe));
const current = uploadNewVersion(artifact.script.id, Buffer.from('current telemetry'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(current.id, artifact.script.id);
closeDb(); closeDb();
const old = listBlueprints()[0]; const old = listBlueprints()[0];
expect(old.totalServers).toBe(1); expect(old.totalServers).toBe(1);
expect(old.entries).toEqual([{ count: 1, recipe: old.recipe }]); expect(old.entries).toEqual([{ count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' }]);
expect(old).not.toHaveProperty('recipe');
expect(previewCreation({ blueprintId: 'legacy' }).recipe.files).toContainEqual(expect.objectContaining({ path: 'telemetry.gsc', versionId: current.id }));
const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries }); const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries });
expect(edited.totalServers).toBe(5); expect(edited.totalServers).toBe(5);
expect(edited.entries[0].recipe.fingerprint).toBe(old.recipe.fingerprint); expect(edited.entries[0]).toEqual({ ...old.entries[0], count: 2 });
const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get('legacy') as { recipe: string };
expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries });
expect(listBlueprints()).toHaveLength(1); expect(listBlueprints()).toHaveLength(1);
}); });
it('uses current promotions for legacy collection blueprints while retaining counts and exact T6 variants', async () => {
const { captureRecipe, listBlueprints } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const artifact = createScript({ name: 'Legacy telemetry', targetRelPath: 'telemetry.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('legacy telemetry') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(artifact.version.id, artifact.script.id);
const legacyEntries = entries.map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipe(game as 't5' | 't6', mapSlug) }));
db().prepare('INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)').run('legacy-lineup', 'Legacy lineup', new Date().toISOString(), JSON.stringify({ format: 2, entries: legacyEntries }));
const current = uploadNewVersion(artifact.script.id, Buffer.from('current telemetry'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(current.id, artifact.script.id);
closeDb();
expect(listBlueprints()[0].entries.map(({ mapName: _mapName, ...entry }) => entry)).toEqual(entries);
const preview = previewCollection('legacy-lineup');
expect(preview.entries[0].recipe.files).toContainEqual(expect.objectContaining({ path: 'telemetry.gsc', versionId: current.id }));
expect(preview.entries[2].recipe.mapSlug).toBe('town');
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
await processCreations();
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ 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/telemetry.gsc'), 'utf8')).toBe('current telemetry');
}
expect(await readFile(join(fixture.t6, 't6_zombie_town_2', 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
});
it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }], it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }],
[{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }], [{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }],
[{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => { [{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => {
@@ -247,7 +286,7 @@ describe('atomic collection launches', () => {
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toHaveLength(1); 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 () => { it('keeps the reviewed lineup after blueprint edits and deletion while using current promotions', async () => {
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('original') }); const { script, version } = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('original') });
@@ -261,10 +300,18 @@ describe('atomic collection launches', () => {
updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] }); updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] });
deleteBlueprint(blueprint.id); deleteBlueprint(blueprint.id);
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) }); const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
const queuedDetail = getCollection(queued.collectionId!)!;
const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
for (const server of queuedDetail.servers.filter(server => server.server.startsWith('t5_'))) {
expect(recipeForDeployment(server.id)?.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: newer.id }));
}
const newest = uploadNewVersion(script.id, Buffer.from('promoted while queued'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newest.id, script.id);
uploadNewVersion(script.id, Buffer.from('unpromoted draft'));
await processCreations(); 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']) { 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'); expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('promoted while queued');
} }
}); });
+75 -15
View File
@@ -62,29 +62,49 @@ describe('launch recipes and previews', () => {
expect(JSON.stringify(first)).not.toContain('private-'); expect(JSON.stringify(first)).not.toContain('private-');
}); });
it('launches reviewed bytes even when scripts and configuration are promoted before queueing and provisioning', async () => { it('uses promotions made after review and queueing, including plugins and composed configuration', async () => {
const { baseline, script } = await seedArtifacts(); const { baseline, script } = await seedArtifacts();
const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const plugin = createScript({ name: 'Game utilities', kind: 'plugin', scopeType: 'game', scopeValue: 't5', targetRelPath: 'utilities.dll', content: Buffer.from('old plugin') });
const overlay = createScript({ name: 'Kino settings', kind: 'config', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('set g_speed "180"\n') });
for (const item of [plugin, overlay]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id);
const preview = previewCreation(selection); const preview = previewCreation(selection);
const newScript = uploadNewVersion(script.script.id, Buffer.from('main(){ new_perks(); }')); const newScript = uploadNewVersion(script.script.id, Buffer.from('main(){ new_perks(); }'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newScript.id, script.script.id); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newScript.id, script.script.id);
const newOverlay = uploadNewVersion(overlay.script.id, Buffer.from('set g_speed "200"\n'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newOverlay.id, overlay.script.id);
uploadNewVersion(script.script.id, Buffer.from('main(){ unpromoted_draft(); }'));
const queued = queueCreateServer({ ...input, previewId: preview.id }); const queued = queueCreateServer({ ...input, previewId: preview.id });
if (!queued.ok) throw new Error(queued.message); if (!queued.ok) throw new Error(queued.message);
expect(recipeForDeployment(queued.deploymentId)?.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: newScript.id }));
const newBaseline = uploadNewVersion(baseline.script.id, Buffer.from('set sv_voice "0"\n')); const newBaseline = uploadNewVersion(baseline.script.id, Buffer.from('set sv_voice "0"\n'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newBaseline.id, baseline.script.id); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newBaseline.id, baseline.script.id);
const newPlugin = uploadNewVersion(plugin.script.id, Buffer.from('new plugin'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newPlugin.id, plugin.script.id);
uploadNewVersion(plugin.script.id, Buffer.from('unpromoted plugin'));
closeDb(); closeDb();
await processCreations(); await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }'); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ new_perks(); }');
expect(await readFile(join(fixture.t5, queued.name, 'configs/dedicated_sp.cfg'), 'utf8')).toContain('set sv_voice "1"'); const deployedConfig = await readFile(join(fixture.t5, queued.name, 'configs/dedicated_sp.cfg'), 'utf8');
expect(getDeployment(queued.deploymentId)?.recipe?.fingerprint).toBe(preview.recipe.fingerprint); expect(deployedConfig).toContain('set sv_voice "0"');
expect(deployedConfig).toContain('set g_speed "200"');
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/plugins/utilities.dll'), 'utf8')).toBe('new plugin');
expect(getDeployment(queued.deploymentId)?.recipe?.files).toContainEqual(expect.objectContaining({ path: 'utilities.dll', versionId: newPlugin.id }));
expect(getServerScript(queued.name, 'perks.gsc')).toMatchObject({ script_version_id: newScript.id, sha256: newScript.sha256 });
expect(getServerScript(queued.name, 'utilities.dll')).toMatchObject({ script_version_id: newPlugin.id, sha256: newPlugin.sha256 });
const recordedConfig = recipeForDeployment(queued.deploymentId)?.recipe.files.find(file => file.path === 'dedicated_sp.cfg');
expect(Buffer.from(recordedConfig!.content, 'base64').toString()).toBe(deployedConfig);
expect(getDeployment(queued.deploymentId)?.recipe?.fingerprint).not.toBe(preview.recipe.fingerprint);
expect(getDeployment(queued.deploymentId)?.deployment).toMatchObject({ state: 'done' }); expect(getDeployment(queued.deploymentId)?.deployment).toMatchObject({ state: 'done' });
expect(JSON.stringify(getDeployment(queued.deploymentId))).not.toContain('private-'); expect(JSON.stringify(getDeployment(queued.deploymentId))).not.toContain('private-');
}); });
it('freezes recipes for legacy API clients at queue time too', async () => { it('uses the latest promotion at provisioning for API clients without a preview', async () => {
const { script } = await seedArtifacts(); const { script } = await seedArtifacts();
const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
@@ -94,7 +114,7 @@ describe('launch recipes and previews', () => {
const changed = uploadNewVersion(script.script.id, Buffer.from('changed')); const changed = uploadNewVersion(script.script.id, Buffer.from('changed'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(changed.id, script.script.id); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(changed.id, script.script.id);
await processCreations(); await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }'); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('changed');
}); });
it('returns the same operation for repeated submission, even after expiry and restart', async () => { it('returns the same operation for repeated submission, even after expiry and restart', async () => {
@@ -173,27 +193,67 @@ describe('launch recipes and previews', () => {
expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'unconfigured' }); expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'unconfigured' });
}); });
it('keeps blueprint bytes after library deletion and blueprint removal, and allocates a new lobby on replay', async () => { it('resolves current file eligibility after a blueprint review and queued launch', async () => {
const { script } = await seedArtifacts(); const { script } = await seedArtifacts();
const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint, deleteBlueprint, listBlueprints, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { saveBlueprint, deleteBlueprint, listBlueprints, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { createScript, setScope } = await import('../../src/lib/server/fleet/scripts.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const blueprint = saveBlueprint({ name: 'Classic night', description: 'Original perks', previewId: previewCreation(selection).id }); const removed = createScript({ name: 'Removed script', targetRelPath: 'removed.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('removed') });
db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(script.script.id); const unpromoted = createScript({ name: 'Unpromoted script', targetRelPath: 'unpromoted.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('unpromoted') });
db().prepare('DELETE FROM scripts WHERE id = ?').run(script.script.id); for (const item of [removed, unpromoted]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id);
const blueprint = saveBlueprint({ name: 'Classic night', description: 'Kino lineup', previewId: previewCreation(selection).id });
const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get(blueprint.id) as { recipe: string };
expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: [{ count: 1, ...selection }] });
closeDb(); closeDb();
expect(listBlueprints()[0]).toEqual(blueprint); expect(listBlueprints()[0]).toEqual(blueprint);
const preview = previewCreation({ blueprintId: blueprint.id }); const preview = previewCreation({ blueprintId: blueprint.id });
deleteBlueprint(blueprint.id); deleteBlueprint(blueprint.id);
const queued = queueCreateServer({ ...input, previewId: preview.id }); const queued = queueCreateServer({ ...input, previewId: preview.id });
if (!queued.ok) throw new Error(queued.message); if (!queued.ok) throw new Error(queued.message);
db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(removed.script.id);
db().prepare('DELETE FROM scripts WHERE id = ?').run(removed.script.id);
db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(unpromoted.script.id);
setScope({ scriptId: script.script.id, scopeType: 'map', scopeValue: 'zombie_moon' });
const added = createScript({ name: 'New telemetry', targetRelPath: 'telemetry.gsc', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('newly promoted telemetry') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(added.version.id, added.script.id);
createScript({ name: 'Draft telemetry', targetRelPath: 'draft.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('never promoted') });
await processCreations(); await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }'); for (const file of ['perks.gsc', 'removed.gsc', 'unpromoted.gsc', 'draft.gsc']) {
await expect(readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp', file))).rejects.toMatchObject({ code: 'ENOENT' });
}
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/telemetry.gsc'), 'utf8')).toBe('newly promoted telemetry');
expect(recipeForDeployment(queued.deploymentId)?.blueprintName).toBe('Classic night'); expect(recipeForDeployment(queued.deploymentId)?.blueprintName).toBe('Classic night');
const replay = previewCreation({ deploymentId: queued.deploymentId }); expect(recipeForDeployment(queued.deploymentId)?.recipe.files.filter(file => file.kind === 'script')).toEqual([
expect.objectContaining({ path: 'telemetry.gsc', versionId: added.version.id })
]);
});
it('launches again with current promotions while preserving the completed deployment record', async () => {
const { script } = await seedArtifacts();
const { previewCreation, createServer, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const original = await createServer(input);
if (!original.ok) throw new Error(original.message);
const historicalRecipe = recipeForDeployment(original.deploymentId);
const promoted = uploadNewVersion(script.script.id, Buffer.from('current telemetry'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(promoted.id, script.script.id);
const replay = previewCreation({ deploymentId: original.deploymentId });
expect(replay).toMatchObject({ name: 't5_zombie_theater_2', port: 28961, hasSavedKey: false }); expect(replay).toMatchObject({ name: 't5_zombie_theater_2', port: 28961, hasSavedKey: false });
expect(replay.recipe.fingerprint).toBe(blueprint.recipe.fingerprint); expect(replay.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: promoted.id }));
expect(saveBlueprint({ name: 'Restored', deploymentId: queued.deploymentId }).recipe.fingerprint).toBe(blueprint.recipe.fingerprint); const blueprint = saveBlueprint({ name: 'Restored', deploymentId: original.deploymentId });
expect(blueprint.entries).toEqual([{ count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' }]);
const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get(blueprint.id) as { recipe: string };
expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: [{ count: 1, ...selection }] });
expect(previewCreation({ blueprintId: blueprint.id }).recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: promoted.id }));
const queued = queueCreateServer({ ...input, previewId: replay.id });
if (!queued.ok) throw new Error(queued.message);
await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('current telemetry');
expect(recipeForDeployment(original.deploymentId)).toEqual(historicalRecipe);
expect(await readFile(join(fixture.t5, original.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }');
}); });
it('preserves the exact T6 map variant in a blueprint', async () => { it('preserves the exact T6 map variant in a blueprint', async () => {