diff --git a/docs/beta-test-plans.md b/docs/beta-test-plans.md index b636234..4103a06 100644 --- a/docs/beta-test-plans.md +++ b/docs/beta-test-plans.md @@ -38,12 +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 first, then by name when no GUID matches. For T6, RCON's +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. 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. +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 diff --git a/src/lib/server-observation.ts b/src/lib/server-observation.ts index 5d778e3..6d2883e 100644 --- a/src/lib/server-observation.ts +++ b/src/lib/server-observation.ts @@ -47,11 +47,6 @@ 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(); -} - 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. @@ -81,34 +76,13 @@ export function selectServerObservation(server: T, 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 => guidKey(player.guid, 'telemetry'))); - 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 ?? []) { - const name = playerMatchName(player.name); - if (name) byName.set(name, byName.has(name) ? null : player); const ping = player.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. - 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(guidKey(match.guid, 'rcon')))) continue; - const ping = match.ping; - if (ping !== null && Number.isSafeInteger(ping) && ping >= 0) pings.set(guid, ping); - } } // New reports carry persisted anchors through both SSE and HTTP. Keep // the fallback for telemetry saved before server timing was included. diff --git a/tests/unit/server-observation.test.ts b/tests/unit/server-observation.test.ts index 9e48bf3..78f0baf 100644 --- a/tests/unit/server-observation.test.ts +++ b/tests/unit/server-observation.test.ts @@ -101,18 +101,16 @@ 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', () => { + 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 = '^2EyelessGreem^7'; - telemetry.players[1].name = ' _frxn.3 '; + 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({ 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]); + const selected = selectServerObservation(server({ game, rcon_status: raw }), telemetry, now); + expect(selected.roster?.map(player => player.ping)).toEqual([null, null]); }); it.each([ @@ -135,7 +133,7 @@ describe('shared server observation', () => { 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', () => { + 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'; @@ -143,7 +141,7 @@ describe('shared server observation', () => { 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', () => { + it('rejects duplicate RCON GUIDs after normalization', () => { const telemetry = report(0, 1); telemetry.players[0].guid = '7097376'; const raw = server({ roster: [ @@ -153,18 +151,7 @@ describe('shared server observation', () => { 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 }; - 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', () => { + 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 }, diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index bedc6ad..1eb9ab4 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -517,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]).toMatchObject({ ping: 151, guid: telemetry.players[2].guid, name: 'Player 3', score: 5000 }); + 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 })] }));