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