Compare commits

..
3 Commits
5 changed files with 104 additions and 13 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ latency measurement in milliseconds, as a nonnegative JSON integer (for example,
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
zero for a missing measurement. Older reporters without this field remain valid.
Fleet prefers telemetry ping and fills missing readings from fresh RCON status,
matching players by GUID. Occupied lobbies continue RCON polling every 30 seconds.
matching players only by GUID. For T6, RCON's
hexadecimal GUIDs are compared with telemetry's decimal GUIDs using exact integer
conversion; stored GUIDs remain unchanged. T5 GUIDs are compared as reported.
Occupied lobbies continue RCON polling every 30 seconds.
RCON supplements only ping; telemetry still supplies roster membership and scores.
RCON pings expire after 90 seconds and must match the current map and server
lifecycle. Missing or ambiguous matches display `—`. Telemetry ping is retained
+16 -3
View File
@@ -20,6 +20,7 @@ export interface GameObservation {
}
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;
@@ -46,6 +47,15 @@ export function freshTelemetry(report: GameTelemetry | undefined, now: number, a
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;
@@ -64,11 +74,14 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
// 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 ?? []) {
if (!player.guid) continue;
const ping = player.ping;
pings.set(player.guid, pings.has(player.guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0 ? null : 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
@@ -82,7 +95,7 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
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(player.guid) ?? null, guid: player.guid, name: player.name })),
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: players === 0 ? null : timing.gameStartedAt,
+14
View File
@@ -154,6 +154,20 @@ describe('parsePlayerRoster', () => {
expect(roster[0].name).toBe('Jenny Two Names');
});
it('parses T6 status with bot column and right-aligned hexadecimal GUIDs', () => {
const body = [
'map: zm_transit',
'num score bot ping guid name lastmsg address qport rate',
'--- ----- --- ---- -------------------------------- ---------------- ------- --------------------- ------ -----',
' 0 940 0 91 6C4C20 EyelessGreem 50 192.0.2.137:4976 40123 20000',
' 1 150 0 151 6C45DD _frxn.3 50 198.51.100.1:4976 8140 20000'
].join('\n');
expect(parsePlayerRoster(body)).toEqual([
{ num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' },
{ num: 1, score: 150, ping: 151, guid: '6C45DD', name: '_frxn.3' }
]);
});
it('strips color codes from statusResponse-dialect names too', () => {
const body = [
'statusResponse',
+61 -3
View File
@@ -52,7 +52,7 @@ describe('shared server observation', () => {
const raw = server({ roster: [
{ ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' },
{ ...telemetry.players[0], num: 3, score: 20, ping: 999 },
{ ...telemetry.players[2], num: 4, score: 30, ping: 45, guid: 'different-player' }
{ ...telemetry.players[2], num: 4, score: 30, ping: 45, guid: 'different-player', name: 'Different player' }
] });
const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now);
expect(selected.roster).toEqual(telemetry.players.map((player, i) => ({
@@ -91,10 +91,9 @@ describe('shared server observation', () => {
expect(selectServerObservation(server({ rcon_status: { ...raw, last_error: 'timeout', rcon_failures: 1 } }), telemetry, now).roster?.[0].ping).toBe(42);
});
it.each(['duplicate', 'missing-guid', 'null', 'negative', 'invalid'])('rejects %s ping matches', mode => {
it.each(['duplicate', 'null', 'negative', 'invalid'])('rejects %s ping matches', mode => {
const telemetry = report();
const player = { ...telemetry.players[0], num: 0, score: 100, ping: 42 as number | null, guid: telemetry.players[0].guid as string | null };
if (mode === 'missing-guid') player.guid = null;
if (mode === 'null') player.ping = null;
if (mode === 'negative') player.ping = -1;
if (mode === 'invalid') player.ping = NaN;
@@ -102,6 +101,65 @@ describe('shared server observation', () => {
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull();
});
it.each(['t5', 't6'])('does not match %s players by name when GUIDs differ or are missing', game => {
const telemetry = report(0, 2);
telemetry.players[0].name = 'EyelessGreem';
telemetry.players[1].name = '_frxn.3';
const raw = server({ roster: [
{ num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' },
{ num: 1, score: 150, ping: 151, guid: null, name: '_frxn.3' }
] });
const selected = selectServerObservation(server({ game, rcon_status: raw }), telemetry, now);
expect(selected.roster?.map(player => player.ping)).toEqual([null, null]);
});
it.each([
['6C4C20', '7097376'], ['6c45dd', '7095773'], ['00006C4C20', '0007097376'],
['0x6C45DD', '7095773'], ['10', '16'], ['20000000000001', '9007199254740993']
])('matches T6 hex GUID %s to decimal %s despite different names', (hex, decimal) => {
const telemetry = report(0, 1);
telemetry.players[0].guid = decimal;
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: hex, name: 'Previous name' }] });
const selected = selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now);
expect(selected.roster?.[0]).toMatchObject({ guid: decimal, name: 'Player 1', ping: 91, score: 5000 });
expect(selected.rcon_status?.roster?.[0].guid).toBe(hex);
});
it.each(['t5', undefined])('keeps decimal-looking GUIDs literal for game %s', game => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Previous name' }] });
expect(selectServerObservation(server({ game, rcon_status: raw }), telemetry, now).roster?.map(player => player.ping)).toEqual([91, null]);
});
it('treats digit-only T6 GUIDs as hexadecimal regardless of player names', () => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Player 1' }] });
expect(selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now).roster?.map(player => player.ping)).toEqual([null, 91]);
});
it('rejects duplicate RCON GUIDs after normalization', () => {
const telemetry = report(0, 1);
telemetry.players[0].guid = '7097376';
const raw = server({ roster: [
{ num: 0, score: 100, ping: 91, guid: '6C4C20', name: 'Player 1' },
{ num: 1, score: 100, ping: 42, guid: '006c4c20', name: 'Other player' }
] });
expect(selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull();
});
it('matches GUIDs regardless of conflicting names', () => {
const telemetry = report(0, 2);
const raw = server({ roster: [
{ num: 0, score: 100, ping: 42, guid: telemetry.players[1].guid, name: telemetry.players[0].name },
{ num: 1, score: 100, ping: 99, guid: 'another-guid', name: telemetry.players[1].name }
] });
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(player => player.ping)).toEqual([null, 42]);
});
it('uses the latest HTTP or streamed telemetry and falls back without extending freshness', () => {
const original = server();
const selected = selectServerObservation(server({ rcon_status: original, telemetry: report(0, 2) }), report(-1000), now);
+9 -6
View File
@@ -490,18 +490,21 @@ describe('database fleet and container readiness', () => {
expect(snapshot.serverNow).toBeLessThanOrEqual(Date.now());
});
it('supplements telemetry with RCON pings consistently in REST and status events', async () => {
it.each(['t5', 't6'])('supplements %s telemetry with RCON pings consistently in REST and status events', async game => {
const { db, inspect, rcon, fanoutStatus } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
db.prepare("UPDATE servers SET last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(new Date().toISOString());
const map = game === 't6' ? 'zm_transit' : 'zombie_theater';
db.prepare("UPDATE servers SET game = ?, map = ?, last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(game, map, new Date().toISOString());
inspect.mockResolvedValue({ ok: true, state: 'running' });
const telemetry = gameEvent('heartbeat', 15, 3, 'managed');
const telemetry = gameEvent('heartbeat', 15, 3, 'managed', map);
telemetry.players[0].ping = 42;
telemetry.players[1].guid = '7097376';
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
status: { players: 4, map: 'zombie_theater', gametype: 'zom', roster: [
status: { players: 4, map, gametype: 'zom', roster: [
{ num: 0, guid: telemetry.players[0].guid, name: 'Old name', score: 50, ping: 100 },
{ num: 1, guid: telemetry.players[1].guid, name: 'Another old name', score: 100, ping: 30 }
{ num: 1, guid: game === 't6' ? '6C4C20' : telemetry.players[1].guid, name: 'Another old name', score: 100, ping: 30 },
{ num: 2, guid: '6C45DD', name: telemetry.players[2].name, score: 150, ping: 151 }
] } });
await postTelemetry(telemetry);
const events: unknown[] = [];
@@ -514,7 +517,7 @@ describe('database fleet and container readiness', () => {
expect(server.roster).toHaveLength(3);
expect(server.roster[0]).toMatchObject({ ping: 42, score: 5000 });
expect(server.roster[1]).toMatchObject({ ping: 30, score: 5000, name: 'Player 2' });
expect(server.roster[2].ping).toBeNull();
expect(server.roster[2]).toMatchObject({ ping: null, guid: telemetry.players[2].guid, name: 'Player 3', score: 5000 });
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at
})] }));