From 68323c0baad1630e533450652d174a188f8204e9 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 4 Oct 2026 12:23:36 -0400 Subject: [PATCH] Simplify game files to automatic T5 and T6 uploads --- README.md | 51 +++--- docs/native-hosts.md | 70 +++++--- src/lib/game-files.ts | 6 +- src/lib/server/game-files/index.ts | 72 ++++---- src/lib/server/hosts/preparation.ts | 30 +++- src/lib/server/provisioning/native.ts | 12 +- .../components/DeployCollectionDialog.svelte | 10 +- .../ui/components/DeployServerDialog.svelte | 21 ++- src/lib/ui/components/HostPreparation.svelte | 4 +- .../ui/components/NativeHostSelection.svelte | 44 +++-- src/lib/ui/native-game-files.ts | 26 +++ src/routes/api/system-status/+server.ts | 8 +- src/routes/game-files/+page.svelte | 168 ++++++++++-------- src/routes/hosts/[id]/+page.svelte | 2 +- tests/unit/game-file-browser-upload.test.ts | 55 +++++- tests/unit/game-file-library.test.ts | 87 ++++++++- tests/unit/host-preparation.test.ts | 64 +++++-- .../unit/native-game-files-selection.test.ts | 55 ++++++ tests/unit/native-provisioning.test.ts | 35 +++- 19 files changed, 588 insertions(+), 232 deletions(-) create mode 100644 src/lib/ui/native-game-files.ts create mode 100644 tests/unit/native-game-files-selection.test.ts diff --git a/README.md b/README.md index 1431af8..aebdb5c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Electric Cherry Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host -agents. Docker and Plutainer run the games; EC handles hosts, game libraries, +agents. Docker and Plutainer run the games; EC handles hosts, game files, collections, configuration, campaigns, maintenance and operation history. Komodo is not required for native hosts. @@ -29,18 +29,19 @@ The default Compose data mount preserves the previous `EC_DATA_DIR` to your preferred persistent directory. Back up that directory, including SQLite metadata and published game-file storage. -1. **Library → Game Files**: select T5 or T6 and upload the complete local game - folder, including its DLC files. Name the library and version, then publish it. +1. **Library → Game Files**: upload the complete local folder under T5 or T6, + including its DLC files. There are no library names or version labels to enter. + Each upload becomes the latest files for that game after verification. Uploads work before any host is enrolled. 2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its generated enrollment command. The host automatically prepares its Plutainer - runtime and the latest published version of every game library. The host page + runtime and the latest T5 and T6 files, when available. The host page shows setup progress. Automatic agent updates are enabled by default. 3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when launching. -4. Launch a server or collection with a host and game-file version. The default - runtime is selected automatically. EC reserves host-specific ports and verifies - startup. +4. Launch a server or collection on a host. The latest files for each game and + the default runtime are selected automatically. EC reserves host-specific + ports and verifies startup. See [Native host management](docs/native-hosts.md) for installation, credentials, storage, failure recovery, API contracts and the live-host acceptance checklist. @@ -72,25 +73,31 @@ and safely retires existing servers as they become eligible. Collections save target maps and lobby counts. Fill missing servers adds only the shortfall; Match collection exactly also queues safe removal of excess servers. -Launch previews select a host, pinned base-game versions and an independent -runtime image. Every new launch uses the latest promoted configuration, scripts -and plugins when execution starts. Saved Plutonium keys are reused by exact -server ID and retained after removal. +Launch previews use the latest verified files for each game and select an +independent runtime image. If newer game files become available before launch, +refresh the preview to confirm the updated files. Every new launch uses the +latest promoted configuration, scripts and plugins when execution starts. Saved +Plutonium keys are reused by exact server ID and retained after removal. ## Libraries and campaigns -Game-file versions contain the uploaded T5/T6 folder, with its directory layout -and included DLC preserved. There is no separate DLC declaration. Versions record -the game, label, file manifest, checksums and size. Uploads use bounded chunks, -support pause/resume and verify checksums before publication. Actual game files -use dedicated filesystem storage, never SQLite blobs. Publishing a new version -automatically prepares it on active hosts, including hosts enrolled later. Host -downloads transfer up to four files concurrently, resume interrupted transfers, +Game Files has one upload slot each for T5 and T6. Uploads preserve the folder's +directory layout and included DLC; there is no separate DLC declaration. +EC records immutable upload IDs, file manifests, checksums and sizes automatically. +Uploads use bounded chunks, support pause/resume and verify checksums before +publication. Actual game files +use dedicated filesystem storage, never SQLite blobs. The last upload to finish +verification becomes that game's latest files, even if it started earlier. +Active hosts always prepare the latest T5 and T6 files when available, including +hosts enrolled later. Host downloads transfer up to four files concurrently, +resume interrupted transfers, report progress and verify checksums before becoming usable. Failed preparation retries automatically with backoff; the host page reports any blocking condition. Set `PLUTAINER_IMAGE` to change the default runtime prepared on hosts. Existing -servers retain their selected library version and runtime. -Multiple instances on one host share read-only base files. +servers retain their existing files and runtime without a forced restart. +Previous uploads, verified host copies and server references are preserved. +Existing named libraries appear together under their game, with no renaming or +reupload required. Multiple instances on one host share read-only base files. Scripts, plugins, configuration baselines and overlays retain their versioning, beta, promotion and rollback workflows. Campaigns stage bytes remotely without @@ -101,7 +108,7 @@ operation history. T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and `dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded after the main config. T6 variants retain their distinct catalog selections. -Game-file libraries and Plutonium runtime revisions are tracked independently. +Game-file uploads and Plutonium runtime revisions are tracked independently. Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it in an authorization header. Anonymous reports are rejected before they can @@ -147,7 +154,7 @@ exercising enrollment. Production agent transport requires HTTPS. `src/lib/server/hosts` owns enrollment, readiness and command transport; `src/lib/server/backends` provides instance identity and remote file access; `src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign -queues to agents; `src/lib/server/game-files` owns immutable libraries and +queues to agents; `src/lib/server/game-files` owns immutable game-file uploads and streaming distribution; `agent/` contains the service, installer and agent tests. Automated tests use simulated Docker/game hosts. Real Debian installer and diff --git a/docs/native-hosts.md b/docs/native-hosts.md index f0c4813..49ef32b 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -18,17 +18,19 @@ the agent owns local execution and its durable operation journal. installation, verifies the downloaded agent and updater SHA-256 over HTTPS, enrolls it and enables `electric-cherry-agent.service` and its automatic update timer. It needs no Git, Node.js or local build. -4. In **Game Files**, select T5 or T6 and upload your local game folder. Name the - library and version and publish - it after the upload finishes. Include the DLC files in that folder; there is - no separate DLC declaration. Uploading also works before enrolling any host. +4. In **Game Files**, upload your complete local folder under T5 or T6. Include + the DLC files in that folder; there is no separate DLC declaration, library + name or version label. The upload becomes the latest files for that game after + verification. Uploading also works before enrolling any host. 5. Hosts automatically prepare the configured Plutainer runtime and the latest - published version of every game library. **Automatic host setup** reports + verified T5 and T6 files whenever available. **Automatic host setup** reports progress and errors. Wait for verification before launching. Hosts report connection and readiness separately; deployment also checks the particular - library, image, capacity and ports you selected. -6. Launch a server or collection. Select the host, immutable game-file version(s) - and use the automatically selected default runtime, or an advanced override. + game files, image, capacity and ports required for the launch. +6. Launch a server or collection. Select the host; the latest game files and + default runtime are selected automatically. An advanced runtime override is + available. If the latest files change after previewing, refresh the preview + before launching. EC selects lobby IDs and reserves UDP/TCP ports atomically. Saved Plutonium server keys remain associated with their exact server names. @@ -37,19 +39,25 @@ copying or per-container setup. EC does not supply the game content itself. The controller reconciles automatic setup on agent heartbeats and operation polls. It first pulls `PLUTAINER_IMAGE` (default -`ghcr.io/ayymoss/plutainer:latest`), then prepares the newest published version of -each library, one setup operation at a time. Publishing more files or enrolling -another host needs no manual propagation. Setup waits for an active, connected, +`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for +T5 and for T6 when available, one setup operation at a time. Uploading new files +or enrolling another host needs no manual propagation. Setup waits for an active, connected, healthy host with sufficient storage. Failed operations retry automatically with backoff from one minute to fifteen minutes; their errors remain visible in the host's setup panel and operation history. Disconnected hosts resume when they reconnect. Draining and disabled hosts do not receive new automatic setup work. -Verified older versions remain cached and running servers keep their selected -files and runtime. Automatic setup ensures that the configured image is present; -it does not refresh an already cached mutable image tag. Advanced controls on the +An automatic download that has not been delivered to the agent can be replaced +when a newer upload becomes available. A delivered operation finishes safely +before the host prepares the latest files. Failed or incomplete uploads never +replace the latest verified files. + +Verified previous uploads remain cached and running servers keep their existing +files and runtime; uploading new files does not restart servers. Automatic setup +ensures that the configured image is present; it does not refresh an already +cached mutable image tag. Advanced controls on the host and game-file pages can explicitly pull a custom runtime, prepare an older -library version or retry preparation immediately. +upload or retry preparation immediately. The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes it. Use a dedicated, root-owned directory. For a separate disk, use a child of its @@ -160,8 +168,13 @@ API outline: - `POST /api/agent/operations/:id`: authenticated acceptance, progress or result. - `POST /api/agent/decommission/:id`: cleanup worker reports file removal and agent uninstall using its own operation-scoped receipt credential. -- `POST /api/game-files`: administrator creates a folder upload with game, name, - version, optional description and `files: [{path, size, lastModified}]`. +- `GET /api/game-files`: returns `{libraries, hosts}`. `libraries` always contains + two game slots, T5 and T6, including empty slots. Each slot has a nullable + `latestVersionId` identifying its latest verified upload, plus upload history. +- `POST /api/game-files`: administrator creates a folder upload with + `{game: "t5" | "t6", files: [{path, size, lastModified}]}`. Upload IDs and + internal revision metadata are generated automatically; no library name, + version label or description is required. - `GET /api/game-files/:id/upload`: durable per-file offsets and publication status. - `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers. @@ -243,7 +256,13 @@ retries the completion receipt from memory. A power loss in that final window can leave the controller awaiting confirmation even though removal succeeded; inspect the machine if that status persists. The controller does not guess. -## Libraries and ports +## Game files and ports + +Game Files always shows T5 and T6, each with its own upload button and latest +verified files. Successful uploads become latest in publication completion +order, not by their start date or an entered label. A newer upload still being +transferred or verified leaves the current files available. Previous uploads +appear in history, and interrupted uploads can be resumed separately. Choose the actual game folder, not its parent, a ZIP archive or the Plutonium launcher data folder. The selected folder's name is removed once; nested paths @@ -262,23 +281,26 @@ unchanged folder; relative paths, sizes and modification times must match. Keep the page open while transferring. Once publication starts, the controller hashes every complete file and publishes the directory atomically; closing the browser does not stop that verification. An interrupted controller can retry publication. -Published versions cannot be updated; -changing files requires a new version. Hosts download up to four files -concurrently with HTTP Range resumption, report progress, verify all checksums +Verified uploads are immutable; changing files means uploading another folder +for the same game. Hosts download up to four files concurrently with HTTP Range +resumption, report progress, verify all checksums and atomically promote the complete cache. Checksums are calculated while bytes arrive, avoiding another complete read of newly downloaded files. Resumed files include their saved prefix in verification. Progress reports are rate-limited so controller round trips do not stall every transferred chunk. Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled -on heartbeats, including vanished caches. Older immutable versions remain usable +on heartbeats, including vanished caches. Previous immutable uploads remain usable when their exact manifest is verified. The controller and reverse proxy must accept 8 MiB upload requests and folder manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes still enforce these limits, declared file sizes and checksums. Set a proxy body limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). -Existing published versions and cached host libraries remain usable after the -upload workflow replaces source-host imports. +Existing uploads, host caches and server references remain usable. Previously +named libraries are grouped under T5 or T6, and the most recently published +upload for each game becomes its latest files. Their immutable IDs and stored +content are preserved; no reupload is needed. Legacy source-host imports retain +their existing IDs and can complete through their original workflow. Reservations include host ID, local bind address, protocol and port. Wildcard binds conflict with concrete addresses on the same host. UDP and TCP reservations diff --git a/src/lib/game-files.ts b/src/lib/game-files.ts index 6067ff3..88e22fa 100644 --- a/src/lib/game-files.ts +++ b/src/lib/game-files.ts @@ -1,4 +1,8 @@ export type GameLibraryGame = 't5' | 't6'; +export const GAME_FILE_GAMES = [ + { id: 't5', name: 'Black Ops I (T5)' }, + { id: 't6', name: 'Black Ops II (T6)' } +] as const; export type GameLibraryState = 'importing' | 'ready' | 'failed'; export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; @@ -18,7 +22,7 @@ export interface GameLibraryVersion { } export interface GameLibrary { id: string; name: string; game: GameLibraryGame; description: string; - createdAt: string; versions: GameLibraryVersion[]; + createdAt: string; latestVersionId: string | null; versions: GameLibraryVersion[]; } export function formatGameBytes(bytes: number): string { diff --git a/src/lib/server/game-files/index.ts b/src/lib/server/game-files/index.ts index a8666b0..70792e2 100644 --- a/src/lib/server/game-files/index.ts +++ b/src/lib/server/game-files/index.ts @@ -7,6 +7,7 @@ import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hos import { ensureGameFileSchema } from './schema.js'; import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js'; import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js'; +import { GAME_FILE_GAMES } from '../../game-files.js'; import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js'; function database() { const handle = db(); ensureGameFileSchema(handle); return handle; } @@ -35,23 +36,29 @@ export function getLibraryFile(versionId: string, path: string) { .get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null; } export function newestReadyLibrary(game: string): GameLibraryVersion | null { - const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined; + const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.published_at DESC, v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined; return row ? versionFromRow(row) : null; } -/** Preparation needs only the latest published version of each library, never its full manifest. */ +/** Hosts follow the latest successful publication for each game, including legacy libraries. */ export function latestPublishedLibraries() { - return database().prepare(`SELECT v.id, l.name, l.game, v.version, v.size_bytes AS sizeBytes, v.manifest_sha256 AS manifestSha256 + const latest = database().prepare(`SELECT v.id, l.game, v.version, v.size_bytes AS sizeBytes, v.manifest_sha256 AS manifestSha256, v.published_at AS publishedAt FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id - WHERE v.state='ready' AND v.id=(SELECT newest.id FROM game_file_versions newest - WHERE newest.library_id=l.id AND newest.state='ready' ORDER BY newest.created_at DESC,newest.rowid DESC LIMIT 1) - ORDER BY l.name`).all() as { id: string; name: string; game: GameLibraryGame; version: string; sizeBytes: number; manifestSha256: string }[]; + WHERE v.state='ready' AND l.game=? + ORDER BY v.published_at DESC,v.created_at DESC,v.rowid DESC LIMIT 1`); + return GAME_FILE_GAMES.flatMap(game => { + const version = latest.get(game.id) as { id: string; game: GameLibraryGame; version: string; sizeBytes: number; manifestSha256: string; publishedAt: string | null } | undefined; + return version ? [{ ...version, name: game.name }] : []; + }); } export function listLibraries(): GameLibrary[] { reconcileLibraryOperations(); - return (database().prepare('SELECT * FROM game_file_libraries ORDER BY name').all() as Array<{ id: string; name: string; game: GameLibraryGame; description: string; created_at: string }>).map(row => ({ - id: row.id, name: row.name, game: row.game, description: row.description, createdAt: row.created_at, - versions: (database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.id=? ORDER BY v.created_at DESC, v.rowid DESC').all(row.id) as VersionRow[]).map(versionFromRow) - })); + const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id])); + return GAME_FILE_GAMES.map(game => { + const versions = (database().prepare(`SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id + WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(versionFromRow); + const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined; + return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions }; + }); } export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null { const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined; @@ -70,27 +77,29 @@ function textField(input: unknown, label: string, max: number, required = true): return input.trim(); } +/** Reuse a game's original storage parent without rewriting immutable historical versions. */ +function libraryForGame(game: GameLibraryGame, now: string): string { + const handle = database(); + const existing = handle.prepare('SELECT id FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game) as { id: string } | undefined; + if (existing) return existing.id; + const id = randomUUID(); + // The legacy name column is globally unique, so its private value must not collide with an old user label. + handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(id, `game-files:${game}:${id}`, game, '', now); + return id; +} + /** Register a folder on the controller; no host or Docker volume is needed. */ export function createLibraryUpload(input: Record): GameLibraryVersion { const game = input.game; if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); - const name = textField(input.name, 'library name', 160); - const description = textField(input.description ?? '', 'description', 4000, false); - const version = textField(input.version, 'version label', 120); const files = validateUploadPlan(input.files); const handle = database(); const id = randomUUID(); const now = new Date().toISOString(); handle.transaction(() => { - let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; - if (library && library.game !== game) throw new Error('This library name is already used for another game.'); - if (!library) { - library = { id: randomUUID(), game }; - handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now); - } - if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.'); + const libraryId = libraryForGame(game, now); // Keep legacy non-null columns for existing databases and published versions. handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume) - VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, library.id, version, description, files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id)); + VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, libraryId, id, '', files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id)); const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)'); for (const file of files) insert.run(id, file.path, file.size, file.lastModified); })(); @@ -181,22 +190,13 @@ export function createLibraryImport(input: Record): GameLibrary const hostId = textField(input.hostId, 'source host', 128); const host = getHost(hostId); if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.'); - const name = textField(input.name, 'library name', 160); - const description = textField(input.description ?? '', 'description', 4000, false); - const version = textField(input.version, 'version label', 120); const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files'; const handle = database(); - let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; - if (library && library.game !== game) throw new Error('This library name is already used for another game.'); const id = randomUUID(); const now = new Date().toISOString(); handle.transaction(() => { - if (!library) { - library = { id: randomUUID(), game }; - handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now); - } - if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.'); + const libraryId = libraryForGame(game, now); handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume) - VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume); + VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, libraryId, id, '', '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume); })(); try { const operation = queueHostOperation(hostId, 'library.import', { @@ -318,7 +318,7 @@ export function updateHostLibraryCaches(hostId: string, input: unknown, options: })(); } -export function queueLibraryDownload(hostId: string, versionId: string) { +export function queueLibraryDownload(hostId: string, versionId: string, options: { automatic?: boolean } = {}) { const version = getLibraryVersion(versionId); if (!version || version.state !== 'ready') throw new Error('Choose a published library version.'); const host = getHost(hostId); @@ -333,7 +333,8 @@ export function queueLibraryDownload(hostId: string, versionId: string) { const operation = queueHostOperation(hostId, 'library.download', { libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`, filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`, - manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes + manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes, + ...(options.automatic ? { automatic: true } : {}) }); updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256 }]); database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId); @@ -358,8 +359,7 @@ export function libraryOverview() { return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({ ...version, caches: hosts.map(host => { const cached = getHostLibraryCache(host.id, version.id); - const latest = library.versions.find(item => item.state === 'ready'); - if (cached) return { ...cached, status: cached.status === 'ready' && latest?.id !== version.id ? 'outdated' as const : cached.status }; + if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status }; return { hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0, bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null diff --git a/src/lib/server/hosts/preparation.ts b/src/lib/server/hosts/preparation.ts index 64bada1..6e77442 100644 --- a/src/lib/server/hosts/preparation.ts +++ b/src/lib/server/hosts/preparation.ts @@ -5,6 +5,7 @@ import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } f import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js'; const activeStates = ['pending', 'accepted', 'running', 'unknown']; +const supersededDownload = 'Superseded by newer game files before delivery to the host.'; type PreparationOperation = { id: string; state: HostOperationState; updated_at: string; error: string | null }; type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation }; @@ -53,8 +54,8 @@ function targets(host: Host): Target[] { const ready = matches && ['ready', 'outdated'].includes(cache!.status) && cache!.bytesDownloaded === version.sizeBytes; result.push({ versionId: version.id, requiredBytes: version.sizeBytes - (matches ? cache!.bytesDownloaded : 0) + host.minimumFreeBytes, operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id), - item: { kind: 'library', label: `${version.name} · ${version.version}`, state: ready ? 'ready' : 'waiting', - detail: ready ? 'Game files are downloaded and verified.' : 'Game files will be downloaded and verified automatically.', + item: { kind: 'library', label: version.name, state: ready ? 'ready' : 'waiting', + detail: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.', bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes } }); } const blocker = hostBlocker(host); @@ -82,7 +83,7 @@ function targets(host: Host): Target[] { export function hostPreparationStatus(host: Host): HostPreparation { const items = targets(host).map(target => target.item); if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting', - detail: 'Upload and publish a game-file library once; hosts download it automatically.' }); + detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' }); const state = items.some(item => item.state === 'blocked') ? 'blocked' : items.some(item => item.state === 'preparing') ? 'preparing' : items.some(item => item.state === 'waiting') ? 'waiting' : 'ready'; @@ -97,25 +98,44 @@ export function presentHostPreparation(host: Host): Host { return { ...host, preparation }; } +function supersedeUndeliveredDownloads(hostId: string): void { + const latest = new Set(latestPublishedLibraries().map(version => version.id)); + const pending = db().prepare(`SELECT id,json_extract(payload,'$.libraryVersionId') AS versionId + FROM ec_host_operations WHERE host_id=? AND kind='library.download' AND state='pending' + AND last_delivered_at IS NULL AND json_extract(payload,'$.automatic')=1`).all(hostId) as { id: string; versionId: string }[]; + const now = new Date().toISOString(); + for (const operation of pending) { + if (latest.has(operation.versionId)) continue; + // Delivered operations may already be writing files. Only unsubmitted + // automatic work can be replaced without consulting the agent journal. + db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(supersededDownload, now, operation.id); + db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)").run(operation.id, now, supersededDownload); + db().prepare("UPDATE host_library_caches SET status='missing',error=NULL,updated_at=? WHERE host_id=? AND version_id=? AND operation_id=? AND status NOT IN ('ready','outdated')") + .run(now, hostId, operation.versionId, operation.id); + } +} + /** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */ export function reconcileHostPreparation(hostId: string): HostPreparation { return db().transaction(() => { const host = getHost(hostId); if (!host) throw new Error('Host was not found.'); if (!hostBlocker(host)) { + supersedeUndeliveredDownloads(hostId); // Queue only one setup mutation at a time so each capacity check uses // fresh disk inventory. This also respects manually queued downloads. const active = db().prepare(`SELECT 1 FROM ec_host_operations WHERE host_id=? AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId); const finished = db().prepare(`SELECT updated_at FROM ec_host_operations WHERE host_id=? AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('completed','failed') - ORDER BY updated_at DESC LIMIT 1`).get(hostId) as { updated_at: string } | undefined; + AND (error IS NULL OR error != ?) + ORDER BY updated_at DESC LIMIT 1`).get(hostId, supersededDownload) as { updated_at: string } | undefined; if (!active && (!finished || Date.parse(host.lastHeartbeat!) > Date.parse(finished.updated_at))) { // A failed or oversized library must not prevent other libraries // from preparing. The runtime still comes first when eligible. const target = targets(host).find(target => target.item.state === 'waiting'); if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image }); - else if (target?.versionId) queueLibraryDownload(host.id, target.versionId); + else if (target?.versionId) queueLibraryDownload(host.id, target.versionId, { automatic: true }); } } return hostPreparationStatus(host); diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index 839d667..7de207c 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -4,7 +4,7 @@ import { db } from '../db/index.js'; import { config, relDestForKind, serverDir } from '../config.js'; import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js'; import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js'; -import { getLibraryVersion, requireReadyLibrary } from '../game-files/index.js'; +import { newestReadyLibrary, requireReadyLibrary } from '../game-files/index.js'; import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js'; import { getServerKey, saveServerKey } from '../fleet/server-keys.js'; import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js'; @@ -29,9 +29,11 @@ interface FilePlan { id: number; path: string; contentBase64: string | null; sha export function nativeSelection(input: Record, game: string): NativeSelection { if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.'); - if (typeof input.libraryId !== 'string' || !input.libraryId) throw new Error('Select a published game-file version.'); - const version = getLibraryVersion(input.libraryId); - if (!version || version.game !== game || version.state !== 'ready') throw new Error('Choose a published game-file version for the selected game.'); + const version = newestReadyLibrary(game); + if (!version) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`); + if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== version.id) { + throw new Error(`The current ${game.toUpperCase()} game files changed. Refresh the launch preview.`); + } const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image; if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.'); const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 }); @@ -68,7 +70,7 @@ export function previewNativeCreation(input: Record): LaunchPre .run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName); const checks: LaunchPreview['checks'] = [ { label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` }, - { label: 'Game files', state: 'pass', detail: `Immutable library version ${selection.libraryId} is ready on the selected host.` }, + { label: 'Game files', state: 'pass', detail: `The latest ${recipe.game.toUpperCase()} game files are verified on the selected host.` }, { label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' }, { label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' }, { label: 'Configuration', state: 'pass', detail: recipe.configSource } diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte index 258871b..bd01fea 100644 --- a/src/lib/ui/components/DeployCollectionDialog.svelte +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -31,7 +31,7 @@ const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0); onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0) - && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); + && (!preview.servers.length || hostReady) && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); $effect(() => { if (!dialog) return; @@ -54,7 +54,7 @@ } async function review() { - if (!hostReady) { error = 'Choose a ready host and cached game files for each game in this collection.'; return; } + if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; } const current = ++session; const previous = preview; busy = true; error = ''; keyNotice = ''; confirmRemovals = false; @@ -102,10 +102,10 @@ - {#if games.length} { preview = null; confirmRemovals = false; }} />{/if} + {#if open && games.length} { preview = null; confirmRemovals = false; }} />{/if}

{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}

{#if error}{/if} - {#if !preview}

{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}

{#if !busy}{/if} + {#if !preview}

{busy ? 'Preparing the collection…' : 'Choose a host. Its latest game files sync automatically before you review capacity and server keys.'}

{#if !busy}{/if} {:else} {#if keyNotice}

{keyNotice}

{/if} {#if expired}

This preview expired. Refresh before launch; keys remain entered for unchanged IDs.

{/if} @@ -145,7 +145,7 @@ {:else}

{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.

{/if} -
Setup and launch requirements

Each new server uses the selected host, immutable game-file version, and prepared runtime. Host readiness, cached files, runtime availability, and local port reservations are checked again before deployment.

{#each preview.entries as entry}
{/each}
+
Setup and launch requirements

Each new server uses the selected host, latest game files, and prepared runtime. Host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.

{#each preview.entries as entry}
{/each}
{/if} diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 0481950..a3a2f71 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -36,6 +36,7 @@ let wasOpen = false; let session = 0; let keyNotice = $state(''); + let selectionNotice = $state(''); let now = $state(Date.now()); let heading: HTMLHeadingElement; const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now); @@ -49,7 +50,7 @@ async function initialize() { const current = ++session; - preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; + preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = ''; blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintId = initialBlueprintId; game = initialGame; @@ -74,15 +75,20 @@ } catch (err) { if (current === session) error = (err as Error).message; } } + function selectionChanged() { + if (preview) selectionNotice = 'The host or game files changed. Review the launch again with the latest files.'; + preview = null; plutoKey = ''; keyNotice = ''; + } + async function review() { if (busy) return; if (!sourceDeploymentId && selectedBlueprint) { open = false; collectionOpen = true; return; } - if (!hostReady) { error = 'Choose a ready host, cached game-file version, and prepared runtime.'; return; } + if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; } const current = session; const previousName = preview?.name; - busy = true; error = ''; savedName = ''; keyNotice = ''; + busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = ''; try { const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }), @@ -101,7 +107,7 @@ } async function launch() { - if (!preview || !preview.ready || expired || busy) return; + if (!preview || !preview.ready || !hostReady || expired || busy) return; busy = true; error = ''; try { const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -135,6 +141,8 @@

{preview ? 'Review server launch' : 'Create server'}

  1. 01 Choose a starting point
  2. 02 Review & launch
{#if error}{/if} + {#if selectionNotice}

{selectionNotice}

{/if} + {#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}{/if} {#if !loaded}

{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}

{#if error}{/if} @@ -150,7 +158,7 @@ {#if preview.hasSavedKey}

Saved key ready

{:else}{/if}

{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} Manage saved keys in a new tab; your launch stays open here.

-
+
{#if !preview.hasSavedKey && !plutoKey.trim()}

Enter this server’s key to enable launch.

{/if}

Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.

@@ -160,13 +168,12 @@ {/if} {:else if sourceDeploymentId} -

Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.

+

Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.

{:else}
{ event.preventDefault(); void review(); }}> {#if selectedBlueprint}

{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}

{#each selectedBlueprint.entries as entry}

{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}

{/each}

Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.

{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
{:else}

Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.

{/if} - {#if !selectedBlueprint}{/if}
Know what you’re launching.

Review the current promoted setup, available lobby, and launch prerequisites before creating your server.

diff --git a/src/lib/ui/components/HostPreparation.svelte b/src/lib/ui/components/HostPreparation.svelte index 0ebd9f6..e45ab50 100644 --- a/src/lib/ui/components/HostPreparation.svelte +++ b/src/lib/ui/components/HostPreparation.svelte @@ -5,7 +5,7 @@
-

The default runtime and the latest published version of each game library are prepared automatically on active hosts. New versions leave running servers on their selected files.

+

The default runtime and the latest available T5 and T6 game files are prepared automatically on active hosts. Running servers keep their current files.

{#if host.preparation}

Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}

    @@ -21,7 +21,7 @@ {/each}
{:else}

Setup progress will appear after the host connects.

{/if} -

Upload and view game-file libraries →

+

Upload and view T5 / T6 game files →

diff --git a/src/lib/ui/native-game-files.ts b/src/lib/ui/native-game-files.ts new file mode 100644 index 0000000..500069f --- /dev/null +++ b/src/lib/ui/native-game-files.ts @@ -0,0 +1,26 @@ +import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js'; + +export type LatestGameFiles = Partial>; + +/** Use the controller's latest upload, even while that upload is still syncing to a host. */ +export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles { + const latest: LatestGameFiles = {}; + for (const library of libraries) { + const version = library.versions.find(item => item.id === library.latestVersionId && item.state === 'ready' && item.game === library.game); + if (version) latest[library.game] = version; + } + return latest; +} + +/** Return whether callers need to discard a preview that targeted different files. */ +export function selectLatestGameFiles(selection: Partial>, latest: LatestGameFiles, games: GameLibraryGame[]): boolean { + let changed = false; + for (const game of games) { + const id = latest[game]?.id; + if (selection[game] === id) continue; + if (id) selection[game] = id; + else delete selection[game]; + changed = true; + } + return changed; +} diff --git a/src/routes/api/system-status/+server.ts b/src/routes/api/system-status/+server.ts index 70e7973..f5d5a10 100644 --- a/src/routes/api/system-status/+server.ts +++ b/src/routes/api/system-status/+server.ts @@ -18,16 +18,16 @@ export async function GET({ url }: { url: URL }) { if (hosts.length || !komodoConfigured()) { const readyHosts = hosts.filter(host => host.readiness === 'ready'); const connected = hosts.filter(host => host.connected); - const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready'); + const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId)); const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId))); const runtimes = connected.filter(host => host.inventory.runtimeImages?.length); const checks: SetupCheck[] = [ { id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' }, - { id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' }, - { id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' }, + { id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' }, + { id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' }, { id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' }, { id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' }, - { id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' } + { id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' } ]; return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } }); } diff --git a/src/routes/game-files/+page.svelte b/src/routes/game-files/+page.svelte index 3b56cf5..acbe2ab 100644 --- a/src/routes/game-files/+page.svelte +++ b/src/routes/game-files/+page.svelte @@ -10,7 +10,6 @@ import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; - import EmptyState from '$ui/components/primitives/EmptyState.svelte'; type OverviewLibrary = Omit & { versions: Omit[] }; interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean } @@ -19,8 +18,7 @@ let libraries = $state([]); let hosts = $state([]); let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false); let showUpload = $state(false); let game = $state<'t5' | 't6'>('t5'); - let name = $state(''); let version = $state(''); let description = $state(''); - let filterGame = $state('all'); let targets = $state>({}); + let targets = $state>({}); let folder = $state.raw(null); let uploadStatus = $state.raw(null); let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state(''); @@ -30,12 +28,13 @@ let activeRequest: XMLHttpRequest | null = null; let disposed = false; const canManage = $derived($page.data.user?.role === 'admin'); const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled')); - const matching = $derived(libraries.filter(library => filterGame === 'all' || library.game === filterGame)); const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0); const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0); const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes)); const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading')); const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload'; + const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'; + const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString(); function automaticDownloadDetail(host: Host | undefined): string { if (host?.decommission) return 'Automatic setup is paused during host deletion.'; if (!host?.enrolled) return 'Waiting for host enrollment.'; @@ -61,7 +60,7 @@ busy = true; error = ''; notice = ''; try { await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) })); - notice = 'Library download queued. Progress will update below.'; await load(); + notice = 'Game files queued for download. Progress will update below.'; await load(); } catch (cause) { error = (cause as Error).message; } finally { busy = false; } } function resetUpload() { @@ -69,10 +68,9 @@ acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false; if (folderInput) folderInput.value = ''; } - async function newUpload(library?: OverviewLibrary) { - resetUpload(); showUpload = true; game = library?.game ?? 't5'; name = library?.name ?? ''; - description = library?.description ?? ''; version = ''; - await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' }); + async function newUpload(library: OverviewLibrary) { + resetUpload(); showUpload = true; game = library.game; + await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' }); } function chooseFolder(event: Event) { uploadError = ''; folder = null; @@ -82,14 +80,13 @@ const selected = selectGameFolder(Array.from(input.files)); if (uploadStatus) checkSelection(selected, uploadStatus); folder = selected; - if (!name && !uploadId) name = game === 't5' ? 'Black Ops I game files' : 'Black Ops II game files'; } catch (cause) { uploadError = (cause as Error).message; input.value = ''; } } function checkSelection(selected: SelectedGameFolder, status: UploadStatus) { const originals = new Map(status.files.map(file => [file.path, file])); if (selected.files.length !== originals.size || selected.files.some(({ path, file }) => { const original = originals.get(path); return !original || original.size !== file.size || original.lastModified !== file.lastModified; - })) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Upload changed game files as a new version.'); + })) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Start a new upload for changed game files.'); } function applyStatus(status: UploadStatus) { uploadStatus = status; @@ -109,12 +106,12 @@ } if (disposed) return; if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.'); - phase = 'published'; folder = null; notice = `${name || 'Game files'} ${version} published. Active hosts will download and verify this version automatically.`; + phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`; if (folderInput) folderInput.value = ''; await load(); } async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) { - resetUpload(); showUpload = true; uploadId = item.id; game = library.game; name = library.name; version = item.version; description = item.description; + resetUpload(); showUpload = true; uploadId = item.id; game = library.game; uploadBusy = true; try { const status = await getStatus(); @@ -122,7 +119,7 @@ else { phase = 'paused'; uploadError = status.error ?? ''; } } catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; } finally { uploadBusy = false; } - await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' }); + await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' }); } function pauseUpload() { pauseRequested = true; activeRequest?.abort(); } async function sendChunk(path: string, file: File, offset: number, length: number): Promise { @@ -158,7 +155,7 @@ uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing'; try { if (!uploadId) { - const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, name, version, description, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) })); + const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) })); uploadId = result.version.id; } const status = await getStatus(); @@ -200,90 +197,119 @@ Game Files | Electric Cherry
- - {#snippet actions()}{#if canManage}{/if}{/snippet} - + {#if error}{/if} {#if notice}

{notice}

{/if} - {#if !canManage}

Administrators publish game files. Active hosts prepare verified copies automatically.

{/if} + {#if !canManage}

Administrators upload game files. Active hosts prepare verified copies automatically.

{/if} {#if canManage && showUpload} -
-

{uploadId ? phase === 'published' ? 'Version published' : 'Continue folder upload' : 'Upload a game folder'}

-

Select your complete T5 or T6 game folder, including its installed DLC. The files upload directly to this controller; a game host is only needed when you are ready to run servers.

+
+

{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}

+

Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.

{#if uploadError}{/if}
-
- - - - -
{#if phase !== 'verifying' && phase !== 'published'}

{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}

{/if} {#if folder}
{folder.name}{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}
Preview folder paths
    {#each folder.files.slice(0, 8) as file}
  • {file.path}
  • {/each}
{#if folder.files.length > 8}

And {(folder.files.length - 8).toLocaleString()} more files.

{/if}
{/if} {#if phase !== 'idle'}
-
{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying and publishing…' : phase === 'published' ? 'Published · host downloads are automatic' : 'Upload paused'}{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files
+
{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying files…' : phase === 'published' ? 'Ready · host downloads are automatic' : 'Upload paused'}{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files

{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring

{#if currentPath}

{currentPath}

{/if} - {#if phase === 'verifying'}

The controller checks every file and publishes this version when verification finishes. Verification continues if you leave this page.

{:else if phase === 'paused'}

Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.

{/if} + {#if phase === 'verifying'}

The controller checks every file before making this upload available to hosts. Verification continues if you leave this page.

{:else if phase === 'paused'}

Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.

{/if}
{/if}
{#if transferring} - {:else if phase !== 'verifying' && phase !== 'published'}{/if} + {:else if phase !== 'verifying' && phase !== 'published'}{/if} {#if phase === 'idle' || phase === 'uploading'}Keep this page open while files transfer. Interrupted uploads can be resumed below.{/if}
{/if} -
{libraries.length} libraries · Game folders and runtime images are versioned separately.
- {#each matching as library (library.id)} - {@const latestPublished = library.versions.find(item => item.state === 'ready')} + {#if !loaded}

Loading game files…

{/if} + {#each libraries as library (library.game)} + {@const latest = library.versions.find(item => item.id === library.latestVersionId)} + {@const unfinished = library.versions.filter(item => item.state !== 'ready')} + {@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
-

{library.name}

{library.game.toUpperCase()} · {library.description}

{#if canManage}{/if}
- {#each library.versions as item (item.id)} - {@const browserUpload = !item.sourceHostId} -
-

{item.version}

{item.state === 'ready' ? 'Published' : item.state === 'importing' ? browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing' : 'Failed'}
-

{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}

- {#if item.error}

{item.error}

{/if} -
Version details
Immutable version ID
{item.id}
Manifest SHA256
{item.manifestSha256 ?? 'Available after verification'}
Uploaded from
{browserUpload ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}
{#if item.state === 'ready'}View complete file manifest{/if}
- {#if item.state === 'ready'} - {#if item.id === latestPublished?.id}

This is the latest published version. Active hosts download and verify it automatically, including hosts enrolled later. Running servers keep their selected version.

{:else}

This older version remains available to servers pinned to it. Use the advanced download control to prepare it on another host.

{/if} - {#if !hosts.length}

Files are published. Add a host to prepare them automatically.

{/if} -
- {#each item.caches as cache} - {@const cacheHost = hosts.find(host => host.id === cache.hostId)} - {@const awaitingAutomaticDownload = item.id === latestPublished?.id && cache.status === 'missing'} - - - - - - - {:else}{/each} -
HostCache stateProgressDetail
{hostName(cache.hostId)}{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}{#if cache.status === 'downloading' || cache.status === 'verifying'}{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} View host setup{/if}
No host copies yet.
- {#if canManage}
Advanced: download or verify this version

Prepare an older version or retry a download now. Latest versions are prepared automatically.

{/if} - {:else if browserUpload}

Resume with the original unchanged folder. Once published, this version is prepared automatically on active hosts.

{#if canManage}
{/if} - {:else if item.state === 'importing'}

The existing host import continues independently. It is reconciled when the host reconnects.

{/if} -
- {/each} +
+

{gameName(library.game)}

{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}

+ {#if canManage}{/if} +
+ {#if latest} +

{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}

Available
+

Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.

+ {@render hostCopies(latest, true)} + {@render uploadDetails(latest)} + {:else} +

Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.

+ {/if} + {#if unfinished.length} +
+

Unfinished uploads

+ {#each unfinished as item (item.id)} + {@const browserUpload = !item.sourceHostId} +
+

Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files

{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}
+ {#if item.error}

{item.error}

{/if} + {#if browserUpload} +

Resume with the original unchanged folder. The latest files stay available while this upload completes.

+ {#if canManage}
{/if} + {:else if item.state === 'importing'}

The existing host import continues independently. It is reconciled when the host reconnects.

{/if} +
+ {/each} +
+ {/if} + {#if history.length} +
+ Previous uploads ({history.length}) +

Previous files remain available for servers that already use them.

+ {#each history as item (item.id)} +
+

Uploaded {uploadDate(item)}

+

{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files

+ {@render uploadDetails(item)} +
Host copies{@render hostCopies(item, false)}
+
+ {/each} +
+ {/if}
- {:else}{#if loaded}{:else}

Loading game libraries…

{/if}{/each} + {/each}
+{#snippet uploadDetails(item: OverviewLibrary['versions'][number])} +
Upload details
Upload ID
{item.id}
Manifest SHA256
{item.manifestSha256 ?? 'Available after verification'}
Uploaded from
{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}
View complete file manifest
+{/snippet} + +{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)} + {#if !hosts.length}

Files are ready. Add a host to prepare them automatically.

{/if} +
+ {#each item.caches as cache} + {@const cacheHost = hosts.find(host => host.id === cache.hostId)} + {@const awaitingAutomaticDownload = latest && cache.status === 'missing'} + + + + + + + {:else}{/each} +
HostFile statusProgressDetail
{hostName(cache.hostId)}{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}{#if cache.status === 'downloading' || cache.status === 'verifying'}{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · previous upload' : ''}{:else}—{/if}{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} View host setup{/if}
No host copies yet.
+ {#if canManage}
Advanced: download or verify these files

Retry a download or prepare a previous upload on a host. The latest files are downloaded automatically.

{/if} +{/snippet} + diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index 658f64d..d92218b 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -41,7 +41,7 @@ {#if loading}

Loading host…

{:else if host} {#if host.decommission}

Host cleanup

{hostDeletionMessage(host)}

{#if host.decommission.error}

{host.decommission.error}

{/if}

Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.

{#if host.decommission.stage === 'cleaned'}

The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.

{/if}
{/if}

{host.connectionState} · {hostReadinessLabel(host.readiness)}

Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}
- {#if host.readinessReasons.length}
    {#each host.readinessReasons as reason}
  • {reason}
  • {/each}
{:else}

Connected and ready. Each deployment also checks its game library, runtime, and ports.

{/if} + {#if host.readinessReasons.length}
    {#each host.readinessReasons as reason}
  • {reason}
  • {/each}
{:else}

Connected and ready. Each deployment also checks its latest game files, runtime, and ports.

{/if} {#if canManage && !cleaning}
{/if}

Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.

diff --git a/tests/unit/game-file-browser-upload.test.ts b/tests/unit/game-file-browser-upload.test.ts index 9bcc2bf..b89013e 100644 --- a/tests/unit/game-file-browser-upload.test.ts +++ b/tests/unit/game-file-browser-upload.test.ts @@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({ handle: null as Database.Database | null })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); import { browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache, - getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload, + getLibraryVersion, latestPublishedLibraries, listLibraries, newestReadyLibrary, publishBrowserUpload, queueLibraryDownload, requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile } from '../../src/lib/server/game-files/index.js'; import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js'; @@ -24,8 +24,7 @@ const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('he const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified }); const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) => uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body); -const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') => - createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files }); +const begin = (game: 't5' | 't6' = 't6', files = [file()]) => createLibraryUpload({ game, files }); beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-')); @@ -37,6 +36,7 @@ afterEach(async () => { state.handle?.close(); state.handle = null; await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); + vi.useRealTimers(); }); function host() { @@ -65,7 +65,7 @@ describe('browser folder uploads', () => { expect(ready.manifestSha256).toBe(manifestChecksum(ready.files)); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' }); - expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc'); + expect(listLibraries().find(library => library.game === game)?.versions[0]).not.toHaveProperty('dlc'); expect(listHosts()).toEqual([]); }); @@ -122,7 +122,7 @@ describe('browser folder uploads', () => { const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize; expect(chunkSize).toBeGreaterThan(0); const large = Buffer.alloc(chunkSize + 17, 0x41); - const initial = begin('t6', [file('main/archive.iwd', large)], 'large'); + const initial = begin('t6', [file('main/archive.iwd', large)]); const first = large.subarray(0, chunkSize); await upload(initial.id, 'main/archive.iwd', first); expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false }); @@ -146,17 +146,54 @@ describe('browser folder uploads', () => { expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0); }); - it('keeps ready bytes and version metadata immutable and requires a new version label', async () => { + it('keeps published files immutable while accepting repeated uploads without labels', async () => { const initial = begin(); await upload(initial.id, file().path, bytes); const ready = await publishBrowserUpload(initial.id); await expect(upload(initial.id, file().path, bytes)).rejects.toThrow(); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); - expect(() => begin()).toThrow(/already exists/); expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256); - expect(begin('t6', [file()], '2').id).not.toBe(ready.id); + const next = begin(); + expect(next.id).not.toBe(ready.id); + expect(next.version).not.toBe(ready.version); + expect(next.libraryId).toBe(ready.libraryId); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); }); + it('ignores legacy names, descriptions, and labels when accepting new game files', () => { + const legacyFields = { name: 'same name', version: 'same version', description: 'a library description' }; + const first = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields }); + const next = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields }); + const otherGame = createLibraryUpload({ game: 't6', files: [file()], ...legacyFields }); + expect(first.description).toBe(''); + expect(first.version).not.toBe(legacyFields.version); + expect(next.version).not.toBe(first.version); + expect(next.libraryId).toBe(first.libraryId); + expect(otherGame.libraryId).not.toBe(first.libraryId); + expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']); + }); + + it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const slow = begin(); + vi.setSystemTime(new Date('2026-01-02T00:00:00Z')); + const fast = begin(); + await upload(fast.id, file().path, bytes); + vi.setSystemTime(new Date('2026-01-03T00:00:00Z')); + await publishBrowserUpload(fast.id); + expect(newestReadyLibrary('t6')?.id).toBe(fast.id); + await upload(slow.id, file().path, bytes); + vi.setSystemTime(new Date('2026-01-04T00:00:00Z')); + await publishBrowserUpload(slow.id); + const failed = begin(); + await requestBrowserPublication(failed.id); + await vi.waitFor(() => expect(getLibraryVersion(failed.id)?.state).toBe('failed')); + begin(); + expect(newestReadyLibrary('t6')?.id).toBe(slow.id); + expect(latestPublishedLibraries().map(version => version.id)).toEqual([slow.id]); + expect(listLibraries().find(library => library.game === 't6')?.latestVersionId).toBe(slow.id); + }); + it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => { const initial = begin(); await upload(initial.id, file().path, bytes); const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }]; @@ -220,7 +257,7 @@ describe('browser folder uploads', () => { expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed'); updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]); expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id); - const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes); + const replacement = begin('t5'); await upload(replacement.id, file().path, bytes); await publishBrowserUpload(replacement.id); expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256); }); diff --git a/tests/unit/game-file-library.test.ts b/tests/unit/game-file-library.test.ts index 2fdf49c..9b51cfc 100644 --- a/tests/unit/game-file-library.test.ts +++ b/tests/unit/game-file-library.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const state = vi.hoisted(() => ({ handle: null as Database.Database | null })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js'; -import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js'; +import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js'; import { serveGameFile } from '../../src/lib/server/game-files/http.js'; import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js'; @@ -22,12 +22,87 @@ beforeEach(async () => { credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential; }); afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); }); -function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); } +function begin() { return createLibraryImport({ hostId, game: 't6' }); } async function published() { const version = begin(); setImportManifest(hostId, version.id, [file]); await uploadImportFile(hostId, version.id, file.path, new Response(data).body); return publishImport(hostId, version.id); } + +function legacyLibrary(id: string, game: 't5' | 't6') { + state.handle!.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)') + .run(id, `User named ${id}`, game, 'Original description', '2025-01-01T00:00:00.000Z'); +} +function legacyVersion(id: string, libraryId: string, createdAt: string, publishedAt: string) { + state.handle!.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume,manifest_sha256) + VALUES(?,?,?,'Legacy version','[]','ready',?,?,?,'','','checksum')`) + .run(id, libraryId, `User label ${id}`, createdAt, publishedAt, join(directory, id)); +} + +describe('fixed game file slots', () => { + it('always lists exactly T5 and T6, including before any files are uploaded', () => { + expect(listLibraries()).toEqual([ + { id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] }, + { id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] } + ]); + expect(latestPublishedLibraries()).toEqual([]); + expect(newestReadyLibrary('t5')).toBeNull(); + expect(newestReadyLibrary('t6')).toBeNull(); + }); + + it('unifies old named libraries by game without rewriting published versions or their host references', () => { + listLibraries(); + legacyLibrary('first-t6-library', 't6'); + legacyLibrary('other-t6-library', 't6'); + legacyLibrary('t5-library', 't5'); + legacyVersion('old-t6', 'first-t6-library', '2025-01-03', '2025-01-04'); + legacyVersion('new-t6', 'other-t6-library', '2025-01-01', '2025-01-05'); + legacyVersion('t5-files', 't5-library', '2025-01-01', '2025-01-02'); + updateHostLibraryCaches(hostId, [ + { libraryVersionId: 'old-t6', status: 'ready', manifestSha256: 'checksum' }, + { libraryVersionId: 'new-t6', status: 'ready', manifestSha256: 'checksum' } + ]); + const before = getLibraryVersion('old-t6'); + const slots = listLibraries(); + expect(slots.map(library => library.id)).toEqual(['t5', 't6']); + expect(slots[1].versions.map(version => version.id)).toEqual(['old-t6', 'new-t6']); + expect(slots.map(library => library.latestVersionId)).toEqual(['t5-files', 'new-t6']); + expect(latestPublishedLibraries().map(version => version.id)).toEqual(['t5-files', 'new-t6']); + expect(newestReadyLibrary('t6')?.id).toBe('new-t6'); + const overview = libraryOverview().libraries[1]; + expect(overview.versions.find(version => version.id === 'old-t6')?.caches[0].status).toBe('outdated'); + expect(overview.versions.find(version => version.id === 'new-t6')?.caches[0].status).toBe('ready'); + expect(getLibraryVersion('old-t6')).toEqual(before); + expect(requireReadyLibrary(hostId, 'old-t6')).toMatchObject({ + libraryId: 'first-t6-library', version: 'User label old-t6', storageLocation: join(directory, 'old-t6') + }); + expect(begin().libraryId).toBe('first-t6-library'); + expect(listLibraries()[1].latestVersionId).toBe('new-t6'); + }); + + it('breaks publication-time ties by creation time and then insertion order across old libraries', () => { + listLibraries(); + legacyLibrary('first-library', 't6'); + legacyLibrary('second-library', 't6'); + legacyVersion('first', 'first-library', '2025-01-01', '2025-01-05'); + legacyVersion('second', 'second-library', '2025-01-02', '2025-01-05'); + legacyVersion('third', 'first-library', '2025-01-02', '2025-01-05'); + legacyVersion('later-created', 'second-library', '2025-01-04', '2025-01-04'); + expect(newestReadyLibrary('t6')?.id).toBe('third'); + expect(latestPublishedLibraries().map(version => version.id)).toEqual(['third']); + expect(listLibraries()[1].latestVersionId).toBe('third'); + }); + + it('ignores user-supplied library metadata when importing from hosts', () => { + const first = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' }); + const next = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' }); + expect(first.description).toBe(''); + expect(first.version).not.toBe('1'); + expect(next.version).not.toBe(first.version); + expect(first.libraryId).toBe(next.libraryId); + }); +}); + describe('game-file import lifecycle', () => { it('publishes verified filesystem content and permanently pins manifest metadata', async () => { const initial = begin(); @@ -42,8 +117,10 @@ describe('game-file import lifecycle', () => { expect((await publishImport(hostId, ready.id)).id).toBe(ready.id); await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); - expect(() => begin()).toThrow(/already exists/); - expect(begin('2').id).not.toBe(ready.id); + const next = begin(); + expect(next.id).not.toBe(ready.id); + expect(next.version).not.toBe(ready.version); + expect(next.libraryId).toBe(ready.libraryId); }); it('restricts an import to its source host and rejects manifest mutation', () => { const version = begin(); @@ -55,7 +132,7 @@ describe('game-file import lifecycle', () => { it('surfaces durable agent failures while preserving historical versions', () => { const version = begin(); reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' }); - expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' }); + expect(listLibraries().find(library => library.game === 't6')?.versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' }); }); it('returns a pollable publication status while large files are verified asynchronously', async () => { const version = begin(); setImportManifest(hostId, version.id, [file]); diff --git a/tests/unit/host-preparation.test.ts b/tests/unit/host-preparation.test.ts index 15cec21..58e0ee2 100644 --- a/tests/unit/host-preparation.test.ts +++ b/tests/unit/host-preparation.test.ts @@ -50,10 +50,10 @@ function operations(id: string) { return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind)); } -async function publish(name = 'T5 files', version = '1', game: 't5' | 't6' = 't5', bytes = content) { +async function publish(game: 't5' | 't6' = 't5', bytes = content) { vi.setSystemTime(Date.now() + 1); const upload = libraries.createLibraryUpload({ - name, version, game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }] + game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }] }); await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', { offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') @@ -177,17 +177,16 @@ describe('automatic host preparation', () => { expect(operations(id)).toHaveLength(2); }); - it('downloads the newest published version of every library, including separate libraries for the same game', async () => { - const old = await publish('T5 files', 'old'); - const latest = await publish('T5 files', 'new'); - const alternate = await publish('T5 alternate'); - const t6 = await publish('T6 files', '1', 't6'); + it('always targets the latest verified T5 and T6 upload while newer uploads remain unfinished', async () => { + const old = await publish(); + const latest = await publish(); + const t6 = await publish('t6'); libraries.createLibraryUpload({ - name: 'T5 files', version: 'unfinished', game: 't5', + game: 't5', files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }] }); const id = enroll({ runtimeImages: [runtime] }); - const expected = new Map([latest, alternate, t6].map(version => [version.id, version])); + const expected = new Map([latest, t6].map(version => [version.id, version])); const downloaded: string[] = []; for (let index = 0; index < expected.size; index++) { preparation.reconcileHostPreparation(id); @@ -218,7 +217,7 @@ describe('automatic host preparation', () => { verified(id, old); heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] }); expect(preparation.reconcileHostPreparation(id).state).toBe('ready'); - const latest = await publish('T5 files', '2'); + const latest = await publish(); preparation.reconcileHostPreparation(id); expect(operations(id)).toHaveLength(1); expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id); @@ -226,6 +225,41 @@ describe('automatic host preparation', () => { expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready'); }); + it('replaces an undelivered automatic download when newer files finish uploading', async () => { + const old = await publish(); + const id = enroll({ runtimeImages: [runtime] }); + preparation.reconcileHostPreparation(id); + const first = operations(id)[0]; + expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true }); + // The next heartbeat may discover a completed cache while this queued + // retry has still never been delivered. Keep that verified evidence. + verified(id, old); + const latest = await publish(); + preparation.reconcileHostPreparation(id); + expect(hosts.getHostOperation(first.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('Superseded') }); + expect(operations(id).filter(operation => operation.state === 'pending')).toMatchObject([ + { payload: { libraryVersionId: latest.id, automatic: true } } + ]); + expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id); + }); + + it('finishes a delivered download before following a newer upload under a new operation ID', async () => { + const old = await publish(); + const id = enroll({ runtimeImages: [runtime] }); + preparation.reconcileHostPreparation(id); + const [first] = hosts.pendingHostOperations(id); + const latest = await publish(); + preparation.reconcileHostPreparation(id); + expect(operations(id)).toHaveLength(1); + expect(hosts.getHostOperation(first.id)?.state).toBe('pending'); + verified(id, old); + hosts.reportHostOperation(id, first.id, { state: 'completed' }); + heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] }); + preparation.reconcileHostPreparation(id); + expect(operations(id)[0]).toMatchObject({ state: 'pending', payload: { libraryVersionId: latest.id } }); + expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id); + }); + it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => { const id = enroll(); hosts.updateHost(id, { mode }); @@ -305,15 +339,15 @@ describe('automatic host preparation', () => { expect(operations(id)).toHaveLength(1); }); - it('lets other libraries prepare when an earlier library cannot fit', async () => { - const oversized = await publish('A oversized'); - const small = await publish('B small', '1', 't6', 'small'); + it('prepares T6 when the latest T5 upload cannot fit', async () => { + const oversized = await publish(); + const small = await publish('t6', 'small'); const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 }); hosts.updateHost(id, { minimumFreeBytes: 1_000 }); const status = preparation.reconcileHostPreparation(id); expect(status.items).toEqual(expect.arrayContaining([ - expect.objectContaining({ label: 'A oversized · 1', state: 'blocked' }), - expect.objectContaining({ label: 'B small · 1', state: 'preparing' }) + expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }), + expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' }) ])); expect(operations(id)).toHaveLength(1); expect(operations(id)[0].payload.libraryVersionId).toBe(small.id); diff --git a/tests/unit/native-game-files-selection.test.ts b/tests/unit/native-game-files-selection.test.ts new file mode 100644 index 0000000..b1b86e0 --- /dev/null +++ b/tests/unit/native-game-files-selection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../../src/lib/game-files.js'; +import { latestGameFiles, selectLatestGameFiles } from '../../src/lib/ui/native-game-files.js'; + +function upload(id: string, game: GameLibraryGame = 't5', state: GameLibraryVersion['state'] = 'ready'): GameLibraryVersion { + return { + id, libraryId: game, game, version: id, description: '', state, + sizeBytes: 10, fileCount: 1, manifestSha256: 'manifest', files: [], + createdAt: '2026-10-04T12:00:00Z', publishedAt: state === 'ready' ? '2026-10-04T12:05:00Z' : null, + storageLocation: '', sourceHostId: null, sourceVolume: null, error: null, operationId: null, caches: [] + }; +} + +function slot(game: GameLibraryGame, latestVersionId: string | null, versions: GameLibraryVersion[]): GameLibrary { + return { id: game, name: game.toUpperCase(), game, description: '', createdAt: '', latestVersionId, versions }; +} + +describe('automatic game file selection for new launches', () => { + it('follows the controller latest upload even when older files are already cached', () => { + const old = upload('old'); + old.caches = [{ hostId: 'host', libraryVersionId: old.id, status: 'ready', bytesDownloaded: 10, + bytesTotal: 10, manifestSha256: 'manifest', error: null, updatedAt: '', operationId: null }]; + const current = upload('latest'); + // The API can retain older uploads and unfinished uploads in its history. + const libraries = [slot('t5', current.id, [upload('unfinished', 't5', 'importing'), old, current])]; + const selected = { t5: old.id }; + const latest = latestGameFiles(libraries); + + expect(latest.t5).toBe(current); + expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true); + expect(selected.t5).toBe(current.id); + expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(false); + }); + + it('updates both games and removes a stale selection when a game has no available upload', () => { + const latest = latestGameFiles([slot('t5', null, [upload('unavailable')]), slot('t6', 'new-t6', [upload('new-t6', 't6')])]); + const selected: Partial> = { t5: 'old-t5', t6: 'old-t6' }; + + expect(selectLatestGameFiles(selected, latest, ['t5', 't6'])).toBe(true); + expect(selected).toEqual({ t6: 'new-t6' }); + }); + + it('never selects an unfinished or missing latest upload from history', () => { + expect(latestGameFiles([slot('t5', 'pending', [upload('old'), upload('pending', 't5', 'importing')])])).toEqual({}); + expect(latestGameFiles([slot('t6', 'missing', [upload('old', 't6')])])).toEqual({}); + }); + + it('only changes games needed by the current launch', () => { + const selected = { t5: 'old-t5', t6: 'keep-t6' }; + const latest = latestGameFiles([slot('t5', 'new-t5', [upload('new-t5')])]); + + expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true); + expect(selected).toEqual({ t5: 'new-t5', t6: 'keep-t6' }); + }); +}); diff --git a/tests/unit/native-provisioning.test.ts b/tests/unit/native-provisioning.test.ts index 2396d47..e28ec2b 100644 --- a/tests/unit/native-provisioning.test.ts +++ b/tests/unit/native-provisioning.test.ts @@ -33,7 +33,7 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); async function prepareLibrary(game: 't5' | 't6' = 't5') { const libs = await import('../../src/lib/server/game-files/index.js'); - const version = libs.createLibraryImport({ hostId, game, name: game, version: '1' }); + const version = libs.createLibraryImport({ hostId, game }); const bytes = Buffer.from('verified base game'); const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; libs.setImportManifest(hostId, version.id, [file]); @@ -73,6 +73,39 @@ async function detail(id: string) { } describe('native instance provisioning', () => { + it('selects the latest game files automatically and rejects stale explicit selections', async () => { + const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js'); + expect(() => nativeSelection({ hostId, image }, 't5')).toThrow('Upload T5 game files'); + const old = await prepareLibrary('t5'); + expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(old.id); + const latest = await prepareLibrary('t5'); + expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(latest.id); + expect(() => nativeSelection({ hostId, image, libraryId: old.id }, 't5')).toThrow('Refresh the launch preview'); + }); + + it('rejects a launch preview made before newer game files were published without reserving a server', async () => { + await prepareLibrary('t5'); + const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); + const preview = previewNativeCreation({ hostId, image, game: 't5', mapSlug: 'theater' }); + await prepareLibrary('t5'); + const result = queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', previewId: preview.id, plutoKey: 'new-key' }); + expect(result).toMatchObject({ ok: false, message: expect.stringContaining('Refresh the launch preview') }); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 0 }); + expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 0 }); + }); + + it('keeps a queued server pinned to its accepted files when newer game files arrive', async () => { + const job = await queue(); + const acceptedId = libraryId; + const latest = await prepareLibrary('t5'); + expect(latest.id).not.toBe(acceptedId); + await advance(); + expect((await operationFor(job.deploymentId))?.payload.libraryId).toBe(acceptedId); + const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); + expect(nativeInstance(job.name)?.library_id).toBe(acceptedId); + }); + it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => { const job = await queue(game); await advance(); const operation = await operationFor(job.deploymentId);