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
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.
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.
Destroying a server preserves its saved key. Updating or removing a saved key
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
mapSlug: string; // 'cosmodrome'
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
}
@@ -160,15 +160,14 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
}
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
if (input.lobby !== undefined && (!Number.isInteger(input.lobby) || input.lobby < 1 || input.lobby > 20)) {
return { ok: false, kind: 'bad-request', message: 'Lobby must be an integer from 1 to 20' };
}
const lobby = input.lobby ?? nextLobbyNumber(baseName);
const lobby = nextLobbyNumber(baseName);
if (lobby === null) {
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
}
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);
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 mapSlug = $state<string>('');
let plutoKey = $state('');
let lobby = $state('1');
let savedIds = $state<string[]>([]);
let deployedIds = $state<string[]>([]);
let keysLoaded = $state(false);
@@ -77,21 +76,12 @@
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 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 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 = ''; });
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
@@ -99,7 +89,7 @@
async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault();
if (deploying) return;
if (deploying || !keysLoaded || lobby === null) return;
if (!selectedMap) { flash = 'Pick a map'; return; }
if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; }
deploying = true;
@@ -108,11 +98,12 @@
const res = await fetch('/api/servers', {
method: 'POST',
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 };
if (!body.ok || !body.name) {
flash = body.error ?? 'Deploy failed';
if (body.kind === 'no-slot') await loadKeys();
deploying = false;
return;
}
@@ -150,10 +141,6 @@
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</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.'}>
<Input
type="password"
@@ -168,7 +155,7 @@
<div class="preview">
<span class="preview-k">Will create</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>
<a href="/server-keys" onclick={close}>Manage saved keys</a>
@@ -177,7 +164,7 @@
<Button
type="submit"
variant="primary"
disabled={deploying || !selectedMap || !keysLoaded || alreadyDeployed}
disabled={deploying || !selectedMap || !keysLoaded || lobby === null}
loading={deploying}
>
Deploy server
@@ -251,7 +238,7 @@
gap: var(--space-4);
}
.fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(5) {
.fields :global(.field):nth-child(4) {
grid-column: 1 / -1;
}
+4 -4
View File
@@ -43,11 +43,11 @@ export async function POST({ request }: { request: Request }) {
gameMode?: string;
mapSlug?: 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(
{ 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 }
);
}
@@ -55,7 +55,7 @@ export async function POST({ request }: { request: Request }) {
gameMode: body.gameMode as GameMode,
mapSlug: body.mapSlug,
plutoKey: body.plutoKey,
lobby: body.lobby,
expectedName: body.expectedName,
createdBy: 'ui'
});
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');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never);
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(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(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];
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 () => {
@@ -153,15 +154,19 @@ describe('persistent server keys', () => {
expect(() => saveServerKey(serverName, 'two words')).toThrow();
});
it('rejects invalid or occupied lobbies before external operations', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
for (const lobby of [0, 21, 1.5]) {
expect(await createServer({ ...input, lobby })).toMatchObject({ ok: false, kind: 'bad-request' });
}
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: true });
expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: false, kind: 'no-slot' });
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
it('fills the lowest gap and rejects stale previews before saving a key or deploying', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false } as never);
expect(await createServer(input)).toMatchObject({ ok: true, name: serverName });
expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
await destroyServer(serverName);
expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' }))
.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 });
});
});