export const requirementKinds = [ 'round', 'matches', 'populated-minutes', 'player-hours', 'population', 'sustained-population', 'healthy-minutes', 'failure-budget', 'recovery', 'manual' ] as const; export type RequirementKind = (typeof requirementKinds)[number]; export interface BetaRequirement { id: string; kind: RequirementKind; target: number; label: string; minRound: number; minPlayers: number; minMinutes: number; } export interface BetaPlan { version: 1; requirements: BetaRequirement[]; scope: 'all' | 'quorum' | 'map' | 'game' | 'map-game'; quorum: number; timeoutHours: number; failureAction: 'fail' | 'pause' | 'rollback'; rollbackRestart: 'manual' | 'when-empty'; waveSize: number; // 0 deploys all selected servers; otherwise pass each wave before expanding. } export const requirementLabels: Record = { round: 'Reach round', matches: 'Complete qualifying matches', 'populated-minutes': 'Populated minutes', 'player-hours': 'Player-hours', population: 'Reach player count', 'sustained-population': 'Sustained population (minutes)', 'healthy-minutes': 'Continuous healthy minutes', 'failure-budget': 'Maximum health outages', recovery: 'Successful recovery cycles', manual: 'Tester checklist' }; export function requirement(kind: RequirementKind, id: string): BetaRequirement { return { id, kind, label: requirementLabels[kind], target: kind === 'round' ? 25 : kind === 'failure-budget' ? 0 : 1, minRound: 0, minPlayers: 1, minMinutes: 0 }; } export function defaultPlan(): BetaPlan { return { version: 1, requirements: [requirement('healthy-minutes', 'health')], scope: 'all', quorum: 1, timeoutHours: 24, failureAction: 'fail', rollbackRestart: 'when-empty', waveSize: 0 }; } export const builtInPlans: Array<{ name: string; plan: BetaPlan }> = [ { name: 'Quick smoke test', plan: defaultPlan() }, { name: 'Gameplay validation', plan: { ...defaultPlan(), requirements: [ { ...requirement('matches', 'matches'), target: 2, minRound: 25 }, { ...requirement('populated-minutes', 'playtime'), target: 90 }, requirement('failure-budget', 'outages') ] } }, { name: 'Long soak', plan: { ...defaultPlan(), timeoutHours: 72, requirements: [ { ...requirement('healthy-minutes', 'health'), target: 360 }, { ...requirement('player-hours', 'participation'), target: 10 }, requirement('failure-budget', 'outages') ] } } ]; /** Strict allow-list validation at every boundary (including stored presets). */ export function parseBetaPlan(value: unknown): BetaPlan { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Test plan must be an object'); const p = value as BetaPlan; if (p.version !== 1 || !['all', 'quorum', 'map', 'game', 'map-game'].includes(p.scope)) throw new Error('Invalid test plan version or scope'); if ( !['fail', 'pause', 'rollback'].includes(p.failureAction) || !['manual', 'when-empty'].includes(p.rollbackRestart) ) throw new Error('Invalid failure policy'); for (const [key, min, max] of [ ['quorum', 1, 1000], ['timeoutHours', 0.01, 8760], ['waveSize', 0, 1000] ] as const) { if (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < min || p[key] > max) throw new Error(`Invalid ${key}`); } if (!Number.isInteger(p.quorum) || !Number.isInteger(p.waveSize)) throw new Error('Quorum and wave size must be integers'); if (!Array.isArray(p.requirements) || !p.requirements.length || p.requirements.length > 30) throw new Error('Choose 1–30 requirements'); const ids = new Set(); const requirements = p.requirements.map((r) => { if (!r || typeof r !== 'object' || !requirementKinds.includes(r.kind)) throw new Error('Unknown requirement'); if ( typeof r.id !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(r.id) || r.id in Object.prototype || ids.has(r.id) ) throw new Error('Requirement IDs must be unique, non-reserved identifiers'); ids.add(r.id); if (typeof r.label !== 'string' || !r.label.trim() || r.label.length > 200) throw new Error('Requirement needs a label (up to 200 characters)'); for (const key of ['target', 'minRound', 'minPlayers', 'minMinutes'] as const) { if ( typeof r[key] !== 'number' || !Number.isFinite(r[key]) || r[key] < 0 || r[key] > 1_000_000 ) throw new Error(`Invalid requirement ${key}`); } if (r.kind !== 'failure-budget' && r.target <= 0) throw new Error('Requirement target must be positive'); if ( ['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes( r.kind ) && !Number.isInteger(r.target) ) throw new Error('Count targets must be integers'); if (!Number.isInteger(r.minRound) || !Number.isInteger(r.minPlayers)) throw new Error('Round and player qualifiers must be integers'); if (r.kind === 'manual' && r.target !== 1) throw new Error('Add a separate requirement for each checklist item'); return { id: r.id, kind: r.kind, label: r.label.trim(), target: r.target, minRound: r.minRound, minPlayers: r.minPlayers, minMinutes: r.minMinutes }; }); return { version: 1, requirements, scope: p.scope, quorum: p.quorum, timeoutHours: p.timeoutHours, failureAction: p.failureAction, rollbackRestart: p.rollbackRestart, waveSize: p.waveSize }; } export interface RequirementProgress { id: string; label: string; current: number; target: number; passed: boolean; failed: boolean; detail: string; } export interface BetaTestView { plan: BetaPlan; outcome: string; reason: string | null; servers: Array<{ server: string; server_deleted_at: string | null; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null; observation?: { lastGameplayAt: string | null; lastEvent: string | null; players: number | null; round: number | null; matchStarted: boolean; gameEnded: boolean; healthy: boolean | null; healthCheckedAt: string | null; }; }>; evidence: Array<{ id: number; server: string; server_deleted_at: string | null; received_at: string; kind: string; payload: string; }>; } export interface BetaPlanIssue { field: string; message: string; requirementId?: string; } export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] { const issues: BetaPlanIssue[] = []; const check = ( field: string, value: number, min: number, max: number, integer: boolean, message: string, requirementId?: string ) => { if ( typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value)) ) issues.push({ field, message, requirementId }); }; check( 'timeoutHours', plan.timeoutHours, 0.01, 8760, false, 'Enter a testing window between 0.01 and 8,760 hours.' ); check( 'waveSize', plan.waveSize, 0, 1000, true, 'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.' ); if (plan.scope === 'quorum') check( 'quorum', plan.quorum, 1, serverCount ?? 1000, true, `Choose between 1 and ${serverCount ?? 1000} qualifying servers.` ); if (!plan.requirements.length) issues.push({ field: 'requirements', message: 'Add at least one requirement to continue.' }); for (const r of plan.requirements) { if (!r.label?.trim()) issues.push({ field: `${r.id}-label`, message: 'Describe what this requirement checks.', requirementId: r.id }); const integral = [ 'round', 'matches', 'population', 'failure-budget', 'recovery', 'manual' ].includes(r.kind); check( `${r.id}-target`, r.target, r.kind === 'failure-budget' ? 0 : 0.000001, 1_000_000, integral, `Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`, r.id ); if ( [ 'round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population' ].includes(r.kind) ) check( `${r.id}-minPlayers`, r.minPlayers, 1, 1_000_000, true, 'Enter at least one player, using a whole number.', r.id ); if (r.kind === 'matches') { check( `${r.id}-minRound`, r.minRound, 0, 1_000_000, true, 'Enter a whole round number; 0 allows any round.', r.id ); check( `${r.id}-minMinutes`, r.minMinutes, 0, 1_000_000, false, 'Enter a duration of zero or more minutes.', r.id ); } } return issues; }