From 274aedb66e5b53dcb357b038e427be02ad1378f7 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 18 Sep 2026 00:39:19 -0400 Subject: [PATCH] Add House Party Protocol for fleet-wide queued destruction --- README.md | 8 ++ src/lib/server/fleet/create.ts | 14 +++- src/lib/server/fleet/house-party.ts | 28 +++++++ src/lib/ui/components/HousePartyDialog.svelte | 74 +++++++++++++++++++ .../api/deployments/house-party/+server.ts | 18 +++++ src/routes/deployments/+page.svelte | 11 +++ tests/unit/create-server.test.ts | 62 ++++++++++++++++ 7 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/fleet/house-party.ts create mode 100644 src/lib/ui/components/HousePartyDialog.svelte create mode 100644 src/routes/api/deployments/house-party/+server.ts diff --git a/README.md b/README.md index c39e6a3..34559ea 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,14 @@ the sweep marks pending rows `manual` so nothing hangs forever. ## Deployment blueprints and launch previews +Deployments also offers **House Party Protocol**: review the entire fleet and queue +destruction in one action. This includes queued creations and reuses pending +destruction requests. Each server waits for a fresh zero-player reading; occupied +or unreachable servers remain queued. Containers, server files, and registrations +are removed, while saved keys and blueprints are retained. The preview must be +reviewed again if fleet membership changes before confirmation. Servers created +after confirmation are not included. Progress appears in deployment history. + Deployments includes a blueprint builder for whole server collections: choose multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet** counts existing and queued servers and creates only the shortfall. The preview diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 2f3e1d1..718b858 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -416,12 +416,16 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT const destroying = new Set(); // Persist the request before returning so navigation and client disconnects cannot lose it. -export function destroyServer(name: string): DestroyServerResult & { deploymentId?: string } { +function hasActiveCreation(name: string): boolean { + return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name); +} + +export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } { const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined; if (existing) return { ok: true, warnings: [], deploymentId: existing.id }; - if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] }; + if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] }; const operation = db().transaction(() => { - const id = beginDeployment(name, 'destroy'); + const id = beginDeployment(name, 'destroy', triggeredBy); db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id); deploymentStage(id, 'Waiting to verify the server is empty'); return id; @@ -443,6 +447,10 @@ async function runDestructions(): Promise { destroying.add(operation.server); try { if (!getServer(operation.server)) { + if (hasActiveCreation(operation.server)) { + deploymentStage(operation.id, 'Waiting for server creation to finish'); + continue; + } finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' }); continue; } diff --git a/src/lib/server/fleet/house-party.ts b/src/lib/server/fleet/house-party.ts new file mode 100644 index 0000000..ef37cb1 --- /dev/null +++ b/src/lib/server/fleet/house-party.ts @@ -0,0 +1,28 @@ +import { db } from '../db/index.js'; +import { destroyServer } from './create.js'; + +export function housePartyTargets(): string[] { + return (db().prepare(` + SELECT name FROM servers WHERE destroyed_at IS NULL + UNION + SELECT server AS name FROM server_deployments + WHERE action = 'create' AND state IN ('queued', 'running', 'awaiting-health') + ORDER BY name + `).all() as { name: string }[]).map(row => row.name); +} + +export function queueHouseParty(expectedServers: string[]) { + return db().transaction(() => { + const servers = housePartyTargets(); + const expected = [...expectedServers].sort(); + if (servers.length !== expected.length || servers.some((name, index) => name !== expected[index])) { + return { ok: false as const, error: 'The fleet changed. Refresh the preview and review the servers again.' }; + } + const deployments = servers.map(server => { + const result = destroyServer(server, 'house-party-protocol'); + if (!result.ok) throw new Error(result.message); + return { server, deploymentId: result.deploymentId! }; + }); + return { ok: true as const, deployments }; + })(); +} diff --git a/src/lib/ui/components/HousePartyDialog.svelte b/src/lib/ui/components/HousePartyDialog.svelte new file mode 100644 index 0000000..6239571 --- /dev/null +++ b/src/lib/ui/components/HousePartyDialog.svelte @@ -0,0 +1,74 @@ + + + { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> +
+
Call it a night

House Party Protocol

+

Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.

+

Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.

+ {#if error}{/if} + {#if servers} + {servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'} + {#if servers.length}
    {#each servers as server}
  • {server}
  • {/each}

Existing destruction requests are reused. Servers added after this request are not included.

{/if} + {:else}

{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}

{/if} +
+ + {#if !servers} + {:else}{/if} +
+
+
+ + diff --git a/src/routes/api/deployments/house-party/+server.ts b/src/routes/api/deployments/house-party/+server.ts new file mode 100644 index 0000000..d337161 --- /dev/null +++ b/src/routes/api/deployments/house-party/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { housePartyTargets, queueHouseParty } from '$lib/server/fleet/house-party.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export function GET() { + return json({ servers: housePartyTargets() }, { headers: { 'Cache-Control': 'no-store' } }); +} + +export async function POST({ request }: { request: Request }) { + const body = await request.json().catch(() => null); + if (!Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string')) { + return json({ error: 'A reviewed server list is required.' }, { status: 400 }); + } + const result = queueHouseParty(body.servers); + if (!result.ok) return json(result, { status: 409 }); + void kick().catch(err => console.error('[house-party] worker kick failed:', err)); + return json(result, { status: 202 }); +} diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index 5cf6ea2..4731498 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -7,6 +7,7 @@ import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte'; import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte'; + import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js'; interface Deployment { @@ -23,6 +24,8 @@ let builderOpen = $state(false); let editingBlueprint = $state(null); let collectionOpen = $state(false); + let housePartyOpen = $state(false); + let housePartyNotice = $state(''); let collections = $state([]); let blueprints = $state([]); let blueprintsLoading = $state(true); @@ -92,6 +95,12 @@ +
+
Call it a night.

Queue the whole fleet for removal once each server is empty.

+ +
+ { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in In flight below.`; void load(); }} /> + {#if housePartyNotice}

{housePartyNotice}

{/if}

Your blueprints {blueprints.length}

{#if blueprintError}{/if} @@ -171,6 +180,8 @@ {/snippet}