Remove name fallback from RCON ping matching
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
})] }));
|
})] }));
|
||||||
|
|||||||
Reference in New Issue
Block a user