Add multi-server blueprint collections and atomic launches
This commit is contained in:
@@ -21,6 +21,44 @@ export interface LaunchRecipe {
|
||||
files: (LaunchFileSummary & { content: string })[];
|
||||
}
|
||||
|
||||
export interface BlueprintEntry { count: number; recipe: LaunchRecipe }
|
||||
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 }];
|
||||
}
|
||||
|
||||
function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
|
||||
const entries = readEntries(row.recipe).map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) }));
|
||||
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) };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEntry[] {
|
||||
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 > 20) throw new Error('Choose 1–20 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);
|
||||
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) };
|
||||
});
|
||||
if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.');
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
|
||||
const map = zombieMapBySlug(game, mapSlug);
|
||||
if (!map) throw new Error('Choose a supported game and map.');
|
||||
@@ -59,35 +97,52 @@ export function recipeForDeployment(id: string): { recipe: LaunchRecipe; bluepri
|
||||
}
|
||||
|
||||
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
|
||||
const row = db().prepare('SELECT recipe, name FROM server_blueprints WHERE id = ?').get(id) as { recipe: string; name: string } | undefined;
|
||||
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
|
||||
return { recipe: JSON.parse(row.recipe), blueprintName: row.name };
|
||||
const { entries, blueprintName } = blueprintEntries(id);
|
||||
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This blueprint 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 {
|
||||
id: string; name: string; description: string; created_at: string; recipe: string;
|
||||
}[]).map(row => ({ id: row.id, name: row.name, description: row.description,
|
||||
createdAt: row.created_at, recipe: summarizeRecipe(JSON.parse(row.recipe)) }));
|
||||
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as BlueprintRow[]).map(summarizeBlueprint);
|
||||
}
|
||||
|
||||
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary {
|
||||
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 blueprint 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 ((typeof input.previewId === 'string') === (typeof input.deploymentId === 'string')) throw new Error('Choose exactly one launch preview or recorded deployment.');
|
||||
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A blueprint 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: LaunchRecipe | 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) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
|
||||
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE').get(input.name.trim())) throw new Error('A blueprint with this name already exists. Choose a new name.');
|
||||
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 id = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const saved = JSON.stringify({ format: 2, entries });
|
||||
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe));
|
||||
return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) };
|
||||
.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('Blueprint not found.');
|
||||
const name = blueprintName(input, id);
|
||||
const entries = buildEntries(input.entries, readEntries(row.recipe));
|
||||
const recipe = JSON.stringify({ format: 2, 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 {
|
||||
|
||||
Reference in New Issue
Block a user