Normalize T6 hexadecimal RCON GUIDs for ping matching

This commit is contained in:
2026-10-06 21:29:05 -04:00
parent 1779ae583b
commit a9059754fb
4 changed files with 69 additions and 12 deletions
+3 -1
View File
@@ -38,7 +38,9 @@ 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. Name matching
matching players by GUID first, then by name when no GUID matches. 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.
+21 -6
View File
@@ -20,6 +20,7 @@ export interface GameObservation {
}
export interface ServerObservation extends GameObservation {
name: string;
game?: string | null;
health: 'online' | 'stale' | 'offline' | 'starting' | 'provisioning';
container_state?: string | null;
container_checked_at?: string | null;
@@ -51,6 +52,15 @@ 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.
// Keep integer precision for IDs beyond Number.MAX_SAFE_INTEGER.
if (source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(guid)) return BigInt(`0x${guid.replace(/^0x/i, '')}`).toString();
if (source === 'telemetry' && /^\d+$/.test(guid)) return BigInt(guid).toString();
return guid;
}
/** 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;
@@ -69,10 +79,11 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
// Telemetry owns roster membership. Supplement missing pings only with
// unambiguous RCON identities from the current map and server lifecycle.
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 => player.guid));
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);
@@ -81,18 +92,22 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
const name = playerMatchName(player.name);
if (name) byName.set(name, byName.has(name) ? null : player);
const ping = player.ping;
if (player.guid) pings.set(player.guid, pings.has(player.guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0 ? null : 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.
if (pings.has(player.guid)) continue;
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(match.guid))) continue;
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(player.guid, ping);
if (ping !== null && Number.isSafeInteger(ping) && ping >= 0) pings.set(guid, ping);
}
}
// New reports carry persisted anchors through both SSE and HTTP. Keep
@@ -106,7 +121,7 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
return { ...result, health: 'online', status_source: 'telemetry', last_players: players,
last_map: report.map, last_seen_at: report.receivedAt, last_error: null,
roster: report.players.map(player => ({ num: null, score: 'score' in player ? player.score : null,
ping: player.ping ?? pings.get(player.guid) ?? null, guid: player.guid, name: player.name })),
ping: player.ping ?? pings.get(guidKey(player.guid, 'telemetry')) ?? null, guid: player.guid, name: player.name })),
active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: players === 0 ? null : timing.gameStartedAt,
+38
View File
@@ -115,6 +115,44 @@ describe('shared server observation', () => {
expect(selectServerObservation(server({ rcon_status: { ...raw, last_seen_at: at(-90_001) } }), telemetry, now).roster?.map(player => player.ping)).toEqual([null, null]);
});
it.each([
['6C4C20', '7097376'], ['6c45dd', '7095773'], ['00006C4C20', '0007097376'],
['0x6C45DD', '7095773'], ['10', '16'], ['20000000000001', '9007199254740993']
])('matches T6 hex GUID %s to decimal %s despite different names', (hex, decimal) => {
const telemetry = report(0, 1);
telemetry.players[0].guid = decimal;
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: hex, name: 'Previous name' }] });
const selected = selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now);
expect(selected.roster?.[0]).toMatchObject({ guid: decimal, name: 'Player 1', ping: 91, score: 5000 });
expect(selected.rcon_status?.roster?.[0].guid).toBe(hex);
});
it.each(['t5', undefined])('keeps decimal-looking GUIDs literal for game %s', game => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Previous name' }] });
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', () => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({ roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Player 1' }] });
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', () => {
const telemetry = report(0, 1);
telemetry.players[0].guid = '7097376';
const raw = server({ roster: [
{ num: 0, score: 100, ping: 91, guid: '6C4C20', name: 'Player 1' },
{ num: 1, score: 100, ping: 42, guid: '006c4c20', name: 'Other player' }
] });
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 };
+7 -5
View File
@@ -490,18 +490,20 @@ describe('database fleet and container readiness', () => {
expect(snapshot.serverNow).toBeLessThanOrEqual(Date.now());
});
it('supplements telemetry with RCON pings consistently in REST and status events', async () => {
it.each(['t5', 't6'])('supplements %s telemetry with RCON pings consistently in REST and status events', async game => {
const { db, inspect, rcon, fanoutStatus } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
db.prepare("UPDATE servers SET last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(new Date().toISOString());
const map = game === 't6' ? 'zm_transit' : 'zombie_theater';
db.prepare("UPDATE servers SET game = ?, map = ?, last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(game, map, new Date().toISOString());
inspect.mockResolvedValue({ ok: true, state: 'running' });
const telemetry = gameEvent('heartbeat', 15, 3, 'managed');
const telemetry = gameEvent('heartbeat', 15, 3, 'managed', map);
telemetry.players[0].ping = 42;
telemetry.players[1].guid = '7097376';
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
status: { players: 4, map: 'zombie_theater', gametype: 'zom', roster: [
status: { players: 4, map, 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: game === 't6' ? '6C4C20' : 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);