Use current promoted files for blueprint launches
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user