diff --git a/README.md b/README.md index 2730117..9502433 100644 --- a/README.md +++ b/README.md @@ -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. Existing single-server blueprints can be expanded with **Edit**. Single-server 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 health verification of an interrupted creation when its original server remains. See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow, -reproducibility boundaries, and API details. +promotion policy, and API details. ## Saved server keys diff --git a/docs/deployment-blueprints.md b/docs/deployment-blueprints.md index 70f5c21..f92fca9 100644 --- a/docs/deployment-blueprints.md +++ b/docs/deployment-blueprints.md @@ -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, and two Town lobbies. Counts are desired fleet totals: existing and queued 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 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, 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. +destroy servers or change existing servers' files. ## 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, 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 -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 -counts. Existing map recipes are retained when their counts change; newly added -maps capture their current promoted setup. Already reviewed launches keep their -original lineup and file contents, including after blueprint edits or removal. +counts. Already reviewed launches keep their original lineup, including after +blueprint edits or removal; their managed files still follow the latest promotions. +Previously saved blueprints automatically use this policy without being resaved. 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, 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 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. -Each map in a blueprint retains the game, map variant, image reference, composed configuration -when available, generated map configuration, and matching promoted script/plugin -bytes. Credentials, lobby IDs, and ports are not copied from the source server. +Each map in a blueprint retains the game, exact map variant, and desired lobby +count. Image references, managed files, and composed configuration are resolved +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 blueprint from the library when it is no longer needed; its existing servers, reviewed previews, and deployment history remain intact. -On a creation detail page, **Launch again** opens its original recipe with the -next available lobby and port. This reproduces the original managed launch files, -not the server's later live state. The preview also lets you save that historical -recipe as a blueprint. Older deployments without a recipe retain their history -but cannot offer this action. +On a creation detail page, **Launch again** selects the original game and map +with the next available lobby and port, using the latest promoted setup. The +preview also lets you save that map as a blueprint with a target of one lobby. +The recorded files remain visible as deployment history. Older deployments +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, -selected map, and image reference are stored in SQLite. Promotion or deletion of -an artifact after preview cannot change those bytes. A submitted preview is bound -to one deployment: retrying the same submission returns the original operation, -including after an application restart or a lost HTTP response. +A preview shows the currently promoted managed files, configuration, and image +reference for the selected map. It does not pin those versions. New launches +resolve the promoted setup again when submitted and when provisioning begins, so +promotions made after a preview or while a job is queued apply to that server. +This applies to blueprint collections, single-server launches, and **Launch again**. -Existing API clients that omit a preview still work. Their recipes are captured -when queued, before provisioning begins. Queued jobs from before this update use -the promoted setup at provisioning time because they have no recorded recipe. +The files selected at provisioning, their hashes and version metadata, composed +configuration, selected map, and image reference are recorded in SQLite for +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, -betas, and drift reconciliation continue to follow the server's game/map scopes. +A submitted preview is bound to one deployment: retrying the same submission +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. Without a promoted baseline, Plutainer seeds its own defaults on first boot, which are not captured. Host, network, game-file volumes, destination directories, 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 -wrong configuration. +The current main config filename and launch settings apply to new deployments. Recipes do not include the separately stored Plutonium or integration credentials. Managed files can themselves contain sensitive configuration, so protect database @@ -142,12 +151,12 @@ require inspection rather than replaying provisioning automatically. ## 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 - `{ 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. -- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. Unchanged - game/map selections keep their saved recipes. +- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. All entries + use the latest promoted setup when launched. - `DELETE /api/blueprints/:id`: remove a library entry. - `POST /api/deployment-collections/preview`: preview `{ blueprintId }`. - `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/:id`: collection and per-server progress. - `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. Returns an ID, expiry, recipe summary, capacity, key availability (never its value), and prerequisite checks. diff --git a/src/lib/deployment-plan.ts b/src/lib/deployment-plan.ts index f43a8a8..7e190cf 100644 --- a/src/lib/deployment-plan.ts +++ b/src/lib/deployment-plan.ts @@ -28,8 +28,7 @@ export interface BlueprintSummary { name: string; description: string; createdAt: string; - recipe: LaunchRecipeSummary; - entries: { count: number; recipe: LaunchRecipeSummary }[]; + entries: { count: number; game: ZombieGame; mapSlug: string; mapName: string }[]; totalServers: number; } diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 283ce82..618d724 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -416,8 +416,8 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs ( expected_map TEXT NOT NULL ); --- Launch recipes copy managed files, not credentials from the key/integration stores. --- Independent of the artifact library so history survives version deletion. +-- Blueprints retain map/count selections only. The legacy column name remains +-- compatible with older snapshot-based definitions, read as selections at runtime. CREATE TABLE IF NOT EXISTS server_blueprints ( id TEXT PRIMARY KEY, 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 ); +-- 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 ( deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, recipe TEXT NOT NULL, diff --git a/src/lib/server/fleet/collections.ts b/src/lib/server/fleet/collections.ts index 20f44fd..b8a3e9f 100644 --- a/src/lib/server/fleet/collections.ts +++ b/src/lib/server/fleet/collections.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.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 { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.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[] = []; 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 (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; } @@ -65,7 +64,7 @@ export function previewCollection(blueprintId: unknown): CollectionPreview { const { entries, blueprintName } = blueprintEntries(blueprintId); const servers = allocate(entries); const counts = coverage(entries); - const blockers = blockersFor(entries, servers); + const blockers = blockersFor(servers); const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString()); @@ -91,7 +90,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }): const entries: BlueprintEntry[] = JSON.parse(preview.entries); const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers); const servers = allocate(entries); - const blockers = blockersFor(entries, servers); + const blockers = blockersFor(servers); if (blockers.length) throw new Error(blockers.join(' ')); if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) { throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.'); diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index d3a8d30..6e45849 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -167,7 +167,9 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.'); const source = recipeForDeployment(input.deploymentId); 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 { 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); @@ -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 port = nextAvailablePort(); 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'] = [ { 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: '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: '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.' } ]; 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())) { 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)) { - return { ok: false, kind: 'unconfigured', message: 'Configuration or health-check settings changed. Refresh the preview.' }; + if (recipe && !config.rcon.password) { + return { ok: false, kind: 'unconfigured', message: 'Health-check settings changed. Refresh the preview.' }; } const prepared = prepareCreation(input); 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); 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); 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 (?, ?, ?, ?, ?, ?)') @@ -293,9 +295,13 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract { const { name, port, plutoKey, mapEntry } = prepared; 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; - 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'); const hostDir = serverDir(name); diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts index 2b4a196..c88d4e5 100644 --- a/src/lib/server/fleet/launch-recipes.ts +++ b/src/lib/server/fleet/launch-recipes.ts @@ -22,27 +22,33 @@ export interface 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 } -/** Existing one-server blueprints remain usable without a destructive migration. */ -function readEntries(raw: string): BlueprintEntry[] { - const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] }; - return saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }]; +/** Legacy snapshots provide the lineup only; their file versions never govern a new launch. */ +function readEntries(raw: string): BlueprintSelection[] { + const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] } + | { 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 { - 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, - 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 } { 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.'); - 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.'); const seen = new Set(); const entries = input.map(entry => { @@ -52,8 +58,7 @@ function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEn const key = `${entry.game}:${entry.mapSlug}`; if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.'); seen.add(key); - const previous = prior.find(item => item.recipe.game === entry.game && item.recipe.mapSlug === entry.mapSlug); - return { count: entry.count as number, recipe: previous?.recipe ?? captureRecipe(entry.game, entry.mapSlug) }; + return { count: entry.count as number, game: entry.game as ZombieGame, mapSlug: entry.mapSlug as string }; }); if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.'); return entries; @@ -123,10 +128,11 @@ export function saveBlueprint(input: { name: unknown; description?: unknown; pre if (row) recipe = JSON.parse(row.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.'); - 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 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 (?, ?, ?, ?, ?)') .run(id, name, input.description ?? '', createdAt, 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; if (!row) throw new Error('Blueprint not found.'); const name = blueprintName(input, id); - const entries = buildEntries(input.entries, readEntries(row.recipe)); - const recipe = JSON.stringify({ format: 2, entries }); + const entries = buildEntries(input.entries); + const recipe = JSON.stringify({ format: 3, entries }); const description = (input.description as string) ?? ''; db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id); return summarizeBlueprint({ ...row, name, description, recipe }); diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte index b6643f5..4254c2f 100644 --- a/src/lib/ui/components/BlueprintBuilder.svelte +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -29,7 +29,7 @@ async function initialize() { const current = ++session; 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; try { const res = await fetch('/api/maps'); @@ -86,7 +86,7 @@ {/each} -

{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.'}

+

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.

{/if} diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte index ca4f744..24080dc 100644 --- a/src/lib/ui/components/DeployCollectionDialog.svelte +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -60,7 +60,7 @@

{preview.servers.length ? 'Review the missing lobbies' : 'Nothing to create'}

{#each preview.blockers as blocker}{/each} {#if preview.servers.length} -

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.

+

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.

{ event.preventDefault(); void launch(); }}>
{#each preview.servers as server, index} @@ -75,7 +75,7 @@

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' })}.

{:else}

Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.

{/if} -
Review saved files for each map{#each preview.entries as entry}
{/each}
+
Review the current promoted setup for each map{#each preview.entries as entry}
{/each}
{/if}
diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 0739643..8edfd1d 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -124,19 +124,19 @@
{#if !sourceDeploymentId}{/if}

Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.

-
Make this a reusable blueprint

Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.

+
Make this a reusable blueprint

Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.

{#if savedName}

“{savedName}” is ready in your blueprint library.

{:else}
{ event.preventDefault(); void save(); }}>
{/if}
{:else if sourceDeploymentId} -

{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}

+

{busy ? 'Preparing the current promoted setup…' : 'Review the latest promoted setup for this map with a fresh lobby and port.'}

{:else}
{ event.preventDefault(); void review(); }}> - {#if selectedBlueprint}
{#if selectedBlueprint.totalServers > 1}

{selectedBlueprint.totalServers} servers in this blueprint

{#each selectedBlueprint.entries as entry}

{entry.count}× {entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}

{/each}

Continue to review all servers and their keys together.

{:else}{/if}{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
- {:else}

Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.

{/if} -
Know what you’re launching.

Review the exact managed files, available lobby, and launch prerequisites before creating your server.

+ {#if selectedBlueprint}

{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}

{#each selectedBlueprint.entries as entry}

{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}

{/each}

Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.

{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
+ {:else}

Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a blueprint for future launches.

{/if} +
Know what you’re launching.

Review the current promoted setup, available lobby, and launch prerequisites before creating your server.

{/if} diff --git a/src/lib/ui/components/LaunchRecipe.svelte b/src/lib/ui/components/LaunchRecipe.svelte index b3b3693..d17dabd 100644 --- a/src/lib/ui/components/LaunchRecipe.svelte +++ b/src/lib/ui/components/LaunchRecipe.svelte @@ -1,19 +1,19 @@
-
Launch recipe

{recipe.mapName}

{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}
+
{historical ? 'Recorded launch files' : 'Current promoted setup'}

{recipe.mapName}

{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}
Configuration
{recipe.configSource}
Container image
{recipe.image}
+ {#if !historical}

Each server uses the latest promoted scripts, plugins, and configuration when provisioning begins, including promotions made after this preview or while queued.

{/if}
- {recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe + {recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}
    {#each recipe.files as file}
  • {file.name}{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes
    {file.path}SHA-256 {file.sha256.slice(0, 12)}
  • {/each}
-

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.

-

Future fleet promotions and drift repair still follow the server’s game and map.

+

{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.'}

Recipe {recipe.fingerprint.slice(0, 16)}
diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index 67593e8..01aa4f4 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -127,7 +127,7 @@

{blueprint.name}

{blueprint.description || 'Your target lineup. Only missing servers will be created.'}

-
    {#each blueprint.entries as entry}
  • {entry.count}× {entry.recipe.mapName} {entry.recipe.game.toUpperCase()}
  • {/each}
+
    {#each blueprint.entries as entry}
  • {entry.count}× {entry.mapName} {entry.game.toUpperCase()}
  • {/each}
diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts index d8f196a..aefcb2f 100644 --- a/tests/unit/deployment-collections.test.ts +++ b/tests/unit/deployment-collections.test.ts @@ -56,6 +56,9 @@ describe('multi-server blueprints', () => { const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); expect(blueprint.totalServers).toBe(5); 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.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]); @@ -66,21 +69,57 @@ describe('multi-server blueprints', () => { 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 { 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 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'); 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(); const old = listBlueprints()[0]; 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 }); 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); }); + 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 }], [{ 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 }) => { @@ -247,7 +286,7 @@ describe('atomic collection launches', () => { 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 { 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') }); @@ -261,10 +300,18 @@ describe('atomic collection launches', () => { updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] }); deleteBlueprint(blueprint.id); 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(); 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'); + expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('promoted while queued'); } }); diff --git a/tests/unit/launch-recipes.test.ts b/tests/unit/launch-recipes.test.ts index 8a28584..0f9bb89 100644 --- a/tests/unit/launch-recipes.test.ts +++ b/tests/unit/launch-recipes.test.ts @@ -62,29 +62,49 @@ describe('launch recipes and previews', () => { 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 { 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 { 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 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); + 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 }); 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')); 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(); 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, 'configs/dedicated_sp.cfg'), 'utf8')).toContain('set sv_voice "1"'); - expect(getDeployment(queued.deploymentId)?.recipe?.fingerprint).toBe(preview.recipe.fingerprint); + expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ new_perks(); }'); + const deployedConfig = await readFile(join(fixture.t5, queued.name, 'configs/dedicated_sp.cfg'), 'utf8'); + 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(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 { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.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')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(changed.id, script.script.id); 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 () => { @@ -173,27 +193,67 @@ describe('launch recipes and previews', () => { 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 { 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 { createScript, setScope } = await import('../../src/lib/server/fleet/scripts.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 }); - db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(script.script.id); - db().prepare('DELETE FROM scripts WHERE id = ?').run(script.script.id); + const removed = createScript({ name: 'Removed script', targetRelPath: 'removed.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('removed') }); + const unpromoted = createScript({ name: 'Unpromoted script', targetRelPath: 'unpromoted.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('unpromoted') }); + 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(); expect(listBlueprints()[0]).toEqual(blueprint); const preview = previewCreation({ blueprintId: blueprint.id }); deleteBlueprint(blueprint.id); const queued = queueCreateServer({ ...input, previewId: preview.id }); 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(); - 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'); - 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.recipe.fingerprint).toBe(blueprint.recipe.fingerprint); - expect(saveBlueprint({ name: 'Restored', deploymentId: queued.deploymentId }).recipe.fingerprint).toBe(blueprint.recipe.fingerprint); + expect(replay.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: promoted.id })); + 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 () => {