Remove name fallback from RCON ping matching

This commit is contained in:
2026-10-06 21:31:13 -04:00
parent a9059754fb
commit baee64a067
4 changed files with 12 additions and 53 deletions
+3 -5
View File
@@ -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 `"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. zero for a missing measurement. Older reporters without this field remain valid.
Fleet prefers telemetry ping and fills missing readings from fresh RCON status, 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 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 conversion; stored GUIDs remain unchanged. T5 GUIDs are compared as reported.
removes CoD color codes and surrounding whitespace, stays case-sensitive, and Occupied lobbies continue RCON polling every 30 seconds.
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 supplements only ping; telemetry still supplies roster membership and scores.
RCON pings expire after 90 seconds and must match the current map and server RCON pings expire after 90 seconds and must match the current map and server
lifecycle. Missing or ambiguous matches display `—`. Telemetry ping is retained lifecycle. Missing or ambiguous matches display `—`. Telemetry ping is retained
-26
View File
@@ -47,11 +47,6 @@ export function freshTelemetry(report: GameTelemetry | undefined, now: number, a
try { parseGameEvent(report); return true; } catch { return false; } 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 { function playerMatchGuid(guid: string, game: string | null | undefined, source: 'rcon' | 'telemetry'): string {
if (game !== 't6') return guid; if (game !== 't6') return guid;
// T6 status prints hexadecimal account IDs; telemetry sends decimal IDs. // T6 status prints hexadecimal account IDs; telemetry sends decimal IDs.
@@ -81,34 +76,13 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
const pings = new Map<string, number | null>(); const pings = new Map<string, number | null>();
const guidKey = (guid: string, source: 'rcon' | 'telemetry') => playerMatchGuid(guid, server.game, source); const guidKey = (guid: string, source: 'rcon' | 'telemetry') => playerMatchGuid(guid, server.game, source);
if (fresh && raw.last_map === report.map) { if (fresh && raw.last_map === report.map) {
const byName = new Map<string, StatusPlayer | null>();
const nameCounts = new Map<string, number>();
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 ?? []) { 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; const ping = player.ping;
if (player.guid) { if (player.guid) {
const guid = guidKey(player.guid, 'rcon'); const guid = guidKey(player.guid, 'rcon');
pings.set(guid, pings.has(guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0 ? null : ping); 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 // New reports carry persisted anchors through both SSE and HTTP. Keep
// the fallback for telemetry saved before server timing was included. // the fallback for telemetry saved before server timing was included.
+8 -21
View File
@@ -101,18 +101,16 @@ describe('shared server observation', () => {
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull(); 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); const telemetry = report(0, 2);
telemetry.players[0].name = '^2EyelessGreem^7'; telemetry.players[0].name = 'EyelessGreem';
telemetry.players[1].name = ' _frxn.3 '; telemetry.players[1].name = '_frxn.3';
const raw = server({ roster: [ const raw = server({ roster: [
{ num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' }, { num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' },
{ num: 1, score: 150, ping: 151, guid: null, name: '_frxn.3' } { num: 1, score: 150, ping: 151, guid: null, name: '_frxn.3' }
] }); ] });
const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now); const selected = selectServerObservation(server({ game, rcon_status: raw }), telemetry, now);
expect(selected.roster?.map(player => player.ping)).toEqual([91, 151]); expect(selected.roster?.map(player => player.ping)).toEqual([null, null]);
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([ 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]); 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); const telemetry = report(0, 2);
telemetry.players[0].guid = '10'; telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16'; 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]); 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); const telemetry = report(0, 1);
telemetry.players[0].guid = '7097376'; telemetry.players[0].guid = '7097376';
const raw = server({ roster: [ 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(); 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 => { it('matches GUIDs regardless of conflicting names', () => {
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 telemetry = report(0, 2);
const raw = server({ roster: [ const raw = server({ roster: [
{ num: 0, score: 100, ping: 42, guid: telemetry.players[1].guid, name: telemetry.players[0].name }, { num: 0, score: 100, ping: 42, guid: telemetry.players[1].guid, name: telemetry.players[0].name },
+1 -1
View File
@@ -517,7 +517,7 @@ describe('database fleet and container readiness', () => {
expect(server.roster).toHaveLength(3); expect(server.roster).toHaveLength(3);
expect(server.roster[0]).toMatchObject({ ping: 42, score: 5000 }); expect(server.roster[0]).toMatchObject({ ping: 42, score: 5000 });
expect(server.roster[1]).toMatchObject({ ping: 30, score: 5000, name: 'Player 2' }); 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({ expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at
})] })); })] }));