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
either script being installed on the other game's servers.
Setup defaults to automatic selection with one instance per applicable game.
Choose the number that must pass for each game. Every automatically selected
instance must pass every requirement in the plan. Set a game's count to zero to
omit it only when every selected file still has a matching participant.
Setup defaults to automatic selection. Enter one **Instances per game** number,
which defaults to 1 and applies to every game represented by the selected files
and their matching servers. Every automatically selected instance must pass
every requirement in the plan.
When you choose **Start beta**, Electric Cherry chooses compatible servers with
the fewest current players, while ensuring that every file is tested. Counts
are exact: requesting two T5 and three T6 instances selects five participants,
and all five must pass. Server choices are made at launch, so review shows the
requested counts rather than a provisional list of server names.
are exact: with T5 and T6 files selected, entering 2 selects two T5 and two T6
participants, and all four must pass. Server choices are made at launch, so
review shows the count for each game rather than a provisional list of server
names.
Automatic selection requires fresh player observations and available running
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
picker includes servers matching any file in the bundle; each file must have a
matching selected server. Manual selection retains the configurable coverage
rules below. Drafts preserve the selection mode and counts, and older drafts
with explicit server choices resume in manual mode.
rules below. Drafts preserve the selection mode and shared instance count, and
older drafts with explicit server choices resume in manual mode.
## Game reporter protocol
@@ -324,10 +325,11 @@ changing the policy still requires a new beta.
## API and storage
`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
only positive integer counts in `instancesPerGame`; omit games set to zero in
the UI. Automatic selection sets the plan's coverage `scope` to `all`.
only positive integer counts in `instancesPerGame`. The setup UI sends the same
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
participant receives in both modes.
`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 }>;
servers: string[]; testPlan: BetaPlan;
selectionMode: 'automatic' | 'manual';
instancesPerGame: Record<string, number | null>;
instanceCount: number | null;
label?: string;
}
/** 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'
|| 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;
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.
return { ...d, selectionMode: d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic'),
instancesPerGame: d.instancesPerGame ?? {}, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
const selectionMode = d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic');
// 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; }
}
@@ -48,9 +56,10 @@ export function betaApplicableGames(files: readonly BetaFileScope[], servers: re
].filter((game): game is string => game === 't5' || game === 't6'))].sort();
}
/** Keep incomplete counts recoverable and default newly applicable games to one. */
export function betaInstanceCounts(games: readonly string[], saved: Record<string, number | null>): Record<string, number | null> {
return Object.fromEntries(games.map(game => [game, saved[game] === undefined ? 1 : saved[game]]));
/** Expand the shared count only to games represented by the selected files. */
export function betaInstanceCounts(games: readonly string[], count: number | null): Record<string, number> {
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[] } {
+14 -16
View File
@@ -29,7 +29,7 @@
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 instancesPerGame = $state<BetaDraft['instancesPerGame']>({});
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;
@@ -43,23 +43,22 @@
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' || 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 candidates = $derived(betaCandidateServers(selectedFiles,fleetServers));
const candidateServers = $derived(candidates.map(server=>server.name));
const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers));
const requestedCounts = $derived(betaInstanceCounts(applicableGames,instancesPerGame));
const automaticCounts = $derived(Object.fromEntries(Object.entries(requestedCounts).filter((entry):entry is [string,number]=>typeof entry[1]==='number' && entry[1]>0)));
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(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.';
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 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 '';
}
if(!pickedServers.size)return 'Select at least one compatible test server.';
@@ -143,19 +142,19 @@
async function resumeDraft() {
if(!availableDraft)return;
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; }
}
function discardDraft() {
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;}
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,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;} }
});
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
@@ -204,7 +203,7 @@
<div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<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 $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}
@@ -229,12 +228,11 @@
{:else if step===1}
{#if selectionMode==='automatic'}
<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>
<div class="instance-counts">{#each applicableGames as game}
<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>
{/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. Set a game to 0 only if every file still has a matching participant.</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)}: ${candidates.filter(server=>server.game===game).length} compatible registered servers`).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>
@@ -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); }
.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; }
.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; }
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; }
+22 -10
View File
@@ -15,15 +15,26 @@ describe('beta draft and plan recovery', () => {
expect(readBetaDraft('{bad')).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 }],
servers: ['previous-manual-choice'], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null }, testPlan: defaultPlan() };
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null } });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 1.5, t6: -1 } }))?.instancesPerGame).toEqual({ t5: 1.5, t6: -1 });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull();
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { unknown: 2 } }))).toBeNull();
servers: ['previous-manual-choice'], selectionMode: 'automatic', instanceCount: 2, testPlan: defaultPlan() };
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instanceCount: 2 });
for (const instanceCount of [null, 1.5, -1]) {
expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount }))?.instanceCount).toBe(instanceCount);
}
expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount: '2' }))).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', () => {
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(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' }];
expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']);
expect(betaInstanceCounts(['t5', 't6'], {})).toEqual({ t5: 1, t6: 1 });
expect(betaInstanceCounts(['t5', 't6'], { t5: 0, t6: null })).toEqual({ t5: 0, t6: null });
expect(betaInstanceCounts(['t6'], { t5: 3, t6: 2 })).toEqual({ t6: 2 });
expect(betaInstanceCounts(betaApplicableGames(files, []), 2)).toEqual({ t5: 2, t6: 2 });
expect(betaInstanceCounts(betaApplicableGames([files[1]], servers), 3)).toEqual({ t6: 3 });
for (const count of [null, 0, -1, 1.5, 1001]) expect(betaInstanceCounts(['t5', 't6'], count)).toEqual({});
expect(betaInstanceCounts([], 2)).toEqual({});
});
});
describe('beta evidence explanations', () => {