402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
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<string>();
|
||
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<typeof promotedArtifactMetadata>[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;
|
||
}
|