import { requirementKinds, type BetaPlan } from './beta-plan.js'; export const betaDraftKey = 'electric-cherry:beta-draft:v1'; export interface BetaDraft { version: 1; savedAt: string; step: number; picks: Array<{ scriptId: number | null; versionId: number | null }>; servers: string[]; testPlan: BetaPlan; selectionMode: 'automatic' | 'manual'; instanceCount: number | null; label?: string; } /** Validate draft structure, preserving incomplete numeric fields for correction. */ export function readBetaDraft(raw: string | null): BetaDraft | null { if (!raw) return null; try { const d = JSON.parse(raw); if (d?.version !== 1 || !Array.isArray(d.picks) || !d.picks.length || d.picks.length > 100 || !d.picks.every((p: BetaDraft['picks'][number]) => p && [p.scriptId, p.versionId].every(v => v === null || Number.isSafeInteger(v) && v! > 0)) || !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; 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; 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. 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; } } 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(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(); } /** 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[] } { const allowed = new Set(candidates), names = [...selected]; return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) }; }