118 lines
5.8 KiB
TypeScript
118 lines
5.8 KiB
TypeScript
import type { MapMetrics } from './metrics.js';
|
||
import { metricDuration, metricPercent } from './ui/metrics.js';
|
||
|
||
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
|
||
export const HOSTING_RULES = {
|
||
pressureExpansion: 0.2,
|
||
occupancyExpansion: 0.8,
|
||
idleUtilization: 0.1,
|
||
quietPressure: 0.01,
|
||
minimumEvidenceHours: 1
|
||
} as const;
|
||
|
||
export interface MetricRecommendation {
|
||
game: string;
|
||
map: string;
|
||
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
|
||
title: string;
|
||
reason: string;
|
||
evidence: string;
|
||
outcome: string;
|
||
tentative: boolean;
|
||
}
|
||
|
||
/** Apply the same visible rules to all selected maps, including sparse observations. */
|
||
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
|
||
return maps
|
||
.map((map) => {
|
||
const utilization = map.observed > 0 ? map.active / map.observed : null;
|
||
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
|
||
const knownShare =
|
||
map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
|
||
const base = {
|
||
game: map.game,
|
||
map: map.map,
|
||
evidence:
|
||
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
|
||
(pressure === null
|
||
? 'Capacity unavailable.'
|
||
: `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
|
||
tentative:
|
||
map.observed < HOSTING_RULES.minimumEvidenceHours ||
|
||
pressure === null ||
|
||
map.capacityObserved < HOSTING_RULES.minimumEvidenceHours ||
|
||
knownShare < 0.8
|
||
};
|
||
if (utilization === null)
|
||
return {
|
||
...base,
|
||
kind: 'capacity' as const,
|
||
title:
|
||
map.unknown > 0
|
||
? 'Check occupancy reporting'
|
||
: 'Let observations accumulate',
|
||
reason:
|
||
map.unknown > 0
|
||
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
|
||
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
|
||
outcome: 'Watch for known server time in the next report.',
|
||
tentative: true
|
||
};
|
||
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion)
|
||
return {
|
||
...base,
|
||
kind: 'expand' as const,
|
||
title: 'Trial one additional lobby',
|
||
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
|
||
outcome:
|
||
'Compare all-full time and use of the additional lobby over the next comparable period.'
|
||
};
|
||
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion)
|
||
return {
|
||
...base,
|
||
kind: 'expand' as const,
|
||
title: 'Trial an extra lobby during busy hours',
|
||
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
|
||
outcome:
|
||
'Check whether players use the extra lobby, and measure capacity if the server reports it.',
|
||
tentative: true
|
||
};
|
||
if (
|
||
utilization <= HOSTING_RULES.idleUtilization &&
|
||
(pressure === null || pressure < HOSTING_RULES.quietPressure)
|
||
)
|
||
return {
|
||
...base,
|
||
kind: 'consolidate' as const,
|
||
title: 'Trial less idle hosting',
|
||
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
|
||
outcome:
|
||
'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
|
||
};
|
||
if (pressure === null)
|
||
return {
|
||
...base,
|
||
kind: 'capacity' as const,
|
||
title: 'Check capacity during busy hours',
|
||
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
|
||
outcome:
|
||
'Look for known-capacity time and all-full periods before the next hosting trial.'
|
||
};
|
||
return {
|
||
...base,
|
||
kind: 'watch' as const,
|
||
title: 'Keep the lineup and watch peaks',
|
||
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
|
||
outcome: 'Watch whether occupied time and measured capacity pressure change.'
|
||
};
|
||
})
|
||
.sort((a, b) => {
|
||
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
|
||
return (
|
||
priority[a.kind] - priority[b.kind] ||
|
||
a.map.localeCompare(b.map) ||
|
||
a.game.localeCompare(b.game)
|
||
);
|
||
});
|
||
}
|