Use one instance count for all beta games
This commit is contained in:
@@ -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
@@ -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[] } {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user