Match RCON player pings by unique name when GUIDs differ

This commit is contained in:
2026-10-06 21:25:03 -04:00
parent 1b42244858
commit 1779ae583b
5 changed files with 83 additions and 8 deletions
+4 -1
View File
@@ -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
+26 -2
View File
@@ -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<T extends ServerObservation>(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<T extends ServerObservation>(server: T,
// unambiguous RCON identities from the current map and server lifecycle.
const pings = new Map<string, number | null>();
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 => 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
+14
View File
@@ -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',
+36 -3
View File
@@ -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);
+3 -2
View File
@@ -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
})] }));