Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baee64a067 | ||
|
|
a9059754fb | ||
|
|
1779ae583b |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
})] }));
|
})] }));
|
||||||
|
|||||||
Reference in New Issue
Block a user