Simplify game files to automatic T5 and T6 uploads
This commit is contained in:
@@ -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