Automatically deploy the lowest available lobby with exact ID preview

This commit is contained in:
2026-09-17 15:59:29 -04:00
parent ae173517b6
commit e8e7332365
5 changed files with 39 additions and 47 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ the sweep marks pending rows `manual` so nothing hangs forever.
Use **Server keys** to add, replace, or remove a Plutonium key for an exact Use **Server keys** to add, replace, or remove a Plutonium key for an exact
server ID such as `t5_zombie_theater_2`. IDs use a supported map slug and a server ID such as `t5_zombie_theater_2`. IDs use a supported map slug and a
lobby number from 1 to 20; the server does not need to be deployed first. lobby number from 1 to 20; the server does not need to be deployed first.
During deployment, choose the map and lobby. A saved key is reused automatically, During deployment, choose the map. The lowest available lobby is assigned
automatically, with the exact server ID shown before deployment. Its saved key is reused automatically,
and any newly entered key is saved for that ID, including if deployment fails. and any newly entered key is saved for that ID, including if deployment fails.
Destroying a server preserves its saved key. Updating or removing a saved key Destroying a server preserves its saved key. Updating or removing a saved key
does not change a running server; changes apply to future deployments. does not change a running server; changes apply to future deployments.
+5 -6
View File
@@ -48,7 +48,7 @@ export interface CreateServerInput {
gameMode: GameMode; // 'zombie' — only value supported today gameMode: GameMode; // 'zombie' — only value supported today
mapSlug: string; // 'cosmodrome' mapSlug: string; // 'cosmodrome'
plutoKey?: string; // saved for this server ID when supplied plutoKey?: string; // saved for this server ID when supplied
lobby?: number; // explicit stable identity; otherwise first available expectedName?: string; // optional preview guard; never selects a lobby
createdBy?: string; // 'ui' by default createdBy?: string; // 'ui' by default
} }
@@ -160,15 +160,14 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
} }
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`; const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
if (input.lobby !== undefined && (!Number.isInteger(input.lobby) || input.lobby < 1 || input.lobby > 20)) { const lobby = nextLobbyNumber(baseName);
return { ok: false, kind: 'bad-request', message: 'Lobby must be an integer from 1 to 20' };
}
const lobby = input.lobby ?? nextLobbyNumber(baseName);
if (lobby === null) { if (lobby === null) {
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` }; return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
} }
const name = `${baseName}_${lobby}`; const name = `${baseName}_${lobby}`;
if (getServer(name)) return { ok: false, kind: 'no-slot', message: `${name} is already deployed` }; if (input.expectedName !== undefined && input.expectedName !== name) {
return { ok: false, kind: 'no-slot', message: 'Available lobby changed. Review the updated server ID and key, then deploy again.' };
}
const plutoKey = input.plutoKey?.trim() || getServerKey(name); const plutoKey = input.plutoKey?.trim() || getServerKey(name);
if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` }; if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` };
@@ -22,7 +22,6 @@
let gameMode = $state<'zombie'>('zombie'); let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>(''); let mapSlug = $state<string>('');
let plutoKey = $state(''); let plutoKey = $state('');
let lobby = $state('1');
let savedIds = $state<string[]>([]); let savedIds = $state<string[]>([]);
let deployedIds = $state<string[]>([]); let deployedIds = $state<string[]>([]);
let keysLoaded = $state(false); let keysLoaded = $state(false);
@@ -77,21 +76,12 @@
if (e.target === dialog) close(); if (e.target === dialog) close();
} }
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
.find(n => !deployedIds.includes(`t5_${gameMode}_${mapSlug}_${n}`)) ?? null);
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug)); let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
let previewName = $derived(selectedMap ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : ''); let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : '');
let hasSavedKey = $derived(savedIds.includes(previewName)); let hasSavedKey = $derived(savedIds.includes(previewName));
let alreadyDeployed = $derived(deployedIds.includes(previewName));
let lobbyOptions = $derived(Array.from({ length: 20 }, (_, i) => {
const value = String(i + 1);
const id = `t5_${gameMode}_${mapSlug}_${value}`;
return { value, label: `${value}${deployedIds.includes(id) ? ' (deployed)' : savedIds.includes(id) ? ' (saved key)' : ''}` };
}));
$effect(() => {
const prefix = `t5_${gameMode}_${mapSlug}_`;
lobby = String(Array.from({ length: 20 }, (_, i) => i + 1)
.find(n => !deployedIds.includes(`${prefix}${n}`)) ?? 1);
});
$effect(() => { mapSlug; lobby; plutoKey = ''; }); $effect(() => { mapSlug; lobby; plutoKey = ''; });
let modeOptions = [{ value: 'zombie', label: 'Zombies' }]; let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
@@ -99,7 +89,7 @@
async function submit(e: SubmitEvent): Promise<void> { async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault(); e.preventDefault();
if (deploying) return; if (deploying || !keysLoaded || lobby === null) return;
if (!selectedMap) { flash = 'Pick a map'; return; } if (!selectedMap) { flash = 'Pick a map'; return; }
if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; } if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; }
deploying = true; deploying = true;
@@ -108,11 +98,12 @@
const res = await fetch('/api/servers', { const res = await fetch('/api/servers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, lobby: Number(lobby), plutoKey: plutoKey.trim() || undefined }) body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
}); });
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; error?: string; kind?: string }; const body = (await res.json()) as { ok: boolean; name?: string; port?: number; error?: string; kind?: string };
if (!body.ok || !body.name) { if (!body.ok || !body.name) {
flash = body.error ?? 'Deploy failed'; flash = body.error ?? 'Deploy failed';
if (body.kind === 'no-slot') await loadKeys();
deploying = false; deploying = false;
return; return;
} }
@@ -150,10 +141,6 @@
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} /> <Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field> </Field>
<Field label="Lobby number" hint="Choose the same lobby to reuse its server ID and saved key.">
<Select bind:value={lobby} options={lobbyOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}> <Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}>
<Input <Input
type="password" type="password"
@@ -168,7 +155,7 @@
<div class="preview"> <div class="preview">
<span class="preview-k">Will create</span> <span class="preview-k">Will create</span>
<span class="preview-v">{previewName || '—'}</span> <span class="preview-v">{previewName || '—'}</span>
<span class="preview-sub">{alreadyDeployed ? "Already deployed; choose another lobby" : "Port auto-assigned"}</span> <span class="preview-sub">{!keysLoaded ? "Loading available lobby..." : lobby === null ? "All lobby slots for this map are in use" : "Lowest available lobby; port auto-assigned"}</span>
</div> </div>
<a href="/server-keys" onclick={close}>Manage saved keys</a> <a href="/server-keys" onclick={close}>Manage saved keys</a>
@@ -177,7 +164,7 @@
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={deploying || !selectedMap || !keysLoaded || alreadyDeployed} disabled={deploying || !selectedMap || !keysLoaded || lobby === null}
loading={deploying} loading={deploying}
> >
Deploy server Deploy server
@@ -251,7 +238,7 @@
gap: var(--space-4); gap: var(--space-4);
} }
.fields :global(.field):nth-child(1), .fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(5) { .fields :global(.field):nth-child(4) {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
+4 -4
View File
@@ -43,11 +43,11 @@ export async function POST({ request }: { request: Request }) {
gameMode?: string; gameMode?: string;
mapSlug?: string; mapSlug?: string;
plutoKey?: string; plutoKey?: string;
lobby?: number; expectedName?: string;
}; };
if (!body || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.lobby !== undefined && typeof body.lobby !== 'number')) { if (!body || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
return json( return json(
{ ok: false, error: 'gameMode and mapSlug are required strings; plutoKey and lobby are optional', kind: 'bad-request' }, { ok: false, error: 'gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
{ status: 400 } { status: 400 }
); );
} }
@@ -55,7 +55,7 @@ export async function POST({ request }: { request: Request }) {
gameMode: body.gameMode as GameMode, gameMode: body.gameMode as GameMode,
mapSlug: body.mapSlug, mapSlug: body.mapSlug,
plutoKey: body.plutoKey, plutoKey: body.plutoKey,
lobby: body.lobby, expectedName: body.expectedName,
createdBy: 'ui' createdBy: 'ui'
}); });
if (!result.ok) { if (!result.ok) {
+19 -14
View File
@@ -127,15 +127,16 @@ describe('persistent server keys', () => {
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never); vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never);
const name = 't5_zombie_cosmodrome_2'; const name = 't5_zombie_cosmodrome_2';
expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: true, name }); expect(await createServer(input)).toMatchObject({ ok: true, name: serverName });
expect(await createServer(input)).toMatchObject({ ok: true, name });
expect(getServerKey(name)).toBe('test-key'); expect(getServerKey(name)).toBe('test-key');
expect(await destroyServer(name)).toMatchObject({ ok: true }); expect(await destroyServer(name)).toMatchObject({ ok: true });
expect(listServerKeys()).toEqual([expect.objectContaining({ server_id: name, deployed: 0 })]); expect(listServerKeys()).toContainEqual(expect.objectContaining({ server_id: name, deployed: 0 }));
expect(JSON.stringify(listServerKeys())).not.toContain('test-key'); expect(JSON.stringify(listServerKeys())).not.toContain('test-key');
expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', lobby: 2 })).toMatchObject({ ok: true, name }); expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: name })).toMatchObject({ ok: true, name });
const spec = vi.mocked(komodoCreateDeployment).mock.calls.at(-1)![0]; const spec = vi.mocked(komodoCreateDeployment).mock.calls.at(-1)![0];
expect(spec.environment).toContainEqual({ variable: 'PLUTO_SERVER_KEY', value: 'test-key' }); expect(spec.environment).toContainEqual({ variable: 'PLUTO_SERVER_KEY', value: 'test-key' });
expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', lobby: 1 })).toMatchObject({ ok: false, kind: 'bad-request' }); expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome' })).toMatchObject({ ok: false, kind: 'bad-request' });
}); });
it('updates and removes keys independently of deployments and survives reopening the database', async () => { it('updates and removes keys independently of deployments and survives reopening the database', async () => {
@@ -153,15 +154,19 @@ describe('persistent server keys', () => {
expect(() => saveServerKey(serverName, 'two words')).toThrow(); expect(() => saveServerKey(serverName, 'two words')).toThrow();
}); });
it('rejects invalid or occupied lobbies before external operations', async () => { it('fills the lowest gap and rejects stale previews before saving a key or deploying', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
for (const lobby of [0, 21, 1.5]) { const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
expect(await createServer({ ...input, lobby })).toMatchObject({ ok: false, kind: 'bad-request' }); vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false } as never);
} expect(await createServer(input)).toMatchObject({ ok: true, name: serverName });
expect(komodoCreateDeployment).not.toHaveBeenCalled(); expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: true }); await destroyServer(serverName);
expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: false, kind: 'no-slot' }); expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' }))
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1); .toMatchObject({ ok: false, kind: 'no-slot' });
expect(getServerKey(serverName)).toBe('test-key');
expect(komodoCreateDeployment).toHaveBeenCalledTimes(2);
expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: serverName }))
.toMatchObject({ ok: true, name: serverName });
}); });
}); });