Files
electric-cherry/src/lib/server-observation.ts
T
2026-10-09 19:41:55 -04:00

199 lines
7.4 KiB
TypeScript

import { freshStatusTimestamp } from './server-freshness.js';
import { parseGameEvent, type GameTelemetry } from './telemetry.js';
export interface StatusPlayer {
num: number | null;
score: number | null;
ping: number | null;
guid: string | null;
name: string;
}
export interface GameObservation {
last_players: number | null;
last_map: string | null;
last_gametype: string | null;
last_seen_at: string | null;
last_error: string | null;
/** Consecutive failed monitoring checks; absent on older snapshots. */
rcon_failures?: number;
roster: StatusPlayer[] | null;
}
export interface ServerObservation extends GameObservation {
name: string;
game?: string | null;
health: 'online' | 'stale' | 'offline' | 'starting' | 'provisioning';
container_state?: string | null;
container_checked_at?: string | null;
last_checked_at?: string | null;
telemetry_after?: string | null;
status_source?: 'telemetry' | 'rcon' | null;
rcon_status?: GameObservation;
telemetry?: GameTelemetry;
active_since?: string | null;
inactive_since?: string | null;
game_started_at?: string | null;
game_ended_at?: string | null;
}
/** Keep a first transient monitoring failure in diagnostics without demoting health. */
export function rconHealthError(
status: Pick<GameObservation, 'last_error' | 'rcon_failures'>
): string | null {
const reason = status.last_error?.split(':')[0];
return status.rcon_failures === 1 && (reason === 'timeout' || reason === 'send-error')
? null
: status.last_error;
}
export function freshTelemetry(
report: GameTelemetry | undefined,
now: number,
after?: string | null
): report is GameTelemetry {
if (!report || !freshStatusTimestamp(report.receivedAt, now)) return false;
if (
after &&
(!Number.isFinite(Date.parse(after)) || Date.parse(report.receivedAt) <= Date.parse(after))
)
return false;
try {
parseGameEvent(report);
return true;
} catch {
return false;
}
}
function playerMatchGuid(
guid: string,
game: string | null | undefined,
source: 'rcon' | 'telemetry'
): string {
if (game !== 't6') return guid;
// T6 status prints hexadecimal account IDs; telemetry sends decimal IDs.
// Keep integer precision for IDs beyond Number.MAX_SAFE_INTEGER.
if (source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(guid))
return BigInt(`0x${guid.replace(/^0x/i, '')}`).toString();
if (source === 'telemetry' && /^\d+$/.test(guid)) return BigInt(guid).toString();
return guid;
}
/** Display policy: use one current roster/count source, keeping raw RCON for diagnostics/fallback. */
export function selectServerObservation<T extends ServerObservation>(
server: T,
report: GameTelemetry | undefined,
now: number
): T {
if (
server.telemetry &&
(!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))
)
report = server.telemetry;
const raw = server.rcon_status ?? server;
const result: T = {
...server,
...raw,
rcon_status: raw,
last_error: rconHealthError(raw),
telemetry: report,
status_source: raw.last_seen_at ? 'rcon' : null
};
const containerUnknown = server.container_state === 'unknown';
if (
['provisioning', 'starting'].includes(server.health) ||
(server.container_state && server.container_state !== 'running' && !containerUnknown) ||
(!server.container_state && server.health === 'offline')
) {
return {
...result,
health: server.health,
last_error: null,
last_players: null,
roster: null,
status_source: null
};
}
const checked = server.container_checked_at ?? server.last_checked_at ?? raw.last_seen_at;
const containerFresh =
server.container_state === 'running' && freshStatusTimestamp(checked ?? null, now);
const after =
!server.telemetry_after ||
Date.parse(raw.last_seen_at ?? '') > Date.parse(server.telemetry_after);
const fresh = after && freshStatusTimestamp(raw.last_seen_at, now) && !result.last_error;
if (
containerFresh &&
report?.server === server.name &&
freshTelemetry(report, now, server.telemetry_after)
) {
const players = report.players.length;
// Telemetry owns roster membership. Supplement missing pings only with
// unambiguous RCON identities from the current map and server lifecycle.
const pings = new Map<string, number | null>();
const guidKey = (guid: string, source: 'rcon' | 'telemetry') =>
playerMatchGuid(guid, server.game, source);
if (fresh && raw.last_map === report.map) {
for (const player of raw.roster ?? []) {
const ping = player.ping;
if (player.guid) {
const guid = guidKey(player.guid, 'rcon');
pings.set(
guid,
pings.has(guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0
? null
: ping
);
}
}
}
// New reports carry persisted anchors through both SSE and HTTP. Keep
// the fallback for telemetry saved before server timing was included.
const timing = report.timing?.server ?? {
activeSince: server.active_since ?? report.receivedAt,
inactiveSince: server.inactive_since ?? report.receivedAt,
gameStartedAt:
report.event === 'game_start' ? report.receivedAt : server.game_started_at,
gameEndedAt:
report.event === 'game_start'
? null
: report.event === 'game_end'
? report.receivedAt
: server.game_ended_at
};
return {
...result,
health: 'online',
status_source: 'telemetry',
last_players: players,
last_map: report.map,
last_seen_at: report.receivedAt,
last_error: null,
roster: report.players.map((player) => ({
num: null,
score: 'score' in player ? player.score : null,
ping: player.ping ?? pings.get(guidKey(player.guid, 'telemetry')) ?? null,
guid: player.guid,
name: player.name
})),
active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: timing.gameStartedAt,
game_ended_at: timing.gameEndedAt
};
}
// A current authenticated RCON reply can establish game liveness when
// inspection is unavailable, without claiming the container was inspected.
const containerExpired = !containerUnknown && !!server.container_checked_at && !containerFresh;
const age = now - Date.parse(raw.last_seen_at ?? '');
const health =
fresh && !containerExpired
? 'online'
: after && age >= 0 && age <= 120_000
? 'stale'
: 'offline';
return {
...result,
health,
last_error: health === 'online' ? result.last_error : raw.last_error
};
}