Files
tws-zombies-scripts/tools/check_telemetry.mjs
T

347 lines
19 KiB
JavaScript

// Run the actual reporter queue/response functions with HTTP, disk and VM shims.
// This validates state transitions, not engine linking or live gameplay.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
function functions(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const found = new Map();
const headers = /^(telemetry_\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(protectedSource));) {
let end = headers.lastIndex, depth = 1;
while (depth && end < protectedSource.length) {
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
assert.equal(depth, 0);
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost');
body = body.replace(/player getGuid\(\)/g, 'getGuid(player)');
body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify(');
body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];');
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;');
body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;');
body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
const generator = ['telemetry_sender', 'telemetry_game_end_watcher'].includes(match[1]);
found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
function disk() {
return { entries: [], failStore: false, failRemove: false, calls: [] };
}
let nextMatch = 0;
function engine(game, storage = disk(), players = []) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_telemetry.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['telemetry_heartbeat_watcher',
'telemetry_round_watcher', 'telemetry_watchdog', 'telemetry_log']);
const sent = [], responses = [], logs = [], notifications = [];
const queue = storage.entries.map(e => ({ ...e, stored: true, captured: 1000 }));
const c = vm.createContext({
level: { telemetry_server: 'test-server', telemetry_url: 'https://test.invalid/telemetry', telemetry_token: 'test-token',
telemetry_started: false, telemetry_ended: false, telemetry_last_round: -1, round_number: 0,
telemetry_queue: queue, telemetry_queue_read: 0, telemetry_queue_write: queue.length,
telemetry_sequence: 0, telemetry_failed: 0, telemetry_completed: 0, telemetry_accepted: 0,
telemetry_http_rejected: 0, telemetry_failure_streak: 0, telemetry_sender_state: 'starting' },
GetDvar: key => key === 'mapname' ? 'test-map' : '',
getTime: () => 1000,
isDefined: value => value !== undefined && value !== null,
int: Math.trunc,
// GSC associative arrays serialize as objects in the native plugin.
jsonSerialize: value => JSON.stringify(Array.isArray(value) &&
Object.keys(value).some(key => !/^\d+$/.test(key))
? Object.fromEntries(Object.entries(value)) : value),
getSubStr: (value, start, end) => value.slice(start, end),
strTok: (value, delimiter) => value.split(delimiter),
getPlayers: () => players,
getGuid: player => player.guid,
telemetry_log: (verbosity, text) => logs.push(text),
notify: event => notifications.push(event),
telemetryNewMatch: () => `boot-${++nextMatch}`,
telemetryStore: (server, url, body) => {
storage.calls.push('store');
if (storage.failStore) return false;
const data = JSON.parse(body);
const existing = storage.entries.find(e => e.match === data.match && e.id === data.seq);
if (existing) assert.equal(existing.body, body);
else storage.entries.push({ match: data.match, id: data.seq, event: data.event, round: data.round ?? 0, body });
return true;
},
telemetryRemove: (server, url, match, seq) => {
storage.calls.push('remove');
if (storage.failRemove) return false;
storage.entries = storage.entries.filter(e => e.match !== match || e.id !== seq);
return true;
},
// Native parser behavior is tested separately against the compiled C++.
telemetryResponse: response => {
try {
const data = JSON.parse(response);
return { accepted: data?.ok === true && response.length < 20480, match_id: String(data?.telemetry?.timing?.matchId ?? '') };
} catch { return { accepted: false, match_id: '' }; }
},
httpPost: (url, options) => {
storage.calls.push('send');
sent.push({ ...options, headers: { ...options.headers } });
return responses[0]?.noHandle ? undefined : {};
},
httpComplete: () => {
assert(responses.length, 'unexpected HTTP request');
const response = responses.shift();
response.during?.();
return [response.body ?? '{"ok":true,"telemetry":{"timing":{"matchId":921}}}', response.success ?? true,
response.error, response.missingDiagnostics ? undefined : { http_status: response.status ?? 200 }];
},
});
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
return { c, all, source, sent, responses, logs, storage, notifications };
}
for (const game of ['t5', 't6']) {
const player = () => ({ name: 'Josh', guid: '12345678901234567', score: 32000, score_total: 62000,
stats: { kills: 420, headshots: 160, downs: 1, revives: 0 },
pers: { kills: 420, headshots: 160, downs: 1, revives: 0, score: 32000 } });
check(`${game}: reports balance and match total separately, with an identity-only start`, () => {
const players = [player()];
const { c } = engine(game, disk(), players);
c.telemetry_game_start();
assert.deepEqual(JSON.parse(c.level.telemetry_queue[0].body).players,
[{ name: 'Josh', guid: '12345678901234567' }]);
c.level.round_number = 17;
c.telemetry_report_round();
const initial = JSON.parse(c.level.telemetry_queue[1].body).players[0];
assert.deepEqual(initial, { name: 'Josh', guid: '12345678901234567', kills: 420,
headshots: 160, downs: 1, revives: 0, score: 32000, score_total: 62000 });
// Earn 1,000 and spend 5,000 between snapshots.
players[0].score = 28000;
players[0].score_total = 63000;
c.telemetry_enqueue('heartbeat');
const later = JSON.parse(c.level.telemetry_queue[2].body).players[0];
assert.equal(later.score, 28000);
assert.equal(later.score_total, 63000);
for (const total of [undefined, -10, 0, 65000.9]) {
players[0].score_total = total;
const stats = JSON.parse(c.telemetry_players_json(true))[0];
assert.equal(stats.score_total, total > 0 ? 65000 : 0);
assert.equal(stats.score, 28000);
}
});
check(`${game}: end captures before scoreboard overwrite and replays both scores unchanged`, () => {
const players = [player()];
const first = engine(game, disk(), players);
first.c.telemetry_game_start();
first.responses.push({});
first.c.telemetry_sender().next();
first.c.level.round_number = 18;
const watcher = first.c.telemetry_game_end_watcher();
assert.deepEqual(Array.from(watcher.next().value), ['end_game', 'game_ended', 'intermission']);
// Resuming at stock end_game must finish capture without another yield.
assert.equal(watcher.next().done, true);
players[0].score = players[0].score_total;
const saved = first.storage.entries[0].body;
const final = JSON.parse(saved);
assert.equal(final.event, 'game_end');
assert.equal(final.players[0].score, 32000);
assert.equal(final.players[0].score_total, 62000);
// A fresh VM/process cannot re-read the player; replay the saved JSON.
const replay = engine(game, first.storage);
replay.responses.push({ status: 503 }, {});
const sender = replay.c.telemetry_sender();
assert.equal(sender.next().value, 1);
assert.equal(sender.next().value, 'telemetry_queued');
assert.deepEqual(replay.sent.map(request => request.body), [saved, saved]);
});
check(`${game}: match identity, sequence and capture before send`, () => {
const { c, storage, sent, responses } = engine(game);
c.telemetry_game_start(); c.telemetry_game_start();
c.level.round_number = 1;
c.telemetry_report_round(); c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('game_end');
assert.equal(c.level.telemetry_queue_write, 4);
const events = c.level.telemetry_queue.map(e => JSON.parse(e.body));
assert.deepEqual(Array.from(events, e => e.seq), [1, 2, 3, 4]);
assert(events.every(e => e.match === events[0].match));
assert(!('round' in events[0]));
assert(events.every(e => Array.isArray(e.players)));
assert.equal(storage.entries.length, 2);
assert.equal(sent.length, 0);
responses.push({}, {}, {}, {});
c.level.telemetry_ended = true;
assert.equal(c.telemetry_sender().next().done, true);
assert.equal(storage.entries.length, 0);
assert.equal(c.level.telemetry_accepted, 4);
assert(sent.every(e => e.connect_timeout_ms === 5000 && e.timeout_ms === 15000));
assert.equal(sent[0].headers.Authorization, 'Bearer test-token');
assert(storage.calls.indexOf('store') < storage.calls.indexOf('send'));
});
check(`${game}: retries retain FIFO head and exact snapshot, coalesce heartbeats`, () => {
const { c, sent, responses, logs } = engine(game);
c.telemetry_game_start();
const initial = c.level.telemetry_queue[0].body;
responses.push({ success: false, error: 'timeout' }, { status: 429 }, { status: 503 }, {}, {}, {}, {});
const sender = c.telemetry_sender();
assert.equal(sender.next().value, 1);
c.telemetry_enqueue('heartbeat');
c.level.round_number = 2; c.telemetry_report_round();
c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('heartbeat');
c.telemetry_enqueue('game_end'); c.level.telemetry_ended = true;
assert.equal(sender.next().value, 2);
assert.equal(sender.next().value, 4);
assert.equal(sender.next().done, true);
const events = sent.map(s => JSON.parse(s.body));
assert(sent.slice(0, 4).every(s => s.body === initial));
assert.deepEqual(events.slice(4).map(e => [e.event, e.seq]), [['round_change', 3], ['heartbeat', 5], ['game_end', 6]]);
assert(logs.some(line => line.includes('ec_match_id=921')));
assert(logs.some(line => line.includes('API recovered')));
});
check(`${game}: response classification and permanent rejection`, () => {
const { c, responses, storage } = engine(game);
for (const status of [200, 201, 204, 299]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'accepted');
for (const status of [0, 429, 500, 502, 503, 599]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'retry');
for (const status of [301, 400, 401, 403, 404, 408, 415, 422]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'drop');
for (const body of ['', '<html>', '{"ok":false}', '{"ok":1}', '{"ok":"true"}']) assert.equal(c.telemetry_response_outcome(body, { http_status: 200 }), 'retry');
assert.equal(c.telemetry_response_outcome('{"ok":true}', undefined), 'retry');
c.telemetry_game_start(); responses.push({ status: 401 });
assert.equal(c.telemetry_sender().next().value, 'telemetry_queued');
assert.equal(storage.entries.length, 0);
assert.equal(c.level.telemetry_accepted, 0);
});
check(`${game}: backoff is capped and a missing handle retains the boundary`, () => {
const { c, responses, storage } = engine(game);
c.telemetry_game_start(); responses.push({ noHandle: true });
const sender = c.telemetry_sender();
const delays = Array.from({ length: 9 }, () => sender.next().value);
assert.deepEqual(delays, [1, 2, 4, 8, 16, 30, 30, 30, 30]);
assert.equal(storage.entries.length, 1);
assert.equal(c.level.telemetry_queue_read, 0);
});
check(`${game}: failed ordinary report is skipped and in-flight heartbeat stays stable`, () => {
const { c, responses, sent } = engine(game);
c.level.telemetry_match = 'match';
c.telemetry_enqueue('heartbeat');
responses.push({ success: false, during: () => { c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('heartbeat'); } }, {});
assert.equal(c.telemetry_sender().next().value, 'telemetry_queued');
assert.deepEqual(sent.map(s => JSON.parse(s.body).seq), [1, 3]);
});
check(`${game}: replay survives map/process reset and precedes new start`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.c.telemetry_enqueue('heartbeat'); first.c.telemetry_enqueue('game_end');
first.responses.push({ success: false }); first.c.telemetry_sender().next();
const replay = engine(game, first.storage);
replay.c.telemetry_game_start();
replay.responses.push({}, {}, {});
assert.equal(replay.c.telemetry_sender().next().value, 'telemetry_queued');
const bodies = replay.sent.map(s => JSON.parse(s.body));
assert.deepEqual(bodies.map(b => b.event), ['game_start', 'game_end', 'game_start']);
assert.equal(bodies[0].match, bodies[1].match);
assert.notEqual(bodies[0].match, bodies[2].match);
assert.equal(bodies[2].seq, 1);
assert.equal(replay.sent[0].body, first.sent[0].body);
assert.equal(first.storage.entries.length, 0);
assert(replay.source.indexOf('telemetryLoad(') < replay.source.indexOf('register_spawn_handler('));
});
check(`${game}: disk failure preserves ordering and blocks sending`, () => {
const { c, storage, sent, responses } = engine(game);
storage.failStore = true;
c.telemetry_game_start(); c.telemetry_enqueue('game_end');
const sender = c.telemetry_sender();
assert.equal(sender.next().value, 1);
assert.equal(sent.length, 0);
storage.failStore = false;
c.telemetry_persist_pending();
assert.deepEqual(storage.entries.map(e => e.event), ['game_start', 'game_end']);
storage.failRemove = true;
responses.push({});
assert.equal(sender.next().value, 2);
assert.equal(sent.length, 1);
assert.equal(sender.next().value, 4);
assert.equal(sent.length, 1); // Only retry deletion after an acknowledgment.
storage.failRemove = false; responses.push({});
assert.equal(sender.next().value, 'telemetry_queued');
assert.equal(storage.entries.length, 0);
assert.equal(sent.length, 2);
});
check(`${game}: end captured behind a stalled round survives map unload`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.responses.push({});
first.c.telemetry_sender().next();
assert.equal(first.storage.entries.length, 0);
first.c.level.round_number = 12; first.c.telemetry_report_round();
const unload = new Error('map unloaded during HTTP wait');
first.responses.push({ during: () => {
first.c.telemetry_enqueue('game_end');
throw unload;
} });
assert.throws(() => first.c.telemetry_sender().next(), error => error === unload);
assert.deepEqual(first.storage.entries.map(e => e.event), ['game_end']);
const replay = engine(game, first.storage);
replay.c.telemetry_game_start();
replay.responses.push({ status: 500 }, {}, {});
const sender = replay.c.telemetry_sender();
assert.equal(sender.next().value, 1);
assert.equal(sender.next().value, 'telemetry_queued');
const events = replay.sent.map(s => JSON.parse(s.body));
assert.deepEqual(events.map(e => e.event), ['game_end', 'game_end', 'game_start']);
assert.equal(events[0].round, 12);
assert.equal(replay.sent[0].body, replay.sent[1].body);
assert.notEqual(events[0].match, events[2].match);
});
check(`${game}: acknowledgment lost at restart replays the identical end`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.responses.push({});
first.c.telemetry_sender().next();
first.c.telemetry_enqueue('game_end');
first.storage.failRemove = true;
first.responses.push({});
assert.equal(first.c.telemetry_sender().next().value, 1);
assert.equal(first.c.level.telemetry_accepted, 2);
first.storage.failRemove = false;
const replay = engine(game, first.storage);
replay.responses.push({});
replay.c.telemetry_sender().next();
assert.equal(replay.sent[0].body, first.sent[1].body);
assert.equal(first.storage.entries.length, 0);
});
}
check('paired queue and delivery implementations match', () => {
const t5 = engine('t5').all, t6 = engine('t6').all;
for (const name of ['telemetry_game_end_watcher', 'telemetry_enqueue', 'telemetry_queue_push', 'telemetry_critical', 'telemetry_persist_pending',
'telemetry_sender', 'telemetry_process', 'telemetry_retry_delay', 'telemetry_send', 'telemetry_response_outcome'])
assert.equal(t5.get(name), t6.get(name), name);
for (const file of ['telemetry.hpp', 'telemetry.cpp']) {
const get = game => readFileSync(new URL(`${game}/plugins/tws-${game}-gsc-utils/src/utils/${file}`, root), 'utf8');
assert.equal(get('t5'), get('t6'), file);
}
});
console.log(`PASS ${checks} telemetry queue/response checks (both games). VM linking/gameplay still require Plutonium.`);