import type { BetaPlan, RequirementProgress } from '../../beta-plan.js'; import type { GameEvent } from '../../telemetry.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; export interface MatchEvidence { map: string; started?: number; lastEvent: number; players: number; ended: boolean; rounds: Record; } export interface TestMetrics { values: Record; match?: MatchEvidence; manual: Record; lastSample?: number; wasHealthy?: boolean; outages: number; streaks: Record; } export const emptyMetrics = (): TestMetrics => ({ values: {}, manual: {}, outages: 0, streaks: {} }); /** Gameplay comes only from the script. No reporter IDs or beta handshake. */ export function applyGameEvent(plan: BetaPlan, metrics: TestMetrics, event: GameEvent, now: number): void { const players = event.players.length; if (event.event === 'game_start' || !metrics.match || metrics.match.map !== event.map) { metrics.match = { map: event.map, started: event.event === 'game_start' ? now : undefined, lastEvent: now, players, ended: false, rounds: {} }; metrics.streaks = {}; } const match = metrics.match; if (match.ended) return; const minutes = Math.max(0, now - match.lastEvent) / 60_000; for (const r of plan.requirements) { const current = metrics.values[r.id] ?? 0; const populated = players >= Math.max(1, r.minPlayers) && match.players >= Math.max(1, r.minPlayers); if (r.kind === 'population') metrics.values[r.id] = Math.max(current, players); if (r.kind === 'populated-minutes' && populated) metrics.values[r.id] = current + minutes; if (r.kind === 'player-hours' && populated) metrics.values[r.id] = current + minutes * Math.min(players, match.players) / 60; if (r.kind === 'sustained-population') { metrics.streaks[r.id] = populated ? (metrics.streaks[r.id] ?? 0) + minutes : 0; metrics.values[r.id] = Math.max(current, metrics.streaks[r.id]); } if (event.event !== 'game_start' && players >= Math.max(1, r.minPlayers)) { match.rounds[r.id] = Math.max(match.rounds[r.id] ?? 0, event.round); if (r.kind === 'round') metrics.values[r.id] = Math.max(current, event.round); } if (event.event === 'game_end' && r.kind === 'matches' && event.round >= MIN_RECORDED_ROUND && (match.rounds[r.id] ?? -1) >= r.minRound && (r.minMinutes === 0 || (match.started !== undefined && now - match.started >= r.minMinutes * 60_000))) { metrics.values[r.id] = current + 1; } } match.lastEvent = now; match.players = players; match.ended = event.event === 'game_end'; } /** Infrastructure health is separate from script-reported gameplay. */ export function applySample(plan: BetaPlan, m: TestMetrics, at: number, healthy: boolean): void { if (m.lastSample !== undefined && at <= m.lastSample) return; const gap = m.lastSample === undefined ? Infinity : at - m.lastSample; const contiguous = gap <= 60_000; const minutes = healthy && m.wasHealthy && contiguous ? gap / 60_000 : 0; if (!healthy && m.wasHealthy === true) m.outages++; for (const r of plan.requirements) { if (r.kind === 'healthy-minutes') m.values[r.id] = healthy && contiguous && m.wasHealthy ? (m.values[r.id] ?? 0) + minutes : 0; } m.lastSample = at; m.wasHealthy = healthy; } export function evaluateRequirements(plan: BetaPlan, metrics: TestMetrics, phase: number): RequirementProgress[] { return plan.requirements.map(r => { const budget = r.kind === 'failure-budget'; const current = budget ? metrics.outages : r.kind === 'manual' ? Number(!!metrics.manual[r.id]) : r.kind === 'recovery' ? phase : metrics.values[r.id] ?? 0; return { id: r.id, label: r.label, current, target: r.target, passed: budget ? current <= r.target : current >= r.target, failed: budget && current > r.target, detail: r.kind === 'manual' ? metrics.manual[r.id] ?? 'Awaiting tester sign-off' : budget ? `${current} / ${r.target} allowed; monitored until promotion` : `${Math.round(current * 100) / 100} / ${r.target}` }; }); } export function coveragePassed(plan: BetaPlan, servers: Array<{ passed: boolean; game: string | null; map: string | null }>): boolean { if (!servers.length) return false; if (plan.scope === 'all') return servers.every(s => s.passed); if (plan.scope === 'quorum') return servers.filter(s => s.passed).length >= plan.quorum; const key = (s: typeof servers[number]) => plan.scope === 'game' ? s.game : plan.scope === 'map' ? s.map : s.game && s.map ? `${s.game}/${s.map}` : null; const groups = new Set(servers.map(key)); return !groups.has(null) && [...groups].every(group => servers.some(s => s.passed && key(s) === group)); }