Files
electric-cherry/src/routes/betas/new/+page.svelte
T

1231 lines
52 KiB
Svelte

<script lang="ts">
import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
import {
betaDraftKey,
readBetaDraft,
compatibleBetaServers,
betaScopeMatches,
betaCandidateServers,
betaApplicableGames,
betaInstanceCounts,
type BetaDraft
} from '$lib/beta-draft.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
import { fleetIdentity, freshServer } from '$lib/fleet-overview.js';
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
import { serverTimeNow } from '$ui/stores/servers.js';
import { confirmAction } from '$ui/confirm.js';
import BetaPlanBuilder from '$ui/components/BetaPlanBuilder.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface FileRow {
id: number;
name: string;
kind: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
latestVersion: { id: number } | null;
runningBetaId: number | null;
}
interface Version {
id: number;
version: number;
release_version: string | null;
sha256: string;
state: string;
}
interface FileDetail {
script: FileRow;
versions: Version[];
matchedServers: string[];
runningBetaId?: number | null;
}
interface Pick {
key: number;
scriptId: number | null;
versionId: number | null;
}
let scripts = $state<FileRow[]>([]),
detailById = $state<Record<number, FileDetail>>({}),
nextKey = 0;
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
let pickedServers = $state<Set<string>>(new Set()),
testPlan = $state<BetaPlan>(defaultPlan());
let selectionMode = $state<BetaDraft['selectionMode']>('automatic');
let instanceCount = $state<BetaDraft['instanceCount']>(1);
let step = $state(0),
stepHeading = $state<HTMLHeadingElement>(),
planBuilder = $state<BetaPlanBuilder>();
let loading = $state(true),
openingBeta = $state(false),
flash = $state(''),
notice = $state('');
let availableDraft = $state<BetaDraft | null>(null),
draftReady = $state(false),
storageUnavailable = $state(false),
allowLeave = false;
let hasLinkedSetup = $state(false);
let loadingVersions = $state<Set<number>>(new Set());
const rowRequests = new Map<number, number>();
let versionRequest = 0;
let betaLabel = $state('');
let search = $state(''),
serverGame = $state(''),
availability = $state(''),
now = $state(Date.now());
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
const fleetServers = $derived($sharedFleet);
const eligibleFiles = $derived(
scripts.filter((file) => file.runningBetaId === null && file.latestVersion !== null)
);
const filesReady = $derived(
picks.length > 0 &&
picks.every(
(p) =>
p.scriptId !== null &&
p.versionId !== null &&
versionsFor(p).some((v) => v.id === p.versionId)
)
);
const dirty = $derived(
!!betaLabel.trim() ||
picks.some((p) => p.scriptId !== null) ||
pickedServers.size > 0 ||
selectionMode === 'manual' ||
instanceCount !== 1 ||
JSON.stringify(testPlan) !== JSON.stringify(defaultPlan())
);
const selectedFiles = $derived(
picks
.map((p) => (p.scriptId === null ? null : detailById[p.scriptId]?.script))
.filter((file): file is FileRow => !!file)
);
const candidates = $derived(betaCandidateServers(selectedFiles, fleetServers));
const candidateServers = $derived(candidates.map((server) => server.name));
const applicableGames = $derived(betaApplicableGames(selectedFiles, fleetServers));
const automaticCounts = $derived(betaInstanceCounts(applicableGames, instanceCount));
const serverCount = $derived(
selectionMode === 'manual'
? pickedServers.size
: Object.values(automaticCounts).reduce((total, count) => total + count, 0)
);
const serverSelectionIssue = $derived.by(() => {
if (selectionMode === 'automatic') {
if (!applicableGames.length)
return 'The selected files have no registered servers in a supported game.';
if (
instanceCount === null ||
!Number.isInteger(instanceCount) ||
instanceCount < 1 ||
instanceCount > 1000
)
return 'Enter a whole number from 1 to 1,000. This count applies to every game in the beta.';
if (!serverCount) return 'Request at least one test instance.';
const missing = selectedFiles.find(
(file) =>
!candidates.some(
(server) =>
betaScopeMatches(file, server) &&
!!server.game &&
(automaticCounts[server.game] ?? 0) > 0
)
);
if (missing)
return `${missing.name} needs at least one instance in a matching game and scope.`;
const short = applicableGames.find(
(game) =>
(automaticCounts[game] ?? 0) >
candidates.filter((server) => server.game === game).length
);
if (short)
return `${gameLabel(short)} has fewer compatible registered servers than requested. Lower the instance count or add servers.`;
return '';
}
if (!pickedServers.size) return 'Select at least one compatible test server.';
if ([...pickedServers].some((name) => !candidateServers.includes(name)))
return 'Recheck the selected files and servers before starting.';
const missing = selectedFiles.find(
(file) =>
!candidates.some(
(server) => pickedServers.has(server.name) && betaScopeMatches(file, server)
)
);
return missing
? `Select at least one server matching ${missing.name}. Every file needs a test participant.`
: '';
});
const visibleServers = $derived(
fleetServers
.filter((server) => candidateServers.includes(server.name))
.filter((server) => {
const identity = fleetIdentity(server),
fresh = freshServer(server, now);
return (
(!serverGame || identity.game === serverGame) &&
(!availability ||
(availability === 'empty'
? fresh && server.last_players === 0
: availability === 'occupied'
? fresh && (server.last_players ?? 0) > 0
: !fresh)) &&
`${server.name} ${identity.title} ${identity.map} ${identity.game}`
.toLowerCase()
.includes(search.toLowerCase())
);
})
);
const groups = $derived(
[...new Set(visibleServers.map((server) => fleetIdentity(server).group))].map((group) => ({
key: group,
servers: visibleServers.filter((server) => fleetIdentity(server).group === group)
}))
);
const forwardReason = $derived(
step > 0 && $fleetError
? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.'
: step === 0
? !filesReady
? 'Choose an available file and exact version in every row.'
: ''
: step === 1
? serverSelectionIssue
: step === 2
? !testPlan.requirements.length
? 'Add at least one requirement to continue.'
: ''
: !filesReady
? 'Recheck the selected files before starting.'
: serverSelectionIssue
);
function versionsFor(pick: Pick) {
return pick.scriptId === null
? []
: (detailById[pick.scriptId]?.versions ?? []).filter(
(v) => v.id !== detailById[pick.scriptId!]?.script.current_version_id
);
}
function eligibleFor(index: number) {
return eligibleFiles.filter(
(file) => !picks.some((p, i) => i !== index && p.scriptId === file.id)
);
}
function gameLabel(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game;
}
function scopeText(file: FileRow) {
return !file.scope_type
? 'Any registered server'
: file.scope_type === 'map'
? `Map: ${mapLabel(file.scope_value ?? '')}`
: `Game: ${file.scope_value === 't5' ? 'Black Ops I' : file.scope_value === 't6' ? 'Black Ops II' : file.scope_value}`;
}
function scopeCount(file: FileRow) {
return fleetServers.filter(
(server) =>
!file.scope_type ||
(file.scope_type === 'game' ? server.game : server.map) === file.scope_value
).length;
}
function reconcileSelection() {
// Failed refreshes can leave an empty or retained snapshot; neither is
// authoritative enough to remove selections from the saved draft.
if (
$fleetError ||
picks.every((p) => p.scriptId === null) ||
picks.some((p) => p.scriptId !== null && !detailById[p.scriptId])
)
return;
const result = compatibleBetaServers(pickedServers, candidateServers);
if (result.removed.length) {
pickedServers = result.kept;
notice = `Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match any file: ${result.removed.join(', ')}.`;
}
}
async function loadSelections(selections: BetaDraft['picks']) {
const warnings: string[] = [];
picks = await Promise.all(
selections.map(async (selection) => {
const pick = { key: nextKey++, ...selection };
if (pick.scriptId === null) return pick;
const listed = scripts.find((file) => file.id === pick.scriptId);
if (!listed || listed.runningBetaId !== null) {
warnings.push(
`File #${pick.scriptId} is unavailable or in another beta. Choose a replacement.`
);
return { ...pick, scriptId: null, versionId: null };
}
try {
const response = await fetch(`/api/scripts/${pick.scriptId}`);
if (!response.ok) throw Error();
const detail = (await response.json()) as FileDetail;
detailById[pick.scriptId] = detail;
if (detail.runningBetaId != null) {
warnings.push(
`${listed.name} is in beta #${detail.runningBetaId}. Choose a replacement.`
);
return { ...pick, scriptId: null, versionId: null };
}
if (
!detail.versions.some((v) => v.id === pick.versionId) ||
detail.script.current_version_id === pick.versionId
) {
warnings.push(
`${listed.name}: saved version ${pick.versionId ?? ''} is unavailable or already promoted. Select an unpromoted version; no newer version was substituted.`
);
pick.versionId = null;
}
return pick;
} catch {
warnings.push(
`${listed.name}: versions could not be loaded. Choose the file again to retry.`
);
return { ...pick, versionId: null };
}
})
);
if (warnings.length) {
flash = warnings.join(' ');
step = 0;
}
}
async function initialize(ignoreDraft = false) {
loading = true;
flash = '';
draftReady = false;
try {
const response = await fetch('/api/scripts');
if (!response.ok)
throw new Error('Could not load files for beta setup. Retry to continue.');
scripts = (await response.json()).scripts;
await refreshFleet();
const query = new URLSearchParams(window.location.search),
selections = parseBetaSelection(query),
reuse = Number(query.get('reuse'));
hasLinkedSetup = selections.length > 0 || reuse > 0;
// Returning from a file detail retains the original selection URL. A
// stored draft must be offered before that URL can seed a fresh setup.
if (!ignoreDraft) {
try {
availableDraft = readBetaDraft(localStorage.getItem(betaDraftKey));
} catch {
storageUnavailable = true;
}
if (availableDraft) {
if (hasLinkedSetup)
notice =
'An unfinished draft is saved. Resume it, or discard it to start from the linked files and exact versions.';
return;
}
}
if (reuse > 0) {
const response = await fetch(`/api/betas/${reuse}`);
if (!response.ok)
throw new Error('The beta to reuse is unavailable. Choose files below.');
const source = await response.json();
if (!['promoted', 'rolled-back'].includes(source.beta.state))
throw new Error(
'Roll back or promote the original beta before reusing its files.'
);
if (source.test?.plan) testPlan = parseBetaPlan(source.test.plan);
betaLabel = source.beta.notes ?? '';
pickedServers = new Set(source.servers.map((s: { server: string }) => s.server));
selectionMode = 'manual';
await loadSelections(
source.scripts.map((file: { id: number; version: { id: number } | null }) => ({
scriptId: file.id,
versionId: file.version?.id ?? null
}))
);
reconcileSelection();
notice =
'Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.';
draftReady = true;
} else if (selections.length) {
await loadSelections(selections);
draftReady = true;
} else draftReady = true;
} catch (error) {
flash = (error as Error).message;
draftReady = true;
} finally {
loading = false;
}
}
async function resumeDraft() {
if (!availableDraft) return;
const draft = availableDraft;
loading = true;
flash = '';
notice = '';
try {
testPlan = draft.testPlan;
selectionMode = draft.selectionMode;
instanceCount = draft.instanceCount;
betaLabel = typeof draft.label === 'string' ? draft.label : '';
step = draft.step;
pickedServers = new Set(draft.servers);
await loadSelections(draft.picks);
reconcileSelection();
notice =
notice ||
'Draft resumed. Exact file versions and compatible servers were rechecked. Automatic selection uses the same instance count for every game; review it before starting.';
} finally {
availableDraft = null;
loading = false;
draftReady = true;
}
}
function discardDraft() {
const useLinked = !!availableDraft && hasLinkedSetup;
availableDraft = null;
picks = [{ key: nextKey++, scriptId: null, versionId: null }];
pickedServers = new Set();
selectionMode = 'automatic';
instanceCount = 1;
testPlan = defaultPlan();
betaLabel = '';
step = 0;
notice = 'Draft discarded.';
try {
localStorage.removeItem(betaDraftKey);
} catch {
storageUnavailable = true;
}
draftReady = true;
if (useLinked) void initialize(true);
}
$effect(() => {
if (!draftReady || loading || openingBeta) return;
const draft: BetaDraft = {
version: 1,
savedAt: new Date().toISOString(),
step,
label: betaLabel,
picks: picks.map(({ scriptId, versionId }) => ({ scriptId, versionId })),
servers: [...pickedServers],
selectionMode,
instanceCount,
testPlan: JSON.parse(JSON.stringify(testPlan))
};
if (dirty) {
try {
localStorage.setItem(betaDraftKey, JSON.stringify(draft));
storageUnavailable = false;
} catch {
storageUnavailable = true;
}
}
});
$effect(() => {
if (draftReady && !loading && !loadingVersions.size) {
void candidateServers;
reconcileSelection();
}
});
$effect(() => {
if (selectionMode === 'automatic' && testPlan.scope !== 'all')
testPlan = { ...testPlan, scope: 'all' };
});
beforeNavigate((navigation) => {
if (!dirty || !storageUnavailable || allowLeave || openingBeta) return;
if (navigation.willUnload) {
navigation.cancel();
return;
}
if (navigation.to) {
navigation.cancel();
void confirmAction({
title: 'Leave unfinished beta?',
message:
'This browser cannot save the draft. Leaving will discard your unfinished setup.',
confirmLabel: 'Leave setup',
danger: true
}).then((confirmed) => {
if (confirmed) {
allowLeave = true;
void goto(navigation.to!.url);
}
});
}
});
onMount(() => {
void initialize();
const timer = setInterval(() => (now = serverTimeNow()), 10000);
const unload = (event: BeforeUnloadEvent) => {
if (dirty && storageUnavailable && !allowLeave && !openingBeta) {
event.preventDefault();
event.returnValue = '';
}
};
window.addEventListener('beforeunload', unload);
return () => {
clearInterval(timer);
window.removeEventListener('beforeunload', unload);
};
});
async function onPickFile(index: number) {
const pick = picks[index],
scriptId = pick.scriptId,
current = ++versionRequest;
rowRequests.set(pick.key, current);
pick.versionId = null;
flash = '';
if (scriptId === null) {
loadingVersions = new Set([...loadingVersions].filter((key) => key !== pick.key));
reconcileSelection();
return;
}
loadingVersions = new Set([...loadingVersions, pick.key]);
try {
const response = await fetch(`/api/scripts/${scriptId}`);
if (!response.ok)
throw new Error('Could not load versions. Choose the file again to retry.');
const detail = (await response.json()) as FileDetail;
if (
rowRequests.get(pick.key) !== current ||
pick.scriptId !== scriptId ||
!picks.some((p) => p.key === pick.key)
)
return;
detailById[scriptId] = detail;
pick.versionId = versionsFor(pick)[0]?.id ?? null;
picks = [...picks];
reconcileSelection();
} catch (error) {
if (rowRequests.get(pick.key) === current) flash = (error as Error).message;
} finally {
if (rowRequests.get(pick.key) === current)
loadingVersions = new Set([...loadingVersions].filter((key) => key !== pick.key));
}
}
function toggleServer(name: string) {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
function selectVisible(select: boolean) {
const next = new Set(pickedServers);
for (const server of visibleServers) {
if (select) next.add(server.name);
else next.delete(server.name);
}
pickedServers = next;
}
async function move(next: number) {
flash = '';
if (next > step && step === 2) {
if (!(await planBuilder?.validate())) return;
try {
parseBetaPlan(testPlan);
} catch (error) {
flash = (error as Error).message;
return;
}
}
step = next;
await tick();
stepHeading?.focus();
}
async function openBeta(event: Event) {
event.preventDefault();
if (step !== 3 || forwardReason || openingBeta) return;
if (betaPlanIssues(testPlan, serverCount).length) {
await move(2);
await planBuilder?.validate();
return;
}
openingBeta = true;
flash = '';
try {
const response = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scripts: picks.map((p) => ({
scriptId: p.scriptId,
scriptVersionId: p.versionId
})),
...(selectionMode === 'automatic'
? { instancesPerGame: automaticCounts }
: { servers: [...pickedServers] }),
testPlan: parseBetaPlan(testPlan),
notes: betaLabel.trim() || undefined,
waitForHealth: true,
waitForMatch: false
})
}),
body = await response.json();
if (!response.ok || !body.ok)
throw new Error(
body.error ?? 'Could not start beta. Review the selected versions and servers.'
);
draftReady = false;
try {
localStorage.removeItem(betaDraftKey);
} catch {
// Draft cleanup is best effort when browser storage is unavailable.
}
allowLeave = true;
await goto(
contextHref(
`/betas/${body.result.betaId}`,
safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')
)
);
} catch (error) {
flash = (error as Error).message;
} finally {
openingBeta = false;
}
}
</script>
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
<div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<SectionHeader
title="New beta"
subtitle="Test files across games in one beta. Choose one instance count for every game."
/>
{#if flash}<div class="ui-error" role="alert">
{flash}{#if !scripts.length}<Button size="sm" onclick={() => initialize()}>Retry</Button
>{/if}
</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">
{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button>
</div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…" />
{:else if availableDraft}<section class="ui-panel draft">
<h2>Resume unfinished beta?</h2>
<p>
Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(
(p) => p.scriptId !== null
).length} files · {availableDraft.selectionMode === 'automatic'
? 'Automatic server selection'
: `${availableDraft.servers.length} selected servers`}. Versions and eligibility
will be rechecked.
</p>
<div class="actions">
<Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button
onclick={discardDraft}
>{hasLinkedSetup
? 'Discard draft and use linked files'
: 'Discard draft'}</Button
>
</div>
</section>
{:else if !eligibleFiles.length && !flash}<EmptyState
title="No files ready to test"
description="Upload a file version or finish its active beta, then return here."
/><Button href="/scripts">Open Library</Button>
{:else if scripts.length}
<ol class="steps" aria-label="Beta setup progress">
{#each steps as label, index}<li
aria-current={step === index ? 'step' : undefined}
class:current={step === index}
>
<span>{index + 1}</span>{label}
</li>{/each}
</ol>
<div class="draft-status">
{#if storageUnavailable}<span role="alert"
>Draft saving is unavailable in this browser. Keep this page open to preserve
your work.</span
>{:else}<span>Unfinished changes save in this browser.</span>{/if}{#if dirty}<Button
variant="ghost"
size="sm"
onclick={async () => {
if (
await confirmAction({
title: 'Discard beta draft?',
message: 'Clear the selected files, servers, and custom test plan?',
confirmLabel: 'Discard draft',
danger: true
})
)
discardDraft();
}}>Discard draft</Button
>{/if}
</div>
<form onsubmit={openBeta} class="beta-form">
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
{#if step === 0}
<label
>Beta label (optional)<input
bind:value={betaLabel}
maxlength="120"
placeholder="For example, Town balance update"
/><span class="muted"
>Shown in the beta list so others can identify this test.</span
></label
>
<div class="picks">
{#each picks as pick, index (pick.key)}<section class="pick-row">
<label
>File {index + 1}<select
bind:value={pick.scriptId}
onchange={() => onPickFile(index)}
><option value={null}>Choose a file</option
>{#each eligibleFor(index) as file}<option value={file.id}
>[{file.kind}] {file.name}</option
>{/each}</select
></label
>
<label
>Exact version<select
bind:value={pick.versionId}
disabled={pick.scriptId === null ||
loadingVersions.has(pick.key)}
><option value={null}
>{loadingVersions.has(pick.key)
? 'Loading versions…'
: 'Choose a version'}</option
>{#each versionsFor(pick) as version}<option value={version.id}
>{artifactVersionLabel(version)} · {version.sha256.slice(
0,
8
)} · {version.state.replaceAll('-', ' ')}</option
>{/each}</select
></label
>
<Button
ariaLabel={`Remove file ${index + 1}`}
disabled={picks.length === 1}
onclick={() => {
picks = picks.filter((p) => p.key !== pick.key);
reconcileSelection();
}}>Remove</Button
>
{#if pick.scriptId !== null}{@const file =
detailById[pick.scriptId]?.script}{#if file}<p class="scope">
{scopeText(file)} · {scopeCount(file)} eligible servers ·
<a
href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}
>Inspect file and versions</a
>{#if !loadingVersions.has(pick.key) && !versionsFor(pick).length}<br
/>No unpromoted versions. Upload a new version from the
file page.{/if}
</p>{/if}{/if}
</section>{/each}
</div>
<Button
onclick={() =>
(picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }])}
disabled={picks.length >= eligibleFiles.length}>Add file</Button
>
<p class="muted">
Combine files for different games in one beta. Each file installs only on
servers matching its scope, and every file needs at least one test participant.
</p>
{:else if step === 1}
{#if selectionMode === 'automatic'}
<div class="automatic-selection">
<h3>Instances that must pass</h3>
<p>
At launch, Electric Cherry chooses compatible servers with the fewest
current players. Every requested instance must pass the test plan.
</p>
<label class="instance-count"
>Instances per game<input
type="number"
min="1"
max="1000"
step="1"
value={instanceCount ?? ''}
oninput={(event) =>
(instanceCount =
event.currentTarget.value === ''
? null
: event.currentTarget.valueAsNumber)}
/><span class="muted"
>The same number applies to every game in this beta.</span
></label
>
<p class="muted">
{applicableGames
.map(
(game) =>
`${gameLabel(game)}: ${countLabel(candidates.filter((server) => server.game === game).length, 'compatible registered server')}`
)
.join(' · ')}
</p>
<p class="muted">
Servers are selected when you start the beta, using fresh player counts
and availability checks. Restarts wait for confirmed empty lobbies.
</p>
</div>
{:else}<p>
<strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible
servers. Restarts wait for confirmed empty lobbies.
</p>{/if}
<details>
<summary>Selected file scopes</summary>
<ul>
{#each picks as pick}{#if pick.scriptId !== null}{@const file =
detailById[pick.scriptId]?.script}{#if file}<li>
{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers
</li>{/if}{/if}{/each}
</ul>
</details>
{#if !candidateServers.length}<div class="ui-error">
No registered servers match these files. Review their scopes above or
register a matching server.
</div>{/if}
<details class="advanced-selection" open={selectionMode === 'manual'}>
<summary>Advanced options · choose specific servers</summary>
<div class="advanced-body">
<fieldset class="selection-mode">
<legend>Server selection</legend>
<label
><input
type="radio"
name="server-selection"
value="automatic"
bind:group={selectionMode}
/>Choose least-used servers automatically</label
>
<label
><input
type="radio"
name="server-selection"
value="manual"
bind:group={selectionMode}
/>Choose specific servers</label
>
</fieldset>
{#if selectionMode === 'manual' && candidateServers.length}
<div class="filters">
<label
>Find servers<input
type="search"
bind:value={search}
placeholder="Name, map, lobby…"
/></label
><label
>Game<select bind:value={serverGame}
><option value="">All games</option
>{#each [...new Set(fleetServers
.map((s) => s.game)
.filter(Boolean))] as game}<option value={game}
>{game === 't5'
? 'Black Ops I'
: game === 't6'
? 'Black Ops II'
: game}</option
>{/each}</select
></label
><label
>Availability<select bind:value={availability}
><option value="">All servers</option><option value="empty"
>Confirmed empty</option
><option value="occupied">Occupied</option><option
value="unknown">Unknown / stale / offline</option
></select
></label
>
</div>
<div class="actions">
<span>{visibleServers.length} visible</span><Button
size="sm"
onclick={() => selectVisible(true)}
disabled={!visibleServers.length}>Select visible</Button
><Button
size="sm"
onclick={() => selectVisible(false)}
disabled={!visibleServers.some((s) =>
pickedServers.has(s.name)
)}>Deselect visible</Button
>
</div>
{#each groups as group}<fieldset>
<legend
>{fleetIdentity(group.servers[0]).map} · {group.servers[0]
.game === 't5'
? 'Black Ops I'
: 'Black Ops II'}</legend
>
<div class="server-picker">
{#each group.servers as server}<label class="server-choice"
><input
type="checkbox"
checked={pickedServers.has(server.name)}
onchange={() => toggleServer(server.name)}
/><span
><ServerIdentity name={server.name} /><small
>{freshServer(server, now) &&
server.last_players !== null
? `${countLabel(server.last_players, 'player')} · ${server.last_players === 0 ? 'Confirmed empty' : 'Occupied'}`
: `Players unknown · ${server.health}`}{#if server.held.length}
· In beta #{server.held[0]
.betaId}{/if}</small
></span
></label
>{/each}
</div>
</fieldset>{/each}
{#if !visibleServers.length}<p>
No servers match these filters. <button
type="button"
class="text-action"
onclick={() => {
search = '';
serverGame = '';
availability = '';
}}>Clear filters</button
>
</p>{/if}
{/if}
</div>
</details>
{:else if step === 2}<BetaPlanBuilder
bind:this={planBuilder}
bind:plan={testPlan}
{serverCount}
allServersRequired={selectionMode === 'automatic'}
/>
{:else}<section class="ui-panel review">
<div class="review-heading">
<h3>Files and exact versions</h3>
<Button size="sm" onclick={() => move(0)}>Edit files</Button>
</div>
<ul>
{#each picks as pick}<li>
<strong
>{scripts.find((file) => file.id === pick.scriptId)
?.name}</strong
>
· {artifactVersionLabel(
versionsFor(pick).find((v) => v.id === pick.versionId)
)} · {versionsFor(pick)
.find((v) => v.id === pick.versionId)
?.sha256.slice(
0,
12
)}{#if pick.scriptId !== null && detailById[pick.scriptId]}<br
/><span class="muted"
>{scopeText(detailById[pick.scriptId].script)}</span
>{/if}
</li>{/each}
</ul>
<div class="review-heading">
<h3>Test instances ({serverCount})</h3>
<Button size="sm" onclick={() => move(1)}>Edit servers</Button>
</div>
{#if selectionMode === 'automatic'}
<ul>
{#each Object.entries(automaticCounts) as [game, count]}<li>
<strong>{gameLabel(game)}:</strong>
{countLabel(count, 'server')} must pass
</li>{/each}
</ul>
<p>
The least-used available servers are chosen when you start the beta.
Selection uses current player counts and ensures every file has a
matching participant. If there are not enough available servers, the
beta will not start.
</p>
{:else}<ul>
{#each [...pickedServers] as name}<li>
<ServerIdentity {name} />
</li>{/each}
</ul>{/if}
<div class="review-heading">
<h3>Pass criteria</h3>
<Button size="sm" onclick={() => move(2)}>Edit plan</Button>
</div>
<p>
{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting
when this beta opens.
</p>
<ul>
{#each testPlan.requirements as requirement}<li>
{requirementSummary(requirement)}
</li>{/each}
</ul>
<h3>Waves and failure handling</h3>
<p>
{testPlan.waveSize
? `${testPlan.waveSize} servers per wave.`
: 'Deploy to all selected servers.'}
{testPlan.failureAction === 'rollback'
? 'Failed checks trigger automatic rollback.'
: testPlan.failureAction === 'pause'
? 'Failed checks pause testing.'
: 'Failed checks hold the beta for review.'}
{testPlan.rollbackRestart === 'when-empty'
? 'Rollback restarts wait for empty servers.'
: 'Rollback restores files; restart servers manually.'}
</p>
<p>
Starting installs each selected version only on matching test servers and
restarts them when empty. Waiting, pausing, and retrying use the original
testing window. Promotion is a separate reviewed action after testing.
</p>
</section>{/if}
<div class="ui-sticky-actions">
<p id="beta-forward-help" class="muted">{forwardReason}</p>
<Button onclick={() => move(step - 1)} disabled={step === 0 || openingBeta}
>Back</Button
>{#if step < 3}<Button
variant="primary"
onclick={() => move(step + 1)}
disabled={!!forwardReason}>Continue</Button
>{:else}<Button
type="submit"
variant="primary"
disabled={!!forwardReason || openingBeta}
loading={openingBeta}>Start beta on {serverCount} instances</Button
>{/if}
</div>
</form>
{/if}
</div>
<style>
.beta-form input:not([type='checkbox']):not([type='radio']),
.beta-form select {
border-color: var(--border-control);
}
.page,
.beta-form {
display: grid;
gap: 24px;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-md);
}
p,
li {
font-size: var(--text-base);
line-height: 1.6;
}
.muted,
.draft-status {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.steps {
display: flex;
list-style: none;
gap: 16px;
padding: 0;
margin: 0;
flex-wrap: wrap;
}
.steps li {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.steps li span {
border: 1px solid var(--border-strong);
border-radius: 50%;
padding: 3px 9px;
}
.steps .current {
color: var(--text-primary);
font-weight: 600;
}
.steps .current span {
background: var(--accent-primary);
color: var(--text-on-accent);
}
.picks {
display: grid;
gap: 16px;
}
.pick-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.scope {
grid-column: 1/-1;
font-size: var(--text-sm);
color: var(--text-secondary);
}
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
color: var(--text-secondary);
min-width: 0;
}
input:not([type='checkbox']):not([type='radio']),
select {
width: 100%;
min-height: 40px;
padding: 9px 12px;
box-sizing: border-box;
border: 1px solid var(--border-strong);
border-radius: 6px;
color: var(--text-primary);
background: var(--surface-sunken);
font: inherit;
}
a,
.text-action {
color: var(--accent-primary-hover);
}
.text-action {
background: none;
border: 0;
text-decoration: underline;
cursor: pointer;
}
.automatic-selection,
.advanced-body {
display: grid;
gap: 16px;
}
.instance-count {
max-width: 400px;
}
.advanced-selection {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.advanced-selection summary {
cursor: pointer;
font-size: var(--text-base);
}
.advanced-body {
margin-top: 16px;
}
.selection-mode {
display: grid;
gap: 12px;
}
.selection-mode label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.selection-mode input {
accent-color: var(--accent-primary);
}
.filters,
.actions,
.draft-status,
.review-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.filters {
align-items: end;
}
.filters label {
flex: 1 1 180px;
}
.draft-status,
.review-heading {
justify-content: space-between;
}
.draft {
display: grid;
gap: 16px;
}
fieldset {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
min-width: 0;
}
legend {
font-size: var(--text-base);
padding: 0 8px;
}
.server-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: 12px;
}
.server-choice {
display: flex;
align-items: center;
min-height: 48px;
gap: 10px;
color: var(--text-primary);
cursor: pointer;
}
.server-choice small {
display: block;
color: var(--text-secondary);
font-size: var(--text-xs);
margin-top: 4px;
}
.server-choice input {
accent-color: var(--accent-primary);
width: 18px;
height: 18px;
flex-shrink: 0;
}
.review {
display: grid;
gap: 16px;
}
.review ul {
margin: 0;
}
.review li {
margin-bottom: 8px;
}
.notice {
padding: 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
.ui-sticky-actions p {
margin-right: auto;
flex: 1 1 200px;
}
@media (max-width: 700px) {
.pick-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.pick-row label:first-child {
grid-column: 1/-1;
}
.steps {
gap: 10px;
}
.steps li {
font-size: var(--text-xs);
}
}
</style>