Support multi-game betas with automatic server selection
This commit is contained in:
+33
-1
@@ -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)) };
|
||||
|
||||
Reference in New Issue
Block a user