import { db } from '../db/index.js'; import { parseGameEvent, type GameTelemetry } from '../../telemetry.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { rconGetPlayerCount, 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 { 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 { 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() }; } /** Host agent readiness remains a caller prerequisite. Old-process telemetry is excluded. */ export async function probeServerStatus(server: string, after?: string | null): Promise< { ok: true; players: number; map: string | null; source: 'rcon' | 'telemetry'; observedAt: string; receivedAt?: string } | { 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 = 'RCON unavailable'; 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', 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() }; }