// 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)`);