diff --git a/docs/beta-test-plans.md b/docs/beta-test-plans.md index 3006b5d..98d33ec 100644 --- a/docs/beta-test-plans.md +++ b/docs/beta-test-plans.md @@ -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 by GUID first, then by name when no GUID matches. 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. 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 diff --git a/src/lib/server-observation.ts b/src/lib/server-observation.ts index 8a72df8..8e35138 100644 --- a/src/lib/server-observation.ts +++ b/src/lib/server-observation.ts @@ -46,6 +46,11 @@ export function freshTelemetry(report: GameTelemetry | undefined, now: number, a try { parseGameEvent(report); return true; } catch { return false; } } +// RCON strips CoD display colors and surrounding column padding from names. +function playerMatchName(name: string): string { + return name.replace(/\^\d/g, '').trim(); +} + /** 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; @@ -65,10 +70,29 @@ export function selectServerObservation(server: T, // unambiguous RCON identities from the current map and server lifecycle. const pings = new Map(); 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)); + for (const player of report.players) { + const name = playerMatchName(player.name); + nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1); + } for (const player of raw.roster ?? []) { - if (!player.guid) continue; + const name = playerMatchName(player.name); + if (name) byName.set(name, byName.has(name) ? null : player); const ping = player.ping; - pings.set(player.guid, pings.has(player.guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0 ? null : ping); + if (player.guid) pings.set(player.guid, pings.has(player.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 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; + const ping = match.ping; + if (ping !== null && Number.isSafeInteger(ping) && ping >= 0) pings.set(player.guid, ping); } } // New reports carry persisted anchors through both SSE and HTTP. Keep diff --git a/tests/unit/rcon.test.ts b/tests/unit/rcon.test.ts index 871ee60..b775a41 100644 --- a/tests/unit/rcon.test.ts +++ b/tests/unit/rcon.test.ts @@ -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', diff --git a/tests/unit/server-observation.test.ts b/tests/unit/server-observation.test.ts index c242ada..cb54955 100644 --- a/tests/unit/server-observation.test.ts +++ b/tests/unit/server-observation.test.ts @@ -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,40 @@ describe('shared server observation', () => { expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull(); }); + it('falls back to unique names when RCON GUIDs differ or are missing', () => { + const telemetry = report(0, 2); + telemetry.players[0].name = '^2EyelessGreem^7'; + 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({ rcon_status: raw }), telemetry, now); + expect(selected.roster?.map(player => player.ping)).toEqual([91, 151]); + expect(selected.roster?.[0]).toMatchObject({ name: '^2EyelessGreem^7', guid: telemetry.players[0].guid, score: 5000 }); + expect(selectServerObservation(server({ rcon_status: { ...raw, last_seen_at: at(-90_001) } }), telemetry, now).roster?.map(player => player.ping)).toEqual([null, null]); + }); + + 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 }; + if (mode === 'telemetry') telemetry.players[1].name = '^1' + telemetry.players[0].name; + if (mode === 'case') player.name = player.name.toLowerCase(); + if (mode === 'blank') { player.name = ' '; telemetry.players[0].name = '^1'; } + if (mode === 'invalid-ping') player.ping = -1; + const raw = server({ roster: mode === 'rcon' ? [player, { ...player, guid: 'another-guid', name: '^2' + player.name }] : [player] }); + expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(player => player.ping)).toEqual([null, null]); + }); + + it('gives GUID matches priority over conflicting names and never reuses them', () => { + 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); diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 9c4698d..2de0a3e 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -501,7 +501,8 @@ describe('database fleet and container readiness', () => { rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', status: { players: 4, map: 'zombie_theater', 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: 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 +515,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: 151, 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 })] }));