Add majority lock and unlock voting for T5 and T6

This commit is contained in:
2026-10-18 16:01:57 -04:00
parent caaaa102a4
commit 9ef5932d36
10 changed files with 816 additions and 78 deletions
+15 -1
View File
@@ -162,7 +162,7 @@ function assertTextOnly(e) {
'service elements are private, non-archived text with no background shaders');
}
for (const game of ['t5', 't6']) {
check(`${game} installed HUD joins all nine commands in one view`, () => {
check(`${game} core and bank commands fit in one view without extra modules`, () => {
const e = engine(game, 8, true);
e.help();
assert.deepEqual(e.helpCommands,
@@ -180,6 +180,20 @@ for (const game of ['t5', 't6']) {
withoutHud.help();
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
});
check(`${game} lock module registrations appear on the second help page`, () => {
const e = engine(game, 8, true);
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8');
for (const [, command, description] of source.matchAll(/register_help_command\("([^"]+)", "([^"]+)"\)/g))
e.c.register_help_command(command, description);
e.help();
assert.equal(e.state.help.pages, 2);
e.advance(12000);
assert.deepEqual(e.helpCommands, ['.lock', '.unlock']);
assert.deepEqual(e.helpDescriptions, ['Vote to lock (6+)', 'Vote to unlock']);
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
const e = engine(game);
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
+270
View File
@@ -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)`);