Use one instance count for all beta games

This commit is contained in:
2026-10-07 09:48:30 -04:00
parent c6352d86b4
commit 56e5a2d06d
4 changed files with 65 additions and 44 deletions
+14 -12
View File
@@ -12,16 +12,17 @@ file applies to all participants. Every file needs at least one matching test
participant. For example, a T5 script and a T6 script can share one beta without participant. For example, a T5 script and a T6 script can share one beta without
either script being installed on the other game's servers. either script being installed on the other game's servers.
Setup defaults to automatic selection with one instance per applicable game. Setup defaults to automatic selection. Enter one **Instances per game** number,
Choose the number that must pass for each game. Every automatically selected which defaults to 1 and applies to every game represented by the selected files
instance must pass every requirement in the plan. Set a game's count to zero to and their matching servers. Every automatically selected instance must pass
omit it only when every selected file still has a matching participant. every requirement in the plan.
When you choose **Start beta**, Electric Cherry chooses compatible servers with When you choose **Start beta**, Electric Cherry chooses compatible servers with
the fewest current players, while ensuring that every file is tested. Counts the fewest current players, while ensuring that every file is tested. Counts
are exact: requesting two T5 and three T6 instances selects five participants, are exact: with T5 and T6 files selected, entering 2 selects two T5 and two T6
and all five must pass. Server choices are made at launch, so review shows the participants, and all four must pass. Server choices are made at launch, so
requested counts rather than a provisional list of server names. review shows the count for each game rather than a provisional list of server
names.
Automatic selection requires fresh player observations and available running Automatic selection requires fresh player observations and available running
servers. Servers held by another active beta or with pending deployment work servers. Servers held by another active beta or with pending deployment work
@@ -34,8 +35,8 @@ empty lobbies and restart capacity.
Open **Advanced options → Choose specific servers** for manual selection. The Open **Advanced options → Choose specific servers** for manual selection. The
picker includes servers matching any file in the bundle; each file must have a picker includes servers matching any file in the bundle; each file must have a
matching selected server. Manual selection retains the configurable coverage matching selected server. Manual selection retains the configurable coverage
rules below. Drafts preserve the selection mode and counts, and older drafts rules below. Drafts preserve the selection mode and shared instance count, and
with explicit server choices resume in manual mode. older drafts with explicit server choices resume in manual mode.
## Game reporter protocol ## Game reporter protocol
@@ -324,10 +325,11 @@ changing the policy still requires a new beta.
## API and storage ## API and storage
`POST /api/betas` accepts `scripts`, `testPlan`, and either `POST /api/betas` accepts `scripts`, `testPlan`, and either
`instancesPerGame` (for example, `{ "t5": 2, "t6": 3 }`) for automatic selection `instancesPerGame` (for example, `{ "t5": 2, "t6": 2 }`) for automatic selection
or `servers` with explicit registered server names for manual selection. Submit or `servers` with explicit registered server names for manual selection. Submit
only positive integer counts in `instancesPerGame`; omit games set to zero in only positive integer counts in `instancesPerGame`. The setup UI sends the same
the UI. Automatic selection sets the plan's coverage `scope` to `all`. shared count for every applicable game; API clients can still supply different
counts per game. Automatic selection sets the plan's coverage `scope` to `all`.
Do not send both selection forms. File scopes determine which bundle files each Do not send both selection forms. File scopes determine which bundle files each
participant receives in both modes. participant receives in both modes.
`GET /api/betas/:id` includes the plan, outcome, per-server progress, and `GET /api/betas/:id` includes the plan, outcome, per-server progress, and
+15 -6
View File
@@ -6,7 +6,7 @@ export interface BetaDraft {
picks: Array<{ scriptId: number | null; versionId: number | null }>; picks: Array<{ scriptId: number | null; versionId: number | null }>;
servers: string[]; testPlan: BetaPlan; servers: string[]; testPlan: BetaPlan;
selectionMode: 'automatic' | 'manual'; selectionMode: 'automatic' | 'manual';
instancesPerGame: Record<string, number | null>; instanceCount: number | null;
label?: string; label?: string;
} }
/** Validate draft structure, preserving incomplete numeric fields for correction. */ /** Validate draft structure, preserving incomplete numeric fields for correction. */
@@ -23,9 +23,17 @@ export function readBetaDraft(raw: string | null): BetaDraft | null {
if (d.instancesPerGame !== undefined && (!d.instancesPerGame || typeof d.instancesPerGame !== 'object' if (d.instancesPerGame !== undefined && (!d.instancesPerGame || typeof d.instancesPerGame !== 'object'
|| Array.isArray(d.instancesPerGame) || !Object.entries(d.instancesPerGame).every(([game, count]) => || Array.isArray(d.instancesPerGame) || !Object.entries(d.instancesPerGame).every(([game, count]) =>
['t5', 't6'].includes(game) && (count === null || typeof count === 'number' && Number.isFinite(count))))) return null; ['t5', 't6'].includes(game) && (count === null || typeof count === 'number' && Number.isFinite(count))))) return null;
if (d.instanceCount !== undefined && d.instanceCount !== null
&& (typeof d.instanceCount !== 'number' || !Number.isFinite(d.instanceCount))) return null;
const previousCounts = Object.values(d.instancesPerGame ?? {}) as Array<number | null>;
const instanceCount = d.instanceCount !== undefined ? d.instanceCount
: previousCounts.includes(null) ? null : previousCounts.length ? Math.max(...previousCounts as number[]) : 1;
// Existing drafts represent explicit selections and must not silently switch modes. // Existing drafts represent explicit selections and must not silently switch modes.
return { ...d, selectionMode: d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic'), const selectionMode = d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic');
instancesPerGame: d.instancesPerGame ?? {}, step: Math.max(0, Math.min(3, Number(d.step) || 0)) }; // Revisit server setup when old per-game counts cannot be retained as one value.
const changedCounts = selectionMode === 'automatic' && d.instanceCount === undefined && new Set(previousCounts).size > 1;
return { ...d, selectionMode,
instanceCount, step: Math.max(0, Math.min(changedCounts ? 1 : 3, Number(d.step) || 0)) };
} catch { return null; } } catch { return null; }
} }
@@ -48,9 +56,10 @@ export function betaApplicableGames(files: readonly BetaFileScope[], servers: re
].filter((game): game is string => game === 't5' || game === 't6'))].sort(); ].filter((game): game is string => game === 't5' || game === 't6'))].sort();
} }
/** Keep incomplete counts recoverable and default newly applicable games to one. */ /** Expand the shared count only to games represented by the selected files. */
export function betaInstanceCounts(games: readonly string[], saved: Record<string, number | null>): Record<string, number | null> { export function betaInstanceCounts(games: readonly string[], count: number | null): Record<string, number> {
return Object.fromEntries(games.map(game => [game, saved[game] === undefined ? 1 : saved[game]])); if (count === null || !Number.isSafeInteger(count) || count < 1 || count > 1000) return {};
return Object.fromEntries(games.map(game => [game, count]));
} }
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } { export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
+14 -16
View File
@@ -29,7 +29,7 @@
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]); let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan()); let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
let selectionMode = $state<BetaDraft['selectionMode']>('automatic'); let selectionMode = $state<BetaDraft['selectionMode']>('automatic');
let instancesPerGame = $state<BetaDraft['instancesPerGame']>({}); let instanceCount = $state<BetaDraft['instanceCount']>(1);
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>(); let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state(''); 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 availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
@@ -43,23 +43,22 @@
const fleetServers = $derived($sharedFleet); const fleetServers = $derived($sharedFleet);
const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null)); 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 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' || Object.values(instancesPerGame).some(count=>count!==1) || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan())); 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 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 candidates = $derived(betaCandidateServers(selectedFiles,fleetServers));
const candidateServers = $derived(candidates.map(server=>server.name)); const candidateServers = $derived(candidates.map(server=>server.name));
const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers)); const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers));
const requestedCounts = $derived(betaInstanceCounts(applicableGames,instancesPerGame)); const automaticCounts = $derived(betaInstanceCounts(applicableGames,instanceCount));
const automaticCounts = $derived(Object.fromEntries(Object.entries(requestedCounts).filter((entry):entry is [string,number]=>typeof entry[1]==='number' && entry[1]>0)));
const serverCount = $derived(selectionMode==='manual' ? pickedServers.size : Object.values(automaticCounts).reduce((total,count)=>total+count,0)); const serverCount = $derived(selectionMode==='manual' ? pickedServers.size : Object.values(automaticCounts).reduce((total,count)=>total+count,0));
const serverSelectionIssue = $derived.by(()=>{ const serverSelectionIssue = $derived.by(()=>{
if(selectionMode==='automatic') { if(selectionMode==='automatic') {
if(!applicableGames.length)return 'The selected files have no registered servers in a supported game.'; if(!applicableGames.length)return 'The selected files have no registered servers in a supported game.';
if(Object.values(requestedCounts).some(count=>count===null || !Number.isInteger(count) || count<0 || count>1000))return 'Enter a whole number from 0 to 1,000 for each 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.'; 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)); 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.`; 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); 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 its instance count or add servers.`; if(short)return `${gameLabel(short)} has fewer compatible registered servers than requested. Lower the instance count or add servers.`;
return ''; return '';
} }
if(!pickedServers.size)return 'Select at least one compatible test server.'; if(!pickedServers.size)return 'Select at least one compatible test server.';
@@ -143,19 +142,19 @@
async function resumeDraft() { async function resumeDraft() {
if(!availableDraft)return; if(!availableDraft)return;
const draft=availableDraft; loading=true; flash=''; notice=''; const draft=availableDraft; loading=true; flash=''; notice='';
try { testPlan=draft.testPlan; selectionMode=draft.selectionMode; instancesPerGame=draft.instancesPerGame; 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.'; } 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; } finally { availableDraft=null; loading=false; draftReady=true; }
} }
function discardDraft() { function discardDraft() {
const useLinked=!!availableDraft && hasLinkedSetup; const useLinked=!!availableDraft && hasLinkedSetup;
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); selectionMode='automatic'; instancesPerGame={}; testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.'; 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;} try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
draftReady=true; draftReady=true;
if(useLinked)void initialize(true); if(useLinked)void initialize(true);
} }
$effect(()=>{ $effect(()=>{
if(!draftReady || loading || openingBeta)return; 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,instancesPerGame:{...instancesPerGame,...requestedCounts},testPlan:JSON.parse(JSON.stringify(testPlan))}; 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;} } if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
}); });
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } }); $effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
@@ -204,7 +203,7 @@
<div class="page"> <div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} /> <Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<BackLink fallback="/betas" /> <BackLink fallback="/betas" />
<SectionHeader title="New beta" subtitle="Test files across games in one beta. Choose how many instances need to pass." /> <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 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 $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 notice}<p role="status" class="notice">{notice}</p>{/if}
@@ -229,12 +228,11 @@
{:else if step===1} {:else if step===1}
{#if selectionMode==='automatic'} {#if selectionMode==='automatic'}
<div class="automatic-selection"> <div class="automatic-selection">
<h3>Instances that must pass per game</h3> <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> <p>At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.</p>
<div class="instance-counts">{#each applicableGames as game} <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>
<label>{gameLabel(game)}<input type="number" min="0" max="1000" step="1" value={requestedCounts[game]??''} oninput={event=>instancesPerGame={...instancesPerGame,[game]:event.currentTarget.value===''?null:event.currentTarget.valueAsNumber}}/><span class="muted">{candidates.filter(server=>server.game===game).length} compatible registered servers</span></label> <p class="muted">{applicableGames.map(game=>`${gameLabel(game)}: ${candidates.filter(server=>server.game===game).length} compatible registered servers`).join(' · ')}</p>
{/each}</div> <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>
<p class="muted">Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies. Set a game to 0 only if every file still has a matching participant.</p>
</div> </div>
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if} {: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> <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>
@@ -274,7 +272,7 @@
.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); } .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:13px; color:var(--text-secondary); } .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:13px; color:var(--text-secondary); }
label { display:grid; gap:6px; font-size:13px; 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; } label { display:grid; gap:6px; font-size:13px; 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-counts { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr)); gap:16px; } .advanced-selection { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } .advanced-selection summary { cursor:pointer; font-size:14px; } .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); } .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:14px; } .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; } .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:14px; 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:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; } fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; 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:12px; 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; } .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; }
+22 -10
View File
@@ -15,15 +15,26 @@ describe('beta draft and plan recovery', () => {
expect(readBetaDraft('{bad')).toBeNull(); expect(readBetaDraft('{bad')).toBeNull();
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull(); expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull();
}); });
it('restores automatic selection and incomplete counts without substituting manual selections', () => { it('restores the shared count and incomplete input without substituting manual selections', () => {
const raw = { version: 1, savedAt: '', step: 1, picks: [{ scriptId: 12, versionId: 45 }], const raw = { version: 1, savedAt: '', step: 1, picks: [{ scriptId: 12, versionId: 45 }],
servers: ['previous-manual-choice'], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null }, testPlan: defaultPlan() }; servers: ['previous-manual-choice'], selectionMode: 'automatic', instanceCount: 2, testPlan: defaultPlan() };
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null } }); expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instanceCount: 2 });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 1.5, t6: -1 } }))?.instancesPerGame).toEqual({ t5: 1.5, t6: -1 }); for (const instanceCount of [null, 1.5, -1]) {
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull(); expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount }))?.instanceCount).toBe(instanceCount);
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { unknown: 2 } }))).toBeNull(); }
expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount: '2' }))).toBeNull();
expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'unknown' }))).toBeNull(); expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'unknown' }))).toBeNull();
}); });
it('migrates per-game drafts without reducing coverage and revisits unequal counts', () => {
const raw = { version: 1, savedAt: '', step: 3, picks: [{ scriptId: 12, versionId: 45 }],
servers: [], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: 3 }, testPlan: defaultPlan() };
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ instanceCount: 3, step: 1 });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 2, t6: 2 } }))).toMatchObject({ instanceCount: 2, step: 3 });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 2, t6: null } }))?.instanceCount).toBeNull();
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: {} }))?.instanceCount).toBe(1);
expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'manual' }))?.step).toBe(3);
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull();
});
it('defaults a legacy draft with no server choices to automatic selection', () => { it('defaults a legacy draft with no server choices to automatic selection', () => {
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: [], testPlan: defaultPlan() }))?.selectionMode).toBe('automatic'); expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: [], testPlan: defaultPlan() }))?.selectionMode).toBe('automatic');
}); });
@@ -56,12 +67,13 @@ describe('multi-game beta setup', () => {
expect(betaCandidateServers([], servers)).toEqual([]); expect(betaCandidateServers([], servers)).toEqual([]);
expect(betaApplicableGames([], servers)).toEqual([]); expect(betaApplicableGames([], servers)).toEqual([]);
}); });
it('keeps explicitly scoped games visible without fleet matches and preserves zero or incomplete counts', () => { it('uses the shared count for every applicable game, including games without fleet matches', () => {
const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'game' as const, scope_value: 't6' }]; const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'game' as const, scope_value: 't6' }];
expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']); expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']);
expect(betaInstanceCounts(['t5', 't6'], {})).toEqual({ t5: 1, t6: 1 }); expect(betaInstanceCounts(betaApplicableGames(files, []), 2)).toEqual({ t5: 2, t6: 2 });
expect(betaInstanceCounts(['t5', 't6'], { t5: 0, t6: null })).toEqual({ t5: 0, t6: null }); expect(betaInstanceCounts(betaApplicableGames([files[1]], servers), 3)).toEqual({ t6: 3 });
expect(betaInstanceCounts(['t6'], { t5: 3, t6: 2 })).toEqual({ t6: 2 }); for (const count of [null, 0, -1, 1.5, 1001]) expect(betaInstanceCounts(['t5', 't6'], count)).toEqual({});
expect(betaInstanceCounts([], 2)).toEqual({});
}); });
}); });
describe('beta evidence explanations', () => { describe('beta evidence explanations', () => {