Improve command discovery and welcome messages

This commit is contained in:
2026-10-19 14:37:24 -04:00
parent e93182cd02
commit a457cbe539
12 changed files with 378 additions and 26 deletions
+100
View File
@@ -0,0 +1,100 @@
// Exercise the actual common/leaderboard chat listeners with private reply shims.
// This does not emulate Plutonium's VM or native chat delivery.
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 translated(source, name) {
const match = new RegExp(`^${name}\\(([^\\n]*)\\)\\s*\\n\\{`, 'm').exec(source);
assert(match, name);
const start = match.index + match[0].length;
let end = start, depth = 1;
while (depth) {
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = source.slice(start, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*=(?!=)/g)].map(m => m[1]));
const listener = name.endsWith('_listener');
if (listener) { locals.add('message'); locals.add('player'); }
for (const param of match[1].split(',').map(s => s.trim())) locals.delete(param);
body = body.replace(/level endon\([^;]*\);/g, '')
.replace(/level waittill\("say", message, player\);/g, '[message, player] = yield "say";')
.replace(/player show_help\(\);/g, 'help.push(player);')
.replace(/player thread lb_request\(true\);/g, 'records.push(player);')
.replace(/player iPrintLn\(/g, 'player.replies.push(')
.replace(/\.size\b/g, '.length');
return `function${listener ? '*' : ''} ${name}(${match[1]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`;
}
for (const game of ['t5', 't6']) {
const common = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
const leaderboard = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const help = [], records = [], sender = { replies: [] }, other = { replies: [] };
const c = vm.createContext({
level: {}, help, records,
isDefined: value => value !== undefined && value !== null,
toLower: value => value.toLowerCase(),
strTok: value => value.split(/[ \t\r\n]+/).filter(Boolean),
getSubStr: (value, start, end) => value.slice(start, end),
spawnStruct: () => ({}),
});
for (const name of ['register_help_command', 'help_first_word', 'help_unknown_command', 'help_chat_listener'])
vm.runInContext(translated(common, name), c);
vm.runInContext(translated(leaderboard, 'lb_chat_listener'), c);
// Missing/disabled optional modules must not suppress unknown-command replies.
assert.equal(c.help_unknown_command('.record'), true);
assert.equal(c.help_unknown_command('.w 2000'), true);
c.register_help_command('.record', 'Custom record command');
assert.equal(c.help_unknown_command('.record'), false);
c.level.tws_help_commands = [];
checks += 3;
const chat = c.help_chat_listener(), lb = c.lb_chat_listener();
assert.equal(chat.next().value, 'say');
assert.equal(lb.next().value, 'say');
for (const file of ['common', 'afk', 'bank', 'hud', 'join', 'lock', 'leaderboard']) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_${file}.gsc`, root), 'utf8');
for (const [, command, description] of source.matchAll(/register_help_command\("([^"]+)", "([^"]+)"\)/g))
c.register_help_command(command, description);
}
c.register_help_command('!custom <value>', 'Custom bang command');
const send = (message, player = sender) => {
sender.replies.length = 0; other.replies.length = 0; help.length = 0; records.length = 0;
assert.equal(chat.next([message, player]).value, 'say');
assert.equal(lb.next([message, player]).value, 'say');
};
for (const message of ['.unknown', '!unknown', '.', '!', '.return', '.records', '.helpful', '!help']) {
send(message);
assert.deepEqual(sender.replies, ['Unknown command. Type .help for the list'], `${game} ${message}`);
assert.deepEqual(other.replies, []);
assert.deepEqual(help, []); assert.deepEqual(records, []);
checks++;
}
for (const message of ['', 'hello', 'info', 'hello .unknown', ' .unknown', undefined,
'.w 2000', '.W ALL', '.d 500', '.w\t2000', '.afk', '.afk buy 10', '.lock', '.unlock', '!custom 5',
'.help extra', '.record extra']) {
send(message);
assert.deepEqual(sender.replies, [], `${game} ${message}`);
assert.deepEqual(help, []); assert.deepEqual(records, []);
checks++;
}
for (const message of ['.help', '.HELP', '.commands', '.info', '.INFO', '.command', '.COMMAND']) {
send(message, other);
assert.deepEqual(help, [other]); assert.deepEqual(records, []);
assert.deepEqual(sender.replies, []); assert.deepEqual(other.replies, []);
checks++;
}
for (const message of ['.leaders', '.LEADERS', '.record', '.RECORD']) {
send(message, other);
assert.deepEqual(records, [other]); assert.deepEqual(help, []);
assert.deepEqual(sender.replies, []); assert.deepEqual(other.replies, []);
checks++;
}
send('.unknown', null);
assert.deepEqual(sender.replies, []); assert.deepEqual(help, []); assert.deepEqual(records, []);
checks++;
}
console.log(`PASS ${checks} command discovery checks (both games). Native chat delivery still requires Plutonium.`);
+31 -1
View File
@@ -34,7 +34,7 @@ function functions(source) {
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]));
const coroutine = ['lb_round_watcher', 'lb_get'].includes(match[1]);
const coroutine = ['lb_round_watcher', 'lb_get', 'lb_welcome'].includes(match[1]);
body = body.replace(/player getGuid\(/g, 'player.getGuid(').replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
if (coroutine)
@@ -172,6 +172,36 @@ for (const game of ['t5', 't6']) {
if (game === 't6') assert(!all.get('lb_welcome').includes('self.hasSpawned'));
assert(source.includes('register_connect_handler(::lb_welcome)'));
});
check(`${game} welcome sends one private native hint before waiting for record data`, () => {
const e = engine(game), c = e.context, replies = [], requests = [];
const other = { replies: [] };
c.self = { iPrintLn: message => replies.push(message) };
c.level.tws_lb_attempt = 0;
c.welcomeRecord = manual => requests.push(manual);
const code = e.all.get('lb_welcome')
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
.replace(/self iPrintLn\(/g, 'self.iPrintLn(')
.replace(/self lb_request\(/g, 'welcomeRecord(');
vm.runInContext(code, c);
const welcome = c.lb_welcome();
assert.equal(welcome.next().value.milliseconds, 250, 'wait for readiness');
assert.deepEqual(replies, []);
c.self.sessionstate = 'spectator';
assert.equal(welcome.next().value.milliseconds, 5000, 'retain the welcome delay');
assert.deepEqual(replies, []);
assert.equal(welcome.next().value.milliseconds, 250, 'record fetch can still be pending');
assert.deepEqual(replies, ['Type .help for commands']);
assert.deepEqual(other.replies, []);
assert.deepEqual(requests, []);
assert.equal(welcome.next().value.milliseconds, 250);
assert.equal(replies.length, 1, 'waiting does not repeat the hint');
c.level.tws_lb_attempt = 1;
assert.equal(welcome.next().done, true);
assert.deepEqual(requests, [false]);
assert.equal(c.lb_welcome().next().done, true, 'duplicate connection dispatch stays silent');
assert.equal(replies.length, 1);
assert(!e.all.get('lb_request').includes('iPrintLn'), 'manual replay does not print the hint');
});
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'] };