From 63e084e38f9c2320a63dbe642e1ce529909817be Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 8 Oct 2026 16:09:12 -0400 Subject: [PATCH] Limit collection targets to 15 lobbies per map --- docs/guides/collections.md | 2 +- src/lib/server/fleet/launch-recipes.ts | 2 +- src/lib/ui/components/BlueprintBuilder.svelte | 6 +++--- tests/unit/collection-launch-performance.test.ts | 3 ++- tests/unit/deployment-collections.test.ts | 11 ++++++----- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/guides/collections.md b/docs/guides/collections.md index a1e5a7e..b5c6d5f 100644 --- a/docs/guides/collections.md +++ b/docs/guides/collections.md @@ -91,7 +91,7 @@ and cleanup recovery where applicable. ### Collection limits and editing -Collections support up to 40 distinct game/map selections, 1–20 lobbies per map, +Collections support up to 40 distinct game/map selections, 1–15 lobbies per map, and 100 servers total. The host's configured port range and existing fleet may allow fewer. A collection can be saved even when there is insufficient capacity to launch it. Saving a collection does not capture or pin managed file versions. diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts index 14442e2..303d660 100644 --- a/src/lib/server/fleet/launch-recipes.ts +++ b/src/lib/server/fleet/launch-recipes.ts @@ -61,7 +61,7 @@ function buildEntries(input: unknown): BlueprintSelection[] { 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.'); + 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); diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte index 71e3b89..c3cab6b 100644 --- a/src/lib/ui/components/BlueprintBuilder.svelte +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -16,8 +16,8 @@ let error = $state(''); let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0)); const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug); - let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row))); - let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : ''); + let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 15 && !duplicate(row))); + let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–15 target lobbies for every row.' : ''); let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug)))); let wasOpen = false; let session = 0; @@ -83,7 +83,7 @@ {String(index + 1).padStart(2, '0')} - + {/each} diff --git a/tests/unit/collection-launch-performance.test.ts b/tests/unit/collection-launch-performance.test.ts index b3edfb2..e7d6384 100644 --- a/tests/unit/collection-launch-performance.test.ts +++ b/tests/unit/collection-launch-performance.test.ts @@ -62,7 +62,8 @@ describe('large native collection admission', () => { const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); const { db } = await import('../../src/lib/server/db/index.js'); const blueprint = saveBlueprint({ name: 'One hundred lobbies', entries: - ['theater', 'cosmodrome', 'coast', 'temple', 'moon'].map(mapSlug => ({ game: 't5', mapSlug, count: 20 })) }); + ['theater', 'cosmodrome', 'coast', 'temple', 'moon', 'pentagon', 'prototype'] + .map((mapSlug, index) => ({ game: 't5', mapSlug, count: index === 6 ? 10 : 15 })) }); const parse = JSON.parse; let manifestParses = 0; diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts index df97b6f..e8995e8 100644 --- a/tests/unit/deployment-collections.test.ts +++ b/tests/unit/deployment-collections.test.ts @@ -72,15 +72,16 @@ describe('multi-server blueprints', () => { 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]).toEqual({ ...old.entries[0], count: 2 }); + const updatedEntries = [{ ...entries[0], count: 15 }, ...entries.slice(1)]; + const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries: updatedEntries }); + expect(edited.totalServers).toBe(18); + expect(edited.entries[0]).toEqual({ ...old.entries[0], count: 15 }); 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(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: updatedEntries }); expect(listBlueprints()).toHaveLength(1); }); - it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }], + it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 16 }], [{ 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 }) => { const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');