// Execute both lock scripts with chat, roster, clock and event shims. This does // not emulate Plutonium's VM or prove that g_password prevents a real join. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const root = new URL('../', import.meta.url); const coroutines = new Set(['lock_chat_listener', 'lock_round_watcher', 'lock_vote_watcher', 'lock_cleanup_watcher']); function translate(source) { const literals = []; source = source.replace(/#include[^\n]*|"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { if (!token.startsWith('"')) return ''; literals.push(token); return `__literal_${literals.length - 1}__`; }); const result = [], headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm; for (let match; (match = headers.exec(source));) { let end = headers.lastIndex, depth = 1; while (depth) { assert(end < source.length, `unclosed ${match[1]}`); const ch = source[end++]; if (ch === '{') depth++; if (ch === '}') depth--; } let body = source.slice(headers.lastIndex, end - 1); const locals = new Set([...body.matchAll(/(? m[1])); if (match[1] === 'lock_chat_listener') { locals.add('message'); locals.add('player'); } body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '') .replace(/#(__literal_\d+__)/g, '$1') .replace(/\.size\b/g, '.length') .replace(/level thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);') .replace(/level endon\(([^;]+)\);/g, 'endon($1);') .replace(/level waittill\(([^,;]+), message, player\);/g, '[message, player] = yield { event: $1 };') .replace(/level waittill\(([^;]+)\);/g, 'yield { event: $1 };') .replace(/level notify\(([^;]+)\);/g, 'emit($1);') .replace(/wait \(([^;]+)\);/g, 'yield { delay: ($1) * 1000 };') .replace(/(player|players\[i\]) iPrintLn\(/g, 'print($1, ') .replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]); result.push(`function${coroutines.has(match[1]) ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); } return result.join('\n'); } function engine(game, count = 4, lockRound = 20) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8'); const level = { round_number: 6 }, players = [], tasks = [], messages = [], writes = [], help = []; let now = 0, running, random = 0, password = 'stale-password'; function resume(task, args) { const prior = running; running = task; const next = task.generator.next(args); task.done = task.done || next.done; task.wait = next.value; task.at = next.value?.delay !== undefined ? now + next.value.delay : Infinity; running = prior; } function startThread(fn, args) { const task = { generator: fn(...args), ends: [] }; tasks.push(task); resume(task); } function emit(event, ...args) { const listeners = tasks.filter(t => !t.done); for (const task of listeners) if (task.ends.includes(event)) task.done = true; for (const task of listeners) if (!task.done && task.wait?.event === event) resume(task, args); } function advance(milliseconds) { const until = now + milliseconds; for (;;) { const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0]; if (!next) break; now = next.at; resume(next); } now = until; } const c = vm.createContext({ level, ensure_config() {}, register_help_command: (command, description) => help.push([command, description]), GetDvar: () => '1', int_dvar_or: () => lockRound, getPlayers: () => players.slice(), getTime: () => now, isDefined: value => value !== undefined && value !== null, is_true: value => value === true || value === 1, spawnStruct: () => ({}), int: Math.trunc, randomInt: () => ++random, toLower: value => value.toLowerCase(), setDvar(name, value) { assert.equal(name, 'g_password'); password = value; writes.push(value); }, print: (player, text) => messages.push({ player, text }), startThread, endon: event => running.ends.push(event), emit, }); vm.runInContext(translate(source), c); function connect() { const player = { sessionstate: 'playing' }; players.push(player); return player; } for (let i = 0; i < count; i++) connect(); c.init(); return { level, players, tasks, messages, writes, help, c, advance, connect, emit, chat: (player, message) => emit('say', message, player), round(number) { level.round_number = number; emit('hud_round_changed'); }, disconnect(player) { const index = players.indexOf(player); assert(index >= 0); players.splice(index, 1); }, get password() { return password; }, // Jump without running the timer to test expiry at the exact chat boundary. jump(milliseconds) { now += milliseconds; }, }; } let checks = 0; function check(name, run) { try { run(); checks++; } catch (cause) { throw new Error(name, { cause }); } } for (const game of ['t5', 't6']) { check(`${game}: startup clears a stale lock and registers both commands quietly`, () => { const e = engine(game); assert.equal(e.password, ''); assert.deepEqual(e.help.map(([command]) => command), ['.lock', '.unlock']); e.round(5); e.round(6); e.round(19); assert.equal(e.messages.length, 0); assert.equal(e.writes.length, 1); }); for (const [count, required] of [[1, 1], [2, 2], [3, 2], [4, 3], [8, 5]]) { for (const command of ['.lock', '.unlock']) { check(`${game}: ${command} needs ${required}/${count} distinct voters`, () => { const e = engine(game, count); const target = command === '.lock'; if (!target) e.round(20); for (let i = 0; i < required - 1; i++) { e.chat(e.players[i], command); e.chat(e.players[i], command); assert.equal(e.level.lock_active, !target); } e.chat(e.players[required - 1], command.toUpperCase()); assert.equal(e.level.lock_active, target); assert.equal(Boolean(e.password), target); assert.equal(e.level.lock_vote, undefined); const writes = e.writes.length; e.chat(e.players[0], command); assert.equal(e.writes.length, writes); }); } } for (const round of [undefined, 0, 1, 4, 5]) { check(`${game}: manual locking is unavailable at round ${round}`, () => { const e = engine(game, 1); e.round(round); e.chat(e.players[0], '.lock'); assert.equal(e.password, ''); assert.equal(e.level.lock_vote, undefined); assert.match(e.messages.at(-1).text, /round 6/); }); } check(`${game}: exact chat commands only; missing and disconnected senders are ignored`, () => { const e = engine(game, 1); for (const text of ['hello .lock', '.lock extra', '.unlock extra', '.locked', undefined]) e.chat(e.players[0], text); e.chat(undefined, '.lock'); e.chat({}, '.lock'); assert.equal(e.messages.length, 0); assert.equal(e.password, ''); }); check(`${game}: round-20 auto-lock runs once; unlock persists across later rounds`, () => { const e = engine(game, 1); e.round(19); assert.equal(e.password, ''); e.round(20); assert(e.password); const writes = e.writes.length; e.round(20); e.round(21); assert.equal(e.writes.length, writes); e.chat(e.players[0], '.unlock'); e.round(22); e.round(40); assert.equal(e.password, ''); e.chat(e.players[0], '.lock'); assert(e.password); }); check(`${game}: early lock/unlock stays open through round 20; later relocks use a fresh password`, () => { const e = engine(game, 1); e.chat(e.players[0], '.lock'); const first = e.password; e.chat(e.players[0], '.unlock'); e.round(20); e.round(21); assert.equal(e.password, ''); e.chat(e.players[0], '.lock'); assert(e.password); assert.notEqual(e.password, first); }); check(`${game}: an already-open reply cannot cancel automatic locking`, () => { const e = engine(game, 1); e.chat(e.players[0], '.unlock'); e.round(20); assert(e.password); }); check(`${game}: early lock is retained at the automatic milestone without another announcement`, () => { const e = engine(game, 1); e.chat(e.players[0], '.lock'); const writes = e.writes.length, messages = e.messages.length; e.round(20); assert.equal(e.writes.length, writes); assert.equal(e.messages.length, messages); e.chat(e.players[0], '.unlock'); e.round(21); assert.equal(e.password, ''); }); check(`${game}: custom automatic threshold below round 6 still permits unlocking`, () => { const e = engine(game, 1, 3); e.round(2); assert.equal(e.password, ''); e.round(3); assert(e.password); e.chat(e.players[0], '.unlock'); assert.equal(e.password, ''); e.round(4); assert.equal(e.password, ''); e.chat(e.players[0], '.lock'); assert.equal(e.password, ''); }); check(`${game}: automatic lock clears an incomplete manual vote`, () => { const e = engine(game); e.round(19); e.chat(e.players[0], '.lock'); e.round(20); assert(e.password); assert.equal(e.level.lock_vote, undefined); e.chat(e.players[1], '.unlock'); e.chat(e.players[2], '.unlock'); assert(e.password); e.chat(e.players[0], '.unlock'); assert.equal(e.password, ''); }); check(`${game}: repeated and opposite commands do not extend or cancel a pending vote`, () => { const e = engine(game); e.chat(e.players[0], '.lock'); const deadline = e.level.lock_vote.deadline; e.advance(30000); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.unlock'); assert.equal(e.level.lock_vote.deadline, deadline); assert.equal(e.c.lock_vote_tally().yes, 1); e.advance(30000); assert.equal(e.level.lock_vote, undefined); assert.equal(e.password, ''); e.chat(e.players[1], '.lock'); e.chat(e.players[2], '.lock'); assert.equal(e.password, ''); e.chat(e.players[0], '.lock'); assert(e.password); }); check(`${game}: the exact deadline rejects old votes before processing chat`, () => { const e = engine(game, 3); e.chat(e.players[0], '.lock'); e.jump(60000); e.chat(e.players[1], '.lock'); assert.equal(e.password, ''); assert.equal(e.c.lock_vote_tally().yes, 1); e.chat(e.players[0], '.lock'); assert(e.password); }); check(`${game}: votes pass just before the deadline`, () => { const e = engine(game, 2); e.chat(e.players[0], '.lock'); e.advance(59999); e.chat(e.players[1], '.lock'); assert(e.password); }); check(`${game}: departed voters do not count and reconnects get no inherited ballot`, () => { const e = engine(game); const departed = e.players[0]; e.chat(departed, '.lock'); e.disconnect(departed); const newcomer = e.connect(); e.advance(500); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); assert.equal(e.password, ''); e.chat(newcomer, '.lock'); assert(e.password); }); check(`${game}: roster shrink can complete a current majority`, () => { const e = engine(game); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); e.disconnect(e.players[3]); e.advance(500); assert(e.password); }); check(`${game}: roster growth raises the majority before accepting more votes`, () => { const e = engine(game, 3); e.chat(e.players[0], '.lock'); const newcomer = e.connect(); e.chat(e.players[1], '.lock'); assert.equal(e.password, ''); e.chat(newcomer, '.lock'); assert(e.password); }); check(`${game}: an empty roster cannot pass a vote`, () => { const e = engine(game, 2); e.chat(e.players[0], '.lock'); e.players.splice(0); e.advance(500); assert.equal(e.password, ''); }); check(`${game}: dead, spectator and AFK connections have equal votes`, () => { const e = engine(game, 3); e.players[0].sessionstate = 'spectator'; e.players[1].afk = true; e.chat(e.players[0], '.lock'); assert.equal(e.password, ''); e.chat(e.players[1], '.lock'); assert(e.password); }); for (const event of ['hud_game_over', 'end_game', 'game_ended', 'intermission']) { check(`${game}: ${event} clears the password/vote and stops all workers`, () => { const e = engine(game); e.round(20); e.chat(e.players[0], '.unlock'); e.emit(event); assert.equal(e.password, ''); assert.equal(e.level.lock_vote, undefined); assert(e.tasks.every(t => t.done)); const writes = e.writes.length; e.chat(e.players[1], '.lock'); e.c.lock_request(e.players[1], '.lock'); e.round(21); e.advance(61000); e.emit('hud_game_over'); assert.equal(e.writes.length, writes); }); } for (const flag of ['intermission', 'gameended', 'tws_ended']) { check(`${game}: ${flag} blocks state changes before cleanup notifications arrive`, () => { const e = engine(game, 2); e.chat(e.players[0], '.lock'); e.level[flag] = true; e.chat(e.players[1], '.lock'); e.disconnect(e.players[1]); e.advance(500); e.round(20); assert.equal(e.password, ''); }); } } console.log(`PASS ${checks} T5/T6 lock vote/lifecycle checks (engine shims; live validation required)`);