Add majority lock and unlock voting for T5 and T6
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// 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(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+)/g)].map(m => 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)`);
|
||||
Reference in New Issue
Block a user