Files
tws-zombies-scripts/tools/check_join.mjs
T

228 lines
8.2 KiB
JavaScript

// Execute the actual T6 join functions with a deterministic event/timer shim.
// This checks script decisions, not GSC compilation or live engine behavior.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../t6/scripts/tws_t6_join.gsc', import.meta.url), 'utf8');
const functions = [];
for (const name of ['join_spawn', 'join_track_death', 'join_request']) {
const match = new RegExp(`^${name}\\(\\)\\s*\\n\\{`, 'm').exec(source);
// The original implementation has no death observer; allow regression runs.
if (!match && name === 'join_track_death') continue;
assert(match, `missing ${name}`);
let end = match.index + match[0].length, depth = 1;
const start = end;
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = source.slice(start, end - 1).replace(/\/\/[^\n]*/g, '');
body = body
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
.replace(/self thread (\w+)\(\);/g, 'startThread($1);')
.replace(/self waittill\(([^;]+)\);/g, 'yield { entity: self, event: $1 };')
.replace(/\bwait ([\d.]+);/g, 'yield { seconds: $1 };')
.replace(/self iPrintLnBold\(/g, 'messages.push(')
.replace(/self \[\[level.spawnplayer\]\]\(\);/g, 'spawnPlayer();')
.replace(/maps\\mp\\zombies\\_zm::spectator_respawn/g, 'stockRespawn')
.replace(/!join_has_active_player\(\)/g, '!activePlayer')
.replace(/\.size\b/g, '.length');
functions.push(`function* ${name}() { let points; ${body} }`);
}
function engine() {
let now = 0, current;
const tasks = [], messages = [];
const self = { sessionstate: 'playing', spectator_respawn: {} };
const level = { round_number: 3, spawnplayer: true };
let spawns = 0;
function resume(task) {
const previous = current;
current = task;
const result = task.generator.next();
task.done = result.done;
task.wait = result.value;
if (task.wait?.seconds !== undefined) task.at = now + task.wait.seconds;
else task.at = Infinity;
current = previous;
}
function startThread(fn) {
const task = { generator: fn(), ends: [] };
tasks.push(task);
resume(task);
}
function event(entity, event) {
const listeners = tasks.filter((task) => !task.done);
for (const task of listeners) {
if (task.ends.some(([e, name]) => e === entity && name === event)) task.done = true;
}
for (const task of listeners) {
if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
}
}
function advance(seconds) {
const until = now + seconds;
for (;;) {
const next = tasks.filter((task) => !task.done && task.at <= until)
.sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
resume(next);
}
now = until;
}
const c = vm.createContext({
self, level, messages, activePlayer: true, stockRespawn: () => {},
isDefined: (value) => value !== undefined && value !== null,
is_true: (value) => value === true || value === 1,
endon: (entity, event) => current.ends.push([entity, event]),
startThread,
getstructarray: () => [{}], randomInt: () => 0,
spawnPlayer: () => {
spawns++;
self.sessionstate = 'playing';
event(self, 'spawned_player');
},
});
vm.runInContext(functions.join('\n'), c);
return { self, level, c, messages, tasks, advance,
start: () => startThread(c.join_spawn),
request: () => startThread(c.join_request),
event: (name) => event(self, name),
end: (name) => event(level, name),
get spawns() { return spawns; },
};
}
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
function newcomer() {
const e = engine();
e.start(); // Common's once-per-connection callback, at spawned_player.
e.advance(0.05);
e.self.sessionstate = 'spectator'; // Stock delayed setup transition.
e.event('spawned_spectator');
e.advance(0.1);
return e;
}
check('Farm round 3 setup spawn leaves a newcomer eligible for exactly one join', () => {
const e = newcomer();
assert(!e.self.tws_join_has_played);
e.request();
assert.equal(e.spawns, 1);
assert.equal(e.self.tws_join_used, true);
e.request();
e.self.sessionstate = 'spectator';
e.event('killed_player');
e.level.round_number++;
e.request();
assert.equal(e.spawns, 1);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('setup transition blocks requests until settled', () => {
const e = engine();
e.start();
e.advance(0.05);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /waiting to enter/);
e.advance(0.1);
e.request();
assert.equal(e.spawns, 1);
});
check('a normal round-start spawn closes eligibility even after later death', () => {
const e = newcomer();
e.level.round_number++;
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('round-1 participants cannot join again after death', () => {
const e = engine();
e.level.round_number = 1;
e.start();
e.advance(0.11);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.equal(e.self.tws_join_has_played, true);
});
for (const initial of [true, false]) {
check(`death during ${initial ? 'initial' : 'later'} spawn settling consumes eligibility`, () => {
const e = initial ? engine() : newcomer();
if (initial) e.start();
else {
e.self.sessionstate = 'playing';
e.event('spawned_player');
}
e.advance(0.02);
e.event('killed_player');
e.self.sessionstate = 'spectator';
e.advance(0.11);
e.request();
assert.equal(e.self.tws_join_has_played, true);
assert.equal(e.spawns, 0);
});
}
check('repeated spectator setup events keep listening for an actual spawn', () => {
const e = newcomer();
e.event('spawned_player');
e.advance(0.11);
assert(!e.self.tws_join_has_played);
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
});
for (const name of ['disconnect', 'end_game', 'game_ended', 'intermission']) {
check(`${name} stops observers during setup and while waiting`, () => {
for (const setup of [true, false]) {
const e = setup ? engine() : newcomer();
if (setup) e.start();
if (name === 'disconnect') e.event(name);
else e.end(name);
e.advance(1);
e.event('killed_player');
assert(!e.self.tws_join_has_played);
assert(e.tasks.every((task) => task.done));
}
});
}
for (const [name, set] of [
['intermission', (e) => { e.level.intermission = true; }],
['game ended', (e) => { e.level.gameended = true; }],
['no active player', (e) => { e.c.activePlayer = false; }],
['missing round', (e) => { delete e.level.round_number; }],
['missing spawn callback', (e) => { delete e.level.spawnplayer; }],
]) {
check(`${name} still rejects joining`, () => {
const e = newcomer();
set(e);
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /not ready/);
});
}
check('reconnecting starts independent eligibility', () => {
const first = newcomer();
first.request();
first.event('disconnect');
const second = newcomer();
second.request();
assert.equal(second.spawns, 1);
});
console.log(`PASS ${checks} T6 join lifecycle/request checks (engine shims; live validation required)`);