import type { ScalingDecision, ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js'; import { parseServerName, zombieMaps } from '../../map-aliases.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js'; export interface EmptyObservation { identity: string; observedAt: number | null; emptySince: number | null; evaluatedAt: number; } /** Only distinct, continuous evidence can advance an empty interval. */ export function emptyObservation( member: ScalingMember, previous: EmptyObservation | undefined, now: number ): EmptyObservation { const stamp = member.observedAt; const empty = member.state === 'empty' && stamp !== null && stamp <= now && now - stamp <= ONLINE_MAX_MS; const continuous = empty && previous?.identity === member.identity && previous.emptySince !== null && (!member.emptyStartedAt || previous.emptySince >= member.emptyStartedAt) && previous.observedAt !== null && stamp >= previous.observedAt && stamp - previous.observedAt <= ONLINE_MAX_MS && now >= previous.evaluatedAt && now - previous.evaluatedAt <= ONLINE_MAX_MS; return { identity: member.identity, observedAt: stamp, emptySince: empty ? (continuous ? previous!.emptySince : stamp) : null, evaluatedAt: now }; } export function planScaling( policy: ScalingPolicy, snapshot: ScalingSnapshot, now: number ): ScalingDecision { if (policy.mapSlug === '*') { const maps = zombieMaps(policy.game); const decisions = maps.map((map) => { const matches = (name: string) => (snapshot.members.find((m) => m.name === name)?.mapSlug ?? parseServerName(name)?.mapSlug) === map.slug; return planScaling( { ...policy, mapSlug: map.slug }, { ...snapshot, members: snapshot.members.filter((m) => matches(m.name)), reservedNames: snapshot.reservedNames.filter(matches) }, now ); }); // Serialize game-wide work and prefer replenishing any map before trimming another. const pending = snapshot.members.find( (m) => m.state === 'creating' || m.state === 'removing' ); const selected = decisions.find((d) => d.action === 'create') ?? decisions.find((d) => d.action === 'destroy') ?? decisions.find((d) => d.deficit > 0 || d.unknown > 0) ?? decisions[0]; const map = maps[decisions.indexOf(selected)]; return { ...selected, action: pending ? 'wait' : selected.action, server: pending ? null : selected.server, reason: pending ? (pending.blocker ?? `Waiting for ${pending.name}: ${pending.state}.`) : `${map.friendly}: ${selected.reason}`, desired: decisions.reduce((n, d) => n + d.desired, 0), deficit: decisions.reduce((n, d) => n + d.deficit, 0), occupied: decisions.reduce((n, d) => n + d.occupied, 0), empty: decisions.reduce((n, d) => n + d.empty, 0), pending: decisions.reduce((n, d) => n + d.pending, 0), unknown: decisions.reduce((n, d) => n + d.unknown, 0) }; } const { members } = snapshot; const occupied = members.filter((m) => m.state === 'occupied').length, empty = members.filter((m) => m.state === 'empty').length; const pending = members.filter((m) => m.state === 'creating' || m.state === 'removing').length; const unknown = members.filter((m) => m.state === 'unknown' || m.state === 'stopped').length; const desired = Math.min(policy.maximum, Math.max(policy.minimum, occupied + policy.spares)); const result = ( action: ScalingDecision['action'], reason: string, server: string | null = null ): ScalingDecision => ({ action, reason, server, desired, deficit: Math.max(0, policy.spares - empty), occupied, empty, pending, unknown }); if (policy.mode === 'off') return result('wait', policy.pausedReason ?? 'Policy is off.'); if (policy.pausedReason) return result('wait', policy.pausedReason); if (snapshot.blockers.length) return result('wait', snapshot.blockers[0]); const blocked = members.find((m) => !['occupied', 'empty'].includes(m.state)); if (blocked) return result('wait', blocked.blocker ?? `Waiting for ${blocked.name}: ${blocked.state}.`); if ( members.some( (m) => m.observedAt === null || m.observedAt > now || now - m.observedAt > ONLINE_MAX_MS ) ) return result('wait', 'Waiting for fresh occupancy evidence.'); if (occupied + empty < desired) { // Brief joins do not increase spare demand until their match reaches round two. // Keep the ordinary desired count for reporting and surplus removal so a // new match or missing telemetry cannot retire an existing spare. const established = members.filter( (m) => m.state === 'occupied' && m.round !== null && m.round >= 2 ).length; const growthTarget = Math.min( policy.maximum, Math.max(policy.minimum, established + policy.spares) ); if (occupied + empty >= growthTarget) return result( 'wait', 'Waiting for occupied lobbies to reach round 2 before adding a spare.' ); if (snapshot.reservedNames.length >= policy.maximum) return result('wait', 'Maximum lobby reservations reached.'); if (!snapshot.keyPool.available) return result( 'wait', snapshot.keyPool.total ? `All ${policy.game.toUpperCase()} Plutonium keys are in use. Waiting for a free key.` : `No Plutonium keys are saved for ${policy.game.toUpperCase()}.` ); const available = Array.from( { length: 20 }, (_, index) => `${policy.game}_zombie_${policy.mapSlug}_${index + 1}` ).find((name) => !snapshot.reservedNames.includes(name)); return available ? result('create', 'Replenish empty lobby capacity.', available) : result('wait', 'No unused lobby identity is available.'); } if (occupied + empty > desired && empty > policy.spares && occupied + empty > policy.minimum) { // Protect the lowest-numbered spares before checking removal timers. // Lobby 1 must survive even when a higher lobby has only just emptied. const orderedEmpty = members .filter((m) => m.state === 'empty') .sort( (a, b) => (parseServerName(a.name)?.lobby ?? Number.MAX_SAFE_INTEGER) - (parseServerName(b.name)?.lobby ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name, 'en', { numeric: true }) ); const requiredEmpty = Math.max(policy.spares, policy.minimum - occupied); const candidates = orderedEmpty .slice(requiredEmpty) .reverse() .filter( (m) => m.emptySince !== null && m.observedAt! - m.emptySince >= policy.emptyMinutes * 60_000 ); if (candidates.length) return result('destroy', 'Surplus lobby has remained empty.', candidates[0].name); return result('wait', 'Waiting for a continuous empty interval.'); } return result( 'wait', empty < policy.spares ? 'Maximum reached; the empty spare target cannot be met.' : 'Empty spare target is satisfied.' ); }