Simplify game files to automatic T5 and T6 uploads
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>): 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 }
|
||||
|
||||
@@ -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 @@
|
||||
<option value="exact">Match collection exactly</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
||||
{#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
||||
<p class="muted">{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.'}</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Choose a host. Its latest game files sync automatically before you review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{:else}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||||
@@ -145,7 +145,7 @@
|
||||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
||||
</form>
|
||||
{:else}<p class="muted">{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.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted 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.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted 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.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -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 @@
|
||||
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
|
||||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||||
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
|
||||
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
|
||||
{#if !loaded}
|
||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
@@ -150,7 +158,7 @@
|
||||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||||
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
|
||||
<p class="muted">{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.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
|
||||
</form>
|
||||
@@ -160,13 +168,12 @@
|
||||
{/if}
|
||||
</details>
|
||||
{:else if sourceDeploymentId}
|
||||
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network /><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
|
||||
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
|
||||
{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void review(); }}>
|
||||
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
|
||||
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
||||
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
|
||||
{#if !selectedBlueprint}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network />{/if}
|
||||
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div class="preparation" aria-live="polite">
|
||||
<p>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.</p>
|
||||
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically on active hosts. Running servers keep their current files.</p>
|
||||
{#if host.preparation}
|
||||
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
|
||||
<ul>
|
||||
@@ -21,7 +21,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>Setup progress will appear after the host connects.</p>{/if}
|
||||
<p><a href="/game-files">Upload and view game-file libraries →</a></p>
|
||||
<p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,38 +5,33 @@
|
||||
import type { GameLibrary } from '$lib/game-files.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { latestGameFiles, selectLatestGameFiles } from '$ui/native-game-files.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
|
||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
|
||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||
let defaultRuntimeImage = $state('');
|
||||
let runtimeSelection: NativeDeploymentSelection | null = null;
|
||||
const host = $derived(hosts.find(item => item.id === selection.hostId));
|
||||
// Keep each library's server ordering, including its tie breaker for versions
|
||||
// created together. Publication completion time does not choose auto setup.
|
||||
const versions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').map(version => ({ ...version, name: library.name }))).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
||||
const latestVersions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').slice(0, 1)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
||||
const latest = $derived(latestGameFiles(libraries));
|
||||
const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image));
|
||||
function cacheReady(versionId: string | undefined) { return !!versionId && versions.some(version => version.id === versionId && version.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status))); }
|
||||
$effect(() => { ready = !loading && !error && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => cacheReady(selection.libraryIds[game])); });
|
||||
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status)); }
|
||||
$effect(() => { ready = !loading && !error && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => selection.libraryIds[game] === latest[game]?.id && cacheReady(game)); });
|
||||
$effect(() => {
|
||||
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
|
||||
if (!selection.image) selection.image = defaultRuntimeImage;
|
||||
runtimeSelection = selection;
|
||||
});
|
||||
$effect(() => {
|
||||
if (!host || disabled) return;
|
||||
let changed = false;
|
||||
for (const game of games) {
|
||||
if (selection.libraryIds[game]) continue;
|
||||
const latest = latestVersions.find(version => version.game === game);
|
||||
if (latest) { selection.libraryIds[game] = latest.id; changed = true; }
|
||||
}
|
||||
if (changed) onchange?.();
|
||||
// Keep an in-flight preview/launch request stable. Reconcile as soon as
|
||||
// it finishes, so a newly uploaded folder invalidates its old preview.
|
||||
if (loading || error || disabled) return;
|
||||
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
|
||||
});
|
||||
async function load() {
|
||||
try {
|
||||
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
|
||||
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game libraries.');
|
||||
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
|
||||
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
|
||||
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
||||
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
||||
@@ -47,18 +42,29 @@
|
||||
<section class="selection" aria-label="Deployment host and game files">
|
||||
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and libraries…</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
||||
<label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.mode !== 'active' || !!candidate.decommission || !candidate.credentialActive}>{candidate.name} · {candidate.connectionState} · {candidate.preparation?.state === 'preparing' ? 'Preparing automatically' : hostReadinessLabel(candidate.readiness)}</option>{/each}</select></label>
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and published game files will be prepared automatically.</p>{/if}
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
|
||||
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
|
||||
{#if host?.preparation && host.preparation.state !== 'ready'}<p class:error={host.preparation.state === 'blocked'}>{host.preparation.items.find(item => item.state === 'blocked' || item.state === 'preparing' || item.state === 'waiting')?.detail} <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View automatic setup</a></p>{/if}
|
||||
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Awaiting host copy'}</option>{/each}</select></label>{#if host && selection.libraryIds[game] && !cacheReady(selection.libraryIds[game])}<p>The selected files must finish preparation before launch. The latest published versions download automatically; older versions can be prepared from <a href="/game-files" target="_blank" rel="noopener">Game Files</a>.</p>{/if}{/each}
|
||||
<p><a href="/game-files" target="_blank" rel="noopener">Publish a game folder</a> to make it available automatically on hosts. Each launch stays pinned to its selected version. Setup progress refreshes automatically.</p>
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
|
||||
<p role="status">{!host ? 'Choose a host to check its files.' : cacheReady(game) ? 'Latest files ready on this host.' : 'Waiting for this host to sync the latest files automatically.'}</p>
|
||||
{:else}
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}{/if}
|
||||
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must be prepared from the host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
|
||||
{#if host && !imageReady}<p>Waiting for runtime preparation. <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View host setup</a>.</p>{/if}
|
||||
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
|
||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
|
||||
|
||||
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
|
||||
|
||||
/** 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<Record<GameLibraryGame, string>>, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user