298 lines
17 KiB
JavaScript
298 lines
17 KiB
JavaScript
// Execute the actual data-only GSC functions with small engine-value shims.
|
|
// This is a fixture check, not a GSC VM, linker or gameplay test.
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const root = new URL('../', import.meta.url);
|
|
const bytes = (text) => Buffer.from(text, 'utf8').toString('latin1');
|
|
const text = (value) => Buffer.from(value, 'latin1').toString('utf8');
|
|
let checks = 0;
|
|
function check(name, run) {
|
|
try { run(); checks++; }
|
|
catch (error) { throw new Error(name, { cause: error }); }
|
|
}
|
|
|
|
function functions(source) {
|
|
// Protect literals/comments before rewriting the small shared language subset.
|
|
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 = /^(lb_\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);
|
|
// Each GSC function has its own implicit locals, including recursion.
|
|
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
|
|
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length').replace(/\bwait\s+[\d.]+;/g, ';');
|
|
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
|
|
found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function engine(game) {
|
|
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
|
|
const all = functions(source);
|
|
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']);
|
|
const table = [];
|
|
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
|
|
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
|
|
const logs = [], queries = [], responses = [];
|
|
const context = vm.createContext({
|
|
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
|
|
GetDvar: (key) => dvars[key] ?? '',
|
|
getTime: () => 1000000,
|
|
spawnStruct: () => ({}),
|
|
isDefined: (value) => value !== undefined && value !== null,
|
|
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
|
|
: typeof value === 'number' ? (Number.isInteger(value) ? 'int' : 'float') : typeof value === 'boolean' ? 'int' : typeof value,
|
|
toLower: (value) => value.toLowerCase(),
|
|
getSubStr: (value, start, end) => {
|
|
assert.equal(typeof value, 'string');
|
|
assert(start >= 0 && start <= value.length, `substring start ${start} exceeds ${value.length}`);
|
|
return value.slice(start, end);
|
|
},
|
|
strTok: (value, delimiter) => value.split(delimiter),
|
|
int: (value) => Math.trunc(Number(value)),
|
|
lb_log: (message) => logs.push(message),
|
|
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
|
|
});
|
|
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
|
|
return { context, all, source, dvars, responses, queries, logs };
|
|
}
|
|
|
|
function mapOption(game) {
|
|
return game === 't5'
|
|
? { game, map: 'zombie_theater', key: 't5:zombie_theater', name: 'Kino Der Toten' }
|
|
: { game, map: 'zm_transit', key: 't6:zm_transit:town', name: 'Town Survival' };
|
|
}
|
|
function entry(option, guid, name = `Player ${guid}`, round = 52) {
|
|
return { guid, name: bytes(name), rank: round === 52 ? 1 : 10, highestRound: round,
|
|
records: { highestRound: { game: option.game, mapKey: option.key, value: round } } };
|
|
}
|
|
function page(option, entries, total, offset = 0, limit = 3, round = 52) {
|
|
return { entries, total, offset, limit, metric: 'highestRound', maps: [option], summary: { highestRound: round } };
|
|
}
|
|
function discovery(option) { return page(option, [], 0, 0, 1, 0); }
|
|
|
|
for (const game of ['t5', 't6']) {
|
|
const { context: c, source, all, dvars, responses, queries } = engine(game);
|
|
const option = mapOption(game);
|
|
check(`${game} exactly one connect listener across modules`, () => {
|
|
const common = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
|
|
assert.equal((common.match(/waittill\("connected"/g) ?? []).length, 1);
|
|
assert(!source.includes('waittill("connected"'));
|
|
assert(!all.get('lb_welcome').includes('spawned_player'));
|
|
if (game === 't6') assert(!all.get('lb_welcome').includes('self.hasSpawned'));
|
|
assert(source.includes('register_connect_handler(::lb_welcome)'));
|
|
});
|
|
check(`${game} record replay uses only a fresh complete cache`, () => {
|
|
assert.equal(c.lb_cached_record(), undefined);
|
|
const snapshot = { saved_at: 1000000, round: 52, lines: ['Alice'] };
|
|
c.level.tws_lb_cache = snapshot;
|
|
assert.equal(c.lb_cached_record(), snapshot);
|
|
snapshot.saved_at = 100000;
|
|
assert.equal(c.lb_cached_record(), snapshot);
|
|
snapshot.saved_at--;
|
|
assert.equal(c.lb_cached_record(), undefined);
|
|
c.level.tws_lb_cache = { saved_at: 1000000, round: 0, lines: [] };
|
|
assert.equal(c.lb_cached_record().round, 0);
|
|
c.level.tws_lb_cache = undefined;
|
|
assert.equal(queries.length, 0);
|
|
});
|
|
check(`${game} common owns the record service window`, () => {
|
|
const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common';
|
|
assert(source.includes(`self ${commonPath}::show_record(snapshot);`));
|
|
assert(!source.includes('lb_show_hud'));
|
|
assert(!source.includes('lb_hud_'));
|
|
assert(!source.includes('tws_help_elems'));
|
|
assert(!source.includes('iPrintLnBold'));
|
|
assert(!source.includes('make_text_elem'));
|
|
assert(!source.includes('tws_t5_hud') && !source.includes('tws_t6_hud'));
|
|
});
|
|
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
|
|
const calls = [];
|
|
c.self = { notify: (event) => calls.push(['notify', event]) };
|
|
c.recordService = (snapshot) => calls.push(['record', snapshot]);
|
|
const request = all.get('lb_request')
|
|
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
|
|
.replace(/\bself notify\(/g, 'self.notify(')
|
|
.replace(/\bself scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(');
|
|
vm.runInContext(request, c);
|
|
for (const cached of [undefined, { saved_at: 1, round: 52, lines: ['Alice'] }]) {
|
|
c.level.tws_lb_cache = cached;
|
|
c.lb_request(false);
|
|
assert.deepEqual(calls, []);
|
|
c.lb_request(true);
|
|
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', undefined]]);
|
|
calls.length = 0;
|
|
}
|
|
const snapshot = { saved_at: 1000000, round: 0, lines: [] };
|
|
c.level.tws_lb_cache = snapshot;
|
|
c.lb_request(false);
|
|
assert.deepEqual(calls, [['record', snapshot]]);
|
|
calls.length = 0;
|
|
c.lb_request(true);
|
|
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
|
|
c.level.tws_lb_cache = undefined;
|
|
});
|
|
check(`${game} unicode and URL encoding`, () => {
|
|
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
|
|
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
|
|
assert.equal(text(c.lb_display_text(bytes('abc🧟'), 6)), 'abc...');
|
|
assert.equal(text(c.lb_display_text(bytes('abc🧟'), 7)), 'abc🧟');
|
|
});
|
|
const valid = [discovery(option), { text: 'René 🧟 " \\ /\n\r\t\b\f', n: -1.23e-4, yes: true, no: false, empty: null },
|
|
[], {}, 0, -0.2, 'hello', true, null];
|
|
for (const value of valid) check(`${game} valid JSON ${JSON.stringify(value).slice(0, 60)}`, () => {
|
|
assert.equal(c.lb_json_valid(bytes(JSON.stringify(value))), true);
|
|
});
|
|
check(`${game} escaped unicode pair and whitespace`, () => {
|
|
assert.equal(c.lb_json_valid(' \n{"s":"\\u00e9 \\uD83E\\uDDDF"}\t'), true);
|
|
});
|
|
const invalid = ['', ' ', '<html>Error</html>', '{', '{"a":}', '{"a":1,}', '[1,]', '[,1]', '{a:1}', 'true false',
|
|
'{"a":"\\q"}', '{"a":"raw\nline"}', '"\\ud800"', '"\\udc00"', '"\\ud800\\u0041"', '"\\u0000"',
|
|
'"\\uX001"', '"unterminated', '01', '-01', '+1', '.5', '1.', '1e', '1e+', '1e999', '1'.repeat(33),
|
|
'NaN', 'Infinity', '['.repeat(18) + '0' + ']'.repeat(18), '"' + 'a'.repeat(20480) + '"',
|
|
'"\xc0\xaf"', '"\xed\xa0\x80"', '"\xf4\x90\x80\x80"', '"\xff"', '"\xc2"'];
|
|
for (const value of invalid) check(`${game} malformed JSON ${JSON.stringify(value).slice(0, 50)}`, () => {
|
|
assert.equal(c.lb_json_valid(value), false);
|
|
});
|
|
check(`${game} every truncated prefix of a valid API response`, () => {
|
|
const body = JSON.stringify(page(option, [entry(option, '000123')], 1));
|
|
for (let i = 0; i < body.length; i++) assert.equal(c.lb_json_valid(body.slice(0, i)), false, `accepted prefix ${i}`);
|
|
});
|
|
check(`${game} success schema rejects API errors and bad field types`, () => {
|
|
for (const value of [{ ok: false, error: 'bad request' }, [], null, 'oops',
|
|
{ ...discovery(option), entries: 'no' }, { ...discovery(option), metric: undefined },
|
|
{ ...discovery(option), limit: "1" }, { ...discovery(option), total: -1 }]) {
|
|
assert.equal(c.lb_page_valid(value, 0, 1), false);
|
|
}
|
|
});
|
|
check(`${game} one holder`, () => {
|
|
responses.push(discovery(option), page(option, [entry(option, '000123', 'Alice')], 1));
|
|
const result = c.lb_refresh();
|
|
assert.equal(result.round, 52);
|
|
assert.deepEqual(Array.from(result.names), ['Alice']);
|
|
assert(queries.at(-1).includes(`map=${encodeURIComponent(option.key)}`));
|
|
});
|
|
check(`${game} every tie across pages, with repeated GUID and identical names`, () => {
|
|
const tied = Array.from({ length: 8 }, (_, i) => entry(option, `${i}`, i < 2 ? 'Same name' : `Holder ${i}`));
|
|
tied.splice(3, 0, tied[2]);
|
|
responses.push(discovery(option), page(option, tied.slice(0, 3), 10), page(option, tied.slice(3, 6), 10, 3),
|
|
page(option, tied.slice(6, 9), 10, 6), page(option, [entry(option, 'lower', 'Lower', 51)], 10, 9));
|
|
const result = c.lb_refresh();
|
|
assert.equal(result.names.length, 8);
|
|
assert.equal(result.names.filter((x) => x === 'Same name').length, 2);
|
|
assert(!result.names.includes('Lower'));
|
|
assert.equal(queries.at(-1).includes('offset=9'), true);
|
|
});
|
|
check(`${game} no records versus missing response`, () => {
|
|
responses.push(discovery(option), page(option, [], 0, 0, 3, 0));
|
|
assert.equal(c.lb_refresh().round, 0);
|
|
responses.push({ ...discovery(option), maps: [] });
|
|
assert.equal(c.lb_refresh().round, 0);
|
|
responses.push(undefined);
|
|
assert.equal(c.lb_refresh(), undefined);
|
|
});
|
|
check(`${game} failed later page never returns partial holders`, () => {
|
|
responses.push(discovery(option), page(option, [entry(option, '1'), entry(option, '2'), entry(option, '3')], 4), undefined);
|
|
assert.equal(c.lb_refresh(), undefined);
|
|
});
|
|
check(`${game} changing top record / total rejects mixed pages`, () => {
|
|
for (const [total, round] of [[4, 53], [5, 52]]) {
|
|
responses.push(discovery(option), page(option, [entry(option, '1'), entry(option, '2'), entry(option, '3')], 4),
|
|
page(option, [entry(option, '4')], total, 3, 3, round));
|
|
assert.equal(c.lb_refresh(), undefined);
|
|
}
|
|
});
|
|
check(`${game} wrong map evidence rejects response`, () => {
|
|
const wrong = entry({ ...option, key: 'wrong' }, '1');
|
|
responses.push(discovery(option), page(option, [wrong], 1));
|
|
assert.equal(c.lb_refresh(), undefined);
|
|
});
|
|
check(`${game} explicit key must match current game and map`, () => {
|
|
c.level.tws_lb_override = 't6:zm_transit:town';
|
|
const wrong = { ...option, game: 'wrong', key: c.level.tws_lb_override };
|
|
responses.push(discovery(wrong));
|
|
assert.equal(c.lb_refresh(), undefined);
|
|
c.level.tws_lb_override = '';
|
|
});
|
|
check(`${game} all holders fit bounded service rows`, () => {
|
|
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
|
|
const lines = Array.from(c.lb_holder_lines(names));
|
|
assert(lines.every((line) => c.lb_holder_width(line) <= 16));
|
|
assert.deepEqual(lines.join(', ').split(', '), names);
|
|
assert.deepEqual(Array.from(c.lb_holder_lines([])), []);
|
|
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']);
|
|
});
|
|
check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => {
|
|
const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5),
|
|
'abcdefghijklééé', '^1Alice\n^7 René 🧟', '^1\n\t'];
|
|
for (const sample of samples) {
|
|
const value = c.lb_holder_name(bytes(sample));
|
|
assert(c.lb_holder_width(value) <= 16, sample);
|
|
assert(c.lb_utf8_valid(value), sample);
|
|
assert(!text(value).includes('\uFFFD'), sample);
|
|
assert(!/[\x00-\x1f\x7f^]/.test(value), sample);
|
|
assert(value.length > 0, sample);
|
|
}
|
|
assert.equal(c.lb_holder_name('M'.repeat(64)), 'M'.repeat(13) + '...');
|
|
assert.equal(text(c.lb_holder_name(bytes('🧟'.repeat(16)))), '🧟'.repeat(6) + '...');
|
|
assert.equal(c.lb_holder_name('^1\n\t'), 'Unknown player');
|
|
assert.equal(c.lb_holder_width(bytes('René 🧟')), 8);
|
|
const names = Array(100).fill(bytes('同じ名前'.repeat(5)));
|
|
const rows = Array.from(c.lb_holder_lines(names));
|
|
assert.equal(rows.length, names.length, 'identical long names still represent every holder');
|
|
assert(rows.every((line) => c.lb_holder_width(line) <= 16 && c.lb_utf8_valid(line)));
|
|
});
|
|
if (game === 't6') {
|
|
const variants = [
|
|
['zm_transit', 'zclassic', 'transit', 'transit'], ['zm_transit', 'zstandard', 'town', 'town'],
|
|
['zm_transit', 'zstandard', 'farm', 'farm'], ['zm_transit', 'zstandard', 'transit', 'busdepot'],
|
|
['zm_nuked', 'zstandard', 'nuked', 'nuked'], ['zm_highrise', 'zclassic', 'rooftop', 'highrise'],
|
|
['zm_prison', 'zclassic', 'prison', 'prison'], ['zm_buried', 'zclassic', 'processing', 'buried'],
|
|
['zm_tomb', 'zclassic', 'tomb', 'tomb'], ['zm_transit', 'zgrief', 'town', 'towngrief'],
|
|
['zm_transit', 'zgrief', 'farm', 'farmgrief'], ['zm_transit', 'zgrief', 'transit', 'busdepotgrief'],
|
|
['zm_prison', 'zgrief', 'cellblock', 'cellblock'], ['zm_buried', 'zgrief', 'street', 'buriedgrief'],
|
|
['zm_transit_dr', 'zcleansed', 'diner', 'diner'], ['zm_buried', 'zcleansed', 'street', 'buriedturned'],
|
|
];
|
|
for (const [map, mode, location, slug] of variants) check(`T6 ${slug}`, () => {
|
|
assert.equal(c.lb_t6_selection({ map }, mode, location).key, `t6:${map}:${slug}`);
|
|
});
|
|
check('T6 unknown variant does not fall back to available Town', () => {
|
|
dvars.ui_gametype = 'unknown';
|
|
assert.equal(c.lb_selection(), undefined);
|
|
dvars.ui_gametype = 'zstandard';
|
|
});
|
|
check('T6 live stock settings take precedence over changed dvars', () => {
|
|
c.level.scr_zm_ui_gametype = 'zclassic';
|
|
c.level.scr_zm_map_start_location = 'transit';
|
|
assert.equal(c.lb_selection().key, 't6:zm_transit:transit');
|
|
});
|
|
}
|
|
assert.equal(responses.length, 0, `${game} unused responses`);
|
|
}
|
|
check('paired data helpers stay identical', () => {
|
|
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
|
|
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
|
|
for (const [name, fn] of t5) {
|
|
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) {
|
|
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
|
|
assert.equal(normalized, fn, name);
|
|
}
|
|
}
|
|
});
|
|
console.log(`PASS ${checks} leaderboard fixture/static checks (both games). Engine linking and gameplay still require Plutonium.`);
|