From a9059754fb5d68ddc5e5439a740674ad8d794474 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 21:29:05 -0400 Subject: [PATCH] Normalize T6 hexadecimal RCON GUIDs for ping matching --- docs/beta-test-plans.md | 4 ++- src/lib/server-observation.ts | 27 ++++++++++++++----- tests/unit/server-observation.test.ts | 38 +++++++++++++++++++++++++++ tests/unit/server-status.test.ts | 12 +++++---- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/docs/beta-test-plans.md b/docs/beta-test-plans.md index 98d33ec..b636234 100644 --- a/docs/beta-test-plans.md +++ b/docs/beta-test-plans.md @@ -38,7 +38,9 @@ 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 first, then by name when no GUID matches. Name matching +matching players by GUID first, then by name when no GUID matches. 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. Name matching removes CoD color codes and surrounding whitespace, stays case-sensitive, and requires a unique name in both rosters. A player already matched by GUID cannot be reused by name. Occupied lobbies continue RCON polling every 30 seconds. diff --git a/src/lib/server-observation.ts b/src/lib/server-observation.ts index 8e35138..5d778e3 100644 --- a/src/lib/server-observation.ts +++ b/src/lib/server-observation.ts @@ -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; @@ -51,6 +52,15 @@ function playerMatchName(name: string): string { return name.replace(/\^\d/g, '').trim(); } +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(server: T, report: GameTelemetry | undefined, now: number): T { if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry; @@ -69,10 +79,11 @@ export function selectServerObservation(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(); + const guidKey = (guid: string, source: 'rcon' | 'telemetry') => playerMatchGuid(guid, server.game, source); if (fresh && raw.last_map === report.map) { const byName = new Map(); const nameCounts = new Map(); - const telemetryGuids = new Set(report.players.map(player => player.guid)); + const telemetryGuids = new Set(report.players.map(player => guidKey(player.guid, 'telemetry'))); for (const player of report.players) { const name = playerMatchName(player.name); nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1); @@ -81,18 +92,22 @@ export function selectServerObservation(server: T, const name = playerMatchName(player.name); if (name) byName.set(name, byName.has(name) ? null : player); const ping = player.ping; - if (player.guid) 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); + } } for (const player of report.players) { // A GUID match always wins, including ambiguous/invalid readings. - if (pings.has(player.guid)) continue; + const guid = guidKey(player.guid, 'telemetry'); + if (pings.has(guid)) continue; const name = playerMatchName(player.name); const match = byName.get(name); // Names must be unique in both rosters. Never borrow a player // already identified by another telemetry player's GUID. - if (!match || nameCounts.get(name) !== 1 || (match.guid && telemetryGuids.has(match.guid))) continue; + if (!match || nameCounts.get(name) !== 1 || (match.guid && telemetryGuids.has(guidKey(match.guid, 'rcon')))) continue; const ping = match.ping; - if (ping !== null && Number.isSafeInteger(ping) && ping >= 0) pings.set(player.guid, ping); + if (ping !== null && Number.isSafeInteger(ping) && ping >= 0) pings.set(guid, ping); } } // New reports carry persisted anchors through both SSE and HTTP. Keep @@ -106,7 +121,7 @@ export function selectServerObservation(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, diff --git a/tests/unit/server-observation.test.ts b/tests/unit/server-observation.test.ts index cb54955..9e48bf3 100644 --- a/tests/unit/server-observation.test.ts +++ b/tests/unit/server-observation.test.ts @@ -115,6 +115,44 @@ describe('shared server observation', () => { expect(selectServerObservation(server({ rcon_status: { ...raw, last_seen_at: at(-90_001) } }), telemetry, now).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 and reserves normalized matches before 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 without bypassing them by name', () => { + 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.each(['rcon', 'telemetry', 'case', 'blank', 'invalid-ping'])('rejects ambiguous or invalid %s name fallback', mode => { const telemetry = report(0, 2); const player = { num: 0, score: 100, ping: 42, guid: 'different-guid', name: telemetry.players[0].name }; diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 2de0a3e..bedc6ad 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -490,18 +490,20 @@ 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);