Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -1,72 +1,170 @@
|
||||
import type { ScalingDecision, ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
|
||||
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 }
|
||||
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 {
|
||||
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 };
|
||||
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 {
|
||||
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);
|
||||
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 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) };
|
||||
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 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 });
|
||||
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.');
|
||||
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) {
|
||||
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 (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 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);
|
||||
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.');
|
||||
return result(
|
||||
'wait',
|
||||
empty < policy.spares
|
||||
? 'Maximum reached; the empty spare target cannot be met.'
|
||||
: 'Empty spare target is satisfied.'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user