Files
electric-cherry/src/lib/server/fleet/status-evidence.ts
T

130 lines
4.9 KiB
TypeScript

import { db } from '../db/index.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import {
rconGetPlayerCount,
rconStatusResult,
type RconStatusResult
} from '../integrations/rcon.js';
import { latestGameSubmission } from './lifecycle-evidence.js';
export { latestGameSubmission } from './lifecycle-evidence.js';
export interface TelemetryEvidence {
players: number;
map: string;
receivedAt: string;
}
export function telemetryReport(server: string): GameTelemetry | null {
const row = db()
.prepare('SELECT received_at, payload FROM server_telemetry WHERE server = ?')
.get(server) as { received_at: string; payload: string | null } | undefined;
if (!row?.payload) return null;
try {
const saved = JSON.parse(row.payload) as GameTelemetry;
const event = parseGameEvent(saved);
return event.server === server
? { ...event, receivedAt: row.received_at, timing: saved.timing }
: null;
} catch {
return null;
}
}
/** Only validated, recently received reports can establish current game state. */
export function telemetryEvidence(
server: string,
after: string | null = latestGameSubmission(server)
): TelemetryEvidence | null {
const report = telemetryReport(server);
if (!report) return null;
const received = Date.parse(report.receivedAt);
const age = Date.now() - received;
if (!Number.isFinite(age) || age < 0 || age > ONLINE_MAX_MS) return null;
if (after && (!Number.isFinite(Date.parse(after)) || received <= Date.parse(after)))
return null;
return { players: report.players.length, map: report.map, receivedAt: report.receivedAt };
}
/** Positive occupancy from either source always vetoes an empty-server claim. */
export async function probePlayerCount(server: string): Promise<number | null> {
return (await probePlayerEvidence(server)).players;
}
export interface PlayerEvidence {
players: number | null;
source: 'rcon' | 'telemetry' | null;
observedAt: string;
receivedAt?: string;
}
/** The same eligibility decision, with provenance from the probes already performed. */
export async function probePlayerEvidence(server: string): Promise<PlayerEvidence> {
const fromTelemetry = (value: TelemetryEvidence): PlayerEvidence => ({
players: value.players,
source: 'telemetry',
observedAt: new Date().toISOString(),
receivedAt: value.receivedAt
});
const occupied = telemetryEvidence(server);
if (occupied && occupied.players > 0) return fromTelemetry(occupied);
const players = await rconGetPlayerCount(server).catch(() => null);
// Read after the network request: a player may join while RCON is pending.
const telemetry = telemetryEvidence(server);
if (players !== null && players > 0)
return { players, source: 'rcon', observedAt: new Date().toISOString() };
if (telemetry && telemetry.players > 0) return fromTelemetry(telemetry);
if (players === 0) return { players, source: 'rcon', observedAt: new Date().toISOString() };
if (telemetry?.players === 0) return fromTelemetry(telemetry);
return { players: null, source: null, observedAt: new Date().toISOString() };
}
export type VerifiedGameStatus = {
ok: true;
players: number;
map: string | null;
observedAt: string;
} & (
| { source: 'rcon'; rcon: Extract<RconStatusResult, { ok: true }> }
| { source: 'telemetry'; receivedAt: string }
);
/** Host agent readiness remains a caller prerequisite. Old-process telemetry is excluded. */
export async function probeServerStatus(
server: string,
after?: string | null
): Promise<VerifiedGameStatus | { ok: false; reason: string; observedAt: string }> {
const current = telemetryEvidence(server, after);
if (current)
return { ok: true, ...current, source: 'telemetry', observedAt: new Date().toISOString() };
let reason: string;
try {
const rcon = await rconStatusResult(server);
const latest = telemetryEvidence(server, after);
if (latest)
return {
ok: true,
...latest,
source: 'telemetry',
observedAt: new Date().toISOString()
};
if (rcon.ok)
return {
ok: true,
players: rcon.status.players,
map: rcon.status.map,
source: 'rcon',
rcon,
observedAt: new Date().toISOString()
};
reason = rcon.reason;
} catch (err) {
reason = (err as Error).message;
}
const telemetry = telemetryEvidence(server, after);
return telemetry
? { ok: true, ...telemetry, source: 'telemetry', observedAt: new Date().toISOString() }
: { ok: false, reason, observedAt: new Date().toISOString() };
}