diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md index ae18f98..f02ccd9 100644 --- a/docs/guides/beta-test-plans.md +++ b/docs/guides/beta-test-plans.md @@ -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 diff --git a/src/lib/beta-draft.ts b/src/lib/beta-draft.ts index 63e7183..6877d9f 100644 --- a/src/lib/beta-draft.ts +++ b/src/lib/beta-draft.ts @@ -6,7 +6,7 @@ export interface BetaDraft { picks: Array<{ scriptId: number | null; versionId: number | null }>; servers: string[]; testPlan: BetaPlan; selectionMode: 'automatic' | 'manual'; - instancesPerGame: Record; + 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; + 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): Record { - 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 { + if (count === null || !Number.isSafeInteger(count) || count < 1 || count > 1000) return {}; + return Object.fromEntries(games.map(game => [game, count])); } export function compatibleBetaServers(selected: Iterable, candidates: readonly string[]): { kept: Set; removed: string[] } { diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte index 34f7c82..e112e85 100644 --- a/src/routes/betas/new/+page.svelte +++ b/src/routes/betas/new/+page.svelte @@ -29,7 +29,7 @@ let picks = $state([{key:nextKey++,scriptId:null,versionId:null}]); let pickedServers = $state>(new Set()), testPlan = $state(defaultPlan()); let selectionMode = $state('automatic'); - let instancesPerGame = $state({}); + let instanceCount = $state(1); let step = $state(0), stepHeading = $state(), planBuilder = $state(); let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state(''); let availableDraft = $state(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 @@
- + {#if flash}{/if} {#if $fleetError}{/if} {#if notice}

{notice}

{/if} @@ -229,12 +228,11 @@ {:else if step===1} {#if selectionMode==='automatic'}
-

Instances that must pass per game

+

Instances that must pass

At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.

-
{#each applicableGames as game} - - {/each}
-

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.

+ +

{applicableGames.map(game=>`${gameLabel(game)}: ${candidates.filter(server=>server.game===game).length} compatible registered servers`).join(' · ')}

+

Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies.

{:else}

{pickedServers.size} selected · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.

{/if}
Selected file scopes
    {#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}
  • {file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers
  • {/if}{/if}{/each}
@@ -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; } diff --git a/tests/unit/beta-setup.test.ts b/tests/unit/beta-setup.test.ts index e617f7f..0b3e521 100644 --- a/tests/unit/beta-setup.test.ts +++ b/tests/unit/beta-setup.test.ts @@ -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', () => {