Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+33 -1
View File
@@ -5,6 +5,8 @@ export interface BetaDraft {
version: 1; savedAt: string; step: number;
picks: Array<{ scriptId: number | null; versionId: number | null }>;
servers: string[]; testPlan: BetaPlan;
selectionMode: 'automatic' | 'manual';
instancesPerGame: Record<string, number | null>;
label?: string;
}
/** Validate draft structure, preserving incomplete numeric fields for correction. */
@@ -17,10 +19,40 @@ export function readBetaDraft(raw: string | null): BetaDraft | null {
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
return { ...d, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
if (d.selectionMode !== undefined && !['automatic', 'manual'].includes(d.selectionMode)) return 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;
// 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)) };
} catch { return null; }
}
interface BetaFileScope { scope_type: 'game' | 'map' | null; scope_value: string | null }
interface BetaServerScope { name: string; game: string | null; map: string | null }
export function betaScopeMatches(file: BetaFileScope, server: BetaServerScope): boolean {
return !file.scope_type || (file.scope_type === 'game' ? server.game : server.map) === file.scope_value;
}
/** A bundle installs each file only on its own matching participants. */
export function betaCandidateServers<T extends BetaServerScope>(files: readonly BetaFileScope[], servers: readonly T[]): T[] {
return servers.filter(server => files.some(file => betaScopeMatches(file, server)));
}
export function betaApplicableGames(files: readonly BetaFileScope[], servers: readonly BetaServerScope[]): string[] {
return [...new Set([
...files.filter(file => file.scope_type === 'game').map(file => file.scope_value),
...betaCandidateServers(files, servers).map(server => server.game)
].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]]));
}
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
const allowed = new Set(candidates), names = [...selected];
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };