import type { MapMetrics } from './metrics.js'; import { metricDuration, metricPercent } from './ui/metrics.js'; /** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */ export const POLICY_SIGNAL_RULES = { noEmptyShare: 0.02, minimumEvidenceHours: 1, minimumKnownShare: 0.8 } as const; export interface MetricRecommendation { game: string; map: string; kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch'; title: string; reason: string; evidence: string; outcome: string; tentative: boolean; policySummary: string | null; href: string | null; action: string | null; } /** Compare captured availability with the current policy; historical settings are not recorded. */ export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] { return maps .map((map): MetricRecommendation => { const availability = map.availabilityObserved + map.availabilityUnknown; const knownShare = availability > 0 ? map.availabilityObserved / availability : 0; const lobbyHours = map.observed + map.unknown; const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0; const noEmptyShare = map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null; const policy = map.policy; const base = { game: map.game, map: map.map, evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` + (map.unknown > 0 ? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. ` : '') + (noEmptyShare === null ? 'Lobby availability is unknown.' : `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) + (policy && noEmptyShare !== null ? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.` : ''), tentative: map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours || knownShare < POLICY_SIGNAL_RULES.minimumKnownShare || knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare, policySummary: policy ? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}` : null, href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null, action: policy ? 'Review policy' : null }; if (noEmptyShare === null) return { ...base, kind: 'reporting', title: map.availabilityUnknown > 0 || map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate', reason: map.availabilityUnknown > 0 || map.unknown > 0 ? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.' : 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.', outcome: 'Watch for known lobby availability in the next report.', tentative: true }; if (!policy) return { ...base, kind: 'policy', title: 'Consider adding an autoscaling policy', reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.', outcome: 'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.', href: '/autoscaling', action: 'Add policy' }; if (policy.pausedReason || policy.mode === 'off') return { ...base, kind: 'policy', title: policy.pausedReason ? 'Review and resume the autoscaling policy' : 'Review and enable the autoscaling policy', reason: policy.pausedReason ? `The current policy is paused: ${policy.pausedReason}` : 'The current policy is off, so it cannot maintain the spare target.', outcome: 'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.' }; if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) { if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum) return { ...base, kind: 'maximum', title: 'Consider raising the policy maximum', reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`, outcome: 'Review host resources and create time, then compare empty-lobby availability after a maximum change.' }; return { ...base, kind: 'spares', title: 'Review the spare target and create time', reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`, outcome: 'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.' }; } return { ...base, kind: 'watch', title: 'Keep the policy and watch availability', reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.', outcome: 'Watch for recurring periods without a reported empty lobby.' }; }) .sort((a, b) => { const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 }; return ( priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game) ); }); }