Compare commits

..
3 Commits
5 changed files with 104 additions and 13 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 `"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. Occupied lobbies continue RCON polling every 30 seconds. 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.
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
+16 -3
View File
@@ -20,6 +20,7 @@ export interface GameObservation {
} }
export interface ServerObservation extends GameObservation { export interface ServerObservation extends GameObservation {
name: string; name: string;
game?: string | null;
health: 'online' | 'stale' | 'offline' | 'starting' | 'provisioning'; health: 'online' | 'stale' | 'offline' | 'starting' | 'provisioning';
container_state?: string | null; container_state?: string | null;
container_checked_at?: string | null; container_checked_at?: string | null;
@@ -46,6 +47,15 @@ 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; }
} }
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. */ /** 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 { 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; if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry;
@@ -64,11 +74,14 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
// Telemetry owns roster membership. Supplement missing pings only with // Telemetry owns roster membership. Supplement missing pings only with
// unambiguous RCON identities from the current map and server lifecycle. // unambiguous RCON identities from the current map and server lifecycle.
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);
if (fresh && raw.last_map === report.map) { if (fresh && raw.last_map === report.map) {
for (const player of raw.roster ?? []) { for (const player of raw.roster ?? []) {
if (!player.guid) continue;
const ping = player.ping; const ping = player.ping;
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);
}
} }
} }
// New reports carry persisted anchors through both SSE and HTTP. Keep // New reports carry persisted anchors through both SSE and HTTP. Keep
@@ -82,7 +95,7 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
return { ...result, health: 'online', status_source: 'telemetry', last_players: players, return { ...result, health: 'online', status_source: 'telemetry', last_players: players,
last_map: report.map, last_seen_at: report.receivedAt, last_error: null, 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, 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, active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null, inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: players === 0 ? null : timing.gameStartedAt, game_started_at: players === 0 ? null : timing.gameStartedAt,
+14
View File
@@ -154,6 +154,20 @@ describe('parsePlayerRoster', () => {
expect(roster[0].name).toBe('Jenny Two Names'); 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', () => { it('strips color codes from statusResponse-dialect names too', () => {
const body = [ const body = [
'statusResponse', 'statusResponse',
+61 -3
View File
@@ -52,7 +52,7 @@ describe('shared server observation', () => {
const raw = server({ roster: [ const raw = server({ roster: [
{ ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' }, { ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' },
{ ...telemetry.players[0], num: 3, score: 20, ping: 999 }, { ...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); const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now);
expect(selected.roster).toEqual(telemetry.players.map((player, i) => ({ 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); 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 telemetry = report();
const player = { ...telemetry.players[0], num: 0, score: 100, ping: 42 as number | null, guid: telemetry.players[0].guid as string | null }; 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 === 'null') player.ping = null;
if (mode === 'negative') player.ping = -1; if (mode === 'negative') player.ping = -1;
if (mode === 'invalid') player.ping = NaN; if (mode === 'invalid') player.ping = NaN;
@@ -102,6 +101,65 @@ 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.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 = '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({ game, rcon_status: raw }), telemetry, now);
expect(selected.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 regardless of player 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', () => {
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('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 },
{ 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', () => { it('uses the latest HTTP or streamed telemetry and falls back without extending freshness', () => {
const original = server(); const original = server();
const selected = selectServerObservation(server({ rcon_status: original, telemetry: report(0, 2) }), report(-1000), now); const selected = selectServerObservation(server({ rcon_status: original, telemetry: report(0, 2) }), report(-1000), now);
+9 -6
View File
@@ -490,18 +490,21 @@ describe('database fleet and container readiness', () => {
expect(snapshot.serverNow).toBeLessThanOrEqual(Date.now()); 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 { db, inspect, rcon, fanoutStatus } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js'); const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.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' }); 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[0].ping = 42;
telemetry.players[1].guid = '7097376';
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', 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: 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); await postTelemetry(telemetry);
const events: unknown[] = []; const events: unknown[] = [];
@@ -514,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].ping).toBeNull(); 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
})] })); })] }));