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
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
-26
View File
@@ -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<T extends ServerObservation>(server: T,
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) {
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 ?? []) {
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.
+8 -21
View File
@@ -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 },
+1 -1
View File
@@ -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
})] }));