69 lines
4.5 KiB
TypeScript
69 lines
4.5 KiB
TypeScript
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<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.
|
|
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<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();
|
|
}
|
|
|
|
/** 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[] } {
|
|
const allowed = new Set(candidates), names = [...selected];
|
|
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
|
|
}
|