135 lines
7.3 KiB
TypeScript
135 lines
7.3 KiB
TypeScript
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)
|
||
);
|
||
});
|
||
}
|