228 lines
8.2 KiB
JavaScript
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)`);
|