import { packFileJson, unpackFileJson } from '../db/file-blobs.js'; import { randomUUID } from 'node:crypto'; import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js'; import { config, configFileForGame } from '../config.js'; import { db } from '../db/index.js'; import { zombieMapBySlug, type ZombieGame } from './map-aliases.js'; import { promotedArtifactMetadata, promotedArtifacts } from './promoted-artifacts.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { getBaselineForScope, getVersionMetadata, listPromotedOverlaysForScope } from './scripts.js'; import { sha256Buffer } from './files.js'; export interface LaunchRecipePlan { native?: import('../../hosts.js').NativePlacement; format: 1; game: ZombieGame; mapSlug: string; mapName: string; expectedMap: string; capturedAt: string; image: string; configFile: string; configSource: string; files: (LaunchFileSummary & { content?: string })[]; } /** Attempted launches retain their exact bytes; queued plans need only metadata. */ export interface LaunchRecipe extends LaunchRecipePlan { files: (LaunchFileSummary & { content: string })[]; } export interface BlueprintEntry { count: number; recipe: LaunchRecipePlan; } interface BlueprintSelection { count: number; game: ZombieGame; mapSlug: string; } interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string; } /** 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) => ({ ...entry, mapName: zombieMapBySlug(entry.game, entry.mapSlug)?.friendly ?? entry.mapSlug })); return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at, 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 collection is no longer available. Choose another starting point.'); const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipePlan(game, mapSlug) })); return { entries, blueprintName: row.name }; } 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) => { if ( !entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string' || !zombieMapBySlug(entry.game, entry.mapSlug) ) throw new Error('Each selection needs a supported game and map.'); if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 15) throw new Error('Choose 1–15 lobbies for each map.'); 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); 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 collection can launch at most 100 servers.'); return entries; } export function captureRecipe( game: ZombieGame, mapSlug: string, rconPassword?: string ): LaunchRecipe { return buildRecipe(game, mapSlug, true, rconPassword); } /** Preview and admission keep file references; execution captures the current bytes. */ export function captureRecipePlan( game: ZombieGame, mapSlug: string, rconPassword?: string ): LaunchRecipePlan { return buildRecipe(game, mapSlug, false, rconPassword); } function buildRecipe( game: ZombieGame, mapSlug: string, includeContent: true, rconPassword?: string ): LaunchRecipe; function buildRecipe( game: ZombieGame, mapSlug: string, includeContent: false, rconPassword?: string ): LaunchRecipePlan; function buildRecipe( game: ZombieGame, mapSlug: string, includeContent: boolean, rconPassword?: string ): LaunchRecipePlan { const map = zombieMapBySlug(game, mapSlug); if (!map) throw new Error('Choose a supported game and map.'); const metadata = ({ kind, script, version }: ReturnType[number]): LaunchFileSummary => ({ name: script.name, kind, path: script.target_rel_path, versionId: version.id, version: version.version, releaseVersion: version.release_version, sha256: version.sha256, size: version.size }); const files: LaunchRecipePlan['files'] = includeContent ? promotedArtifacts(game, map.canonical).map((artifact) => ({ ...metadata(artifact), content: artifact.version.content.toString('base64') })) : promotedArtifactMetadata(game, map.canonical).map(metadata); const addConfig = (name: string, path: string, content: Buffer) => files.push({ name, path, kind: 'config', versionId: null, version: null, releaseVersion: null, sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { content: content.toString('base64') } : {}) }); const composed = getPromotedServerConfig(game, map.canonical, map.slug, rconPassword); let configSource = 'Plutainer defaults, seeded on first boot'; if (composed) { const baseline = getBaselineForScope('game', game)!; const layers = [ baseline, ...listPromotedOverlaysForScope('game', game), ...listPromotedOverlaysForScope('map', map.canonical) ]; configSource = layers .map( (layer) => `${layer.name} v${getVersionMetadata(layer.current_version_id!)!.version}` ) .join(' + '); addConfig('Composed server configuration', configFileForGame(game), composed); } addConfig('Selected map', MAP_CONFIG_FILE, getServerMapConfig(game, map.canonical, map.slug)!); return { format: 1, game, mapSlug, mapName: map.friendly, expectedMap: map.canonical, capturedAt: new Date().toISOString(), image: config.defaultRuntimeImage, configFile: configFileForGame(game), configSource, files }; } export function summarizeRecipe( recipe: LaunchRecipePlan, fingerprint = sha256Buffer(Buffer.from(JSON.stringify(recipe))) ): LaunchRecipeSummary { const { format: _format, files, ...summary } = recipe; return { ...summary, files: files.map(({ content: _content, ...file }) => file), fingerprint }; } /** Keep the small detail response beside the immutable attempted file bytes. */ export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } { const encoded = JSON.stringify(recipe); return { recipe: JSON.stringify(packFileJson(recipe).value), recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) }; } export function recipeSummaryForDeployment( id: string ): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null { const row = db() .prepare( 'SELECT recipe_summary, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?' ) .get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined; if (!row) return null; if (row.recipe_summary !== null) { const saved: LaunchRecipeSummary = JSON.parse(row.recipe_summary); const summary = withHistoricalReleaseVersions(saved); if (summary !== saved) db() .prepare( 'UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?' ) .run(JSON.stringify(summary), id); return { recipe: summary, blueprintName: row.blueprint_name }; } // Old snapshots remain readable. Populate on first access, without making // controller startup scan and decode every retained plugin in its history. const launch = recipeForDeployment(id)!; const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe)); db() .prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?') .run(JSON.stringify(summary), id); return { recipe: summary, blueprintName: row.blueprint_name }; } /** Restore display metadata from the exact retained upload, preserving the captured recipe. */ function withHistoricalReleaseVersions(summary: LaunchRecipeSummary): LaunchRecipeSummary { const files = summary.files.map((file) => { if (file.releaseVersion !== undefined || file.versionId === null) return file; const version = getVersionMetadata(file.versionId); if (!version || version.version !== file.version || version.sha256 !== file.sha256) return file; return { ...file, releaseVersion: version.release_version }; }); return files.some((file, index) => file !== summary.files[index]) ? { ...summary, files } : summary; } export function recipeForDeployment( id: string ): { recipe: LaunchRecipePlan; blueprintName: string | null } | null { const row = db() .prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?') .get(id) as { recipe: string; blueprint_name: string | null } | undefined; return row ? { recipe: unpackFileJson(JSON.parse(row.recipe)), blueprintName: row.blueprint_name } : null; } export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } { const { entries, blueprintName } = blueprintEntries(id); if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This collection launches multiple servers. Use the collection preview.'); return { recipe: entries[0].recipe, blueprintName }; } export function listBlueprints(): BlueprintSummary[] { return ( db() .prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE') .all() as BlueprintRow[] ).map(summarizeBlueprint); } function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string { if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the collection a name of 1–80 characters.'); if ( input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500) ) throw new Error('Keep the description to 500 characters.'); if ( db() .prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?') .get(input.name.trim(), exceptId) ) throw new Error('A collection with this name already exists. Choose a new name.'); return input.name.trim(); } export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown; entries?: unknown; }): BlueprintSummary { const name = blueprintName(input); if ( [input.previewId, input.deploymentId, input.entries].filter((value) => value !== undefined) .length !== 1 ) throw new Error('Choose exactly one launch preview, recorded deployment, or set of maps.'); let recipe: LaunchRecipePlan | undefined; if (typeof input.previewId === 'string') { const row = db() .prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?') .get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined; 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) : buildEntries([{ count: 1, game: recipe!.game, mapSlug: recipe!.mapSlug }]); const id = randomUUID(); const createdAt = new Date().toISOString(); 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 }); } export function updateBlueprint( id: string, input: { name: unknown; description?: unknown; entries?: unknown } ): BlueprintSummary { return db().transaction(() => { const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; if (!row) throw new Error('Collection not found.'); const name = blueprintName(input, id); 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 }); })(); } export function deleteBlueprint(id: string): boolean { return db().prepare('DELETE FROM server_blueprints WHERE id = ?').run(id).changes > 0; }