// Execute the actual common GSC service functions with HUD/event shims. // These fixtures do not emulate the GSC VM, fonts, safe areas or HUD pool limits. import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import vm from 'node:vm'; import './check_core_hud.mjs'; const root = new URL('../', import.meta.url); let checks = 0; function check(name, run) { try { run(); checks++; } catch (error) { throw new Error(name, { cause: error }); } } const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via']); function translate(source) { const literals = []; const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { if (token.startsWith('/')) return ''; literals.push(token); return `__literal_${literals.length - 1}__`; }); const headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm; const result = []; for (let match; (match = headers.exec(protectedSource));) { const name = match[1]; if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record'].includes(name)) continue; if (name === 'help_chat_listener') continue; let end = headers.lastIndex, depth = 1; while (depth) { assert(end < protectedSource.length, `unclosed ${name}`); const ch = protectedSource[end++]; if (ch === '{') depth++; if (ch === '}') depth--; } let body = protectedSource.slice(headers.lastIndex, end - 1); const locals = new Set([...body.matchAll(/(? x[1])); body = body .replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]') .replace(/\.size\b/g, '.length') .replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);') .replace(/self thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);') .replace(/(self|level) waittill\(([^;]+)\);/g, 'yield { entity: $1, event: $2 };') .replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);') .replace(/self (\w+)\(/g, '$1(') .replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };') .replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetValue|SetShader|Destroy)\(/g, '$1.$2(') .replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]); result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); } return result.join('\n'); } const commands = [ ['.help', 'List available commands'], ['.afk', 'Pause with protection'], ['.back', 'Return from AFK'], ['.join', 'Join mid-round (newcomers only)'], ['.leaders', 'Show the map record again'], ['.b', 'Show bank balance'], ['.d ', 'Deposit points'], ['.w ', 'Withdraw points'], ['.x', 'Extra command'], ]; function engine(game, count = 8) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); let now = 0, running; const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = []; function resume(task) { const prior = running; running = task; const result = task.generator.next(); task.done = result.done; task.wait = result.value; task.at = result.value?.milliseconds !== undefined ? now + result.value.milliseconds : Infinity; running = prior; } function startThread(fn, args) { const task = { generator: fn(...args), ends: [] }; tasks.push(task); resume(task); } function emit(entity, event) { const listeners = tasks.filter(t => !t.done); for (const task of listeners) if (task.ends.some(([e, n]) => e === entity && n === event)) task.done = true; for (const task of listeners) if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task); } 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; } function newElement(owner) { assert.equal(owner, self, 'every HUD element must be client-owned'); const element = { alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0, SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; }, SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; }, SetShader(shader, width, height) { assert(!this.destroyCalls); Object.assign(this, { shader, width, height }); }, Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; }, }; elements.push(element); return element; } const c = vm.createContext({ self, level, NewClientHudElem: newElement, getTime: () => now, spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null, max: Math.max, int: Math.trunc, getSubStr: (text, begin, end) => { assert(end <= text.length); return text.slice(begin, end); }, endon: (entity, event) => running.ends.push([entity, event]), startThread, emit, }); vm.runInContext(translate(source), c); c.common_config(); for (const [command, description] of commands.slice(0, count).reverse()) c.register_help_command(command, description); for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]); return { c, self, level, elements, tasks, advance, source, help: () => c.show_help(), record: snapshot => c.show_record(snapshot), event: name => emit(self, name), end: name => emit(level, name), get state() { return self.tws_service; }, get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); }, get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); }, }; } const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines }); for (const game of ['t5', 't6']) { for (const count of [1, 4, 5, 8, 9]) check(`${game} ${count} registry entries paginate, order and dismiss`, () => { const e = engine(game, count); assert.equal(e.elements.length, 0, 'service is lazy and independent of core'); e.help(); const s = e.state, background = [s.background.x, s.background.y, s.background.width, s.background.height]; const pages = Math.ceil(count / 4); assert.equal(s.help.pages, pages); assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0])); for (let page = 0; page < pages; page++) { assert.equal(s.help.page, page); assert.equal(s.page_number.value, page + 1); assert.equal(s.page_total.value, pages); assert.equal(s.command_column.text.split('\n').length, Math.min(4, count - page * 4)); assert.equal(s.description_column.text.split('\n').length, Math.min(4, count - page * 4)); assert.deepEqual([s.background.x, s.background.y, s.background.width, s.background.height], background); e.advance(12000); } assert.equal(s.help, undefined); assert.equal(e.visible.length, 0); }); check(`${game} repeated help reuses strings/elements and resets page one`, () => { const e = engine(game); e.help(); const count = e.elements.length, texts = e.textCalls; e.help(); assert.equal(e.textCalls, texts, 'repeating current page does not reassign text'); e.advance(12000); assert.equal(e.state.help.page, 1); e.help(); assert.equal(e.state.help.page, 0); e.advance(11900); e.help(); e.advance(100); assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement'); e.advance(23900); assert.equal(e.state.help, undefined); assert.equal(e.elements.length, count); assert.equal(e.textCalls, texts + 6, 'only three page transitions update the two text columns'); }); check(`${game} help pauses record time and resumes the same page`, () => { const e = engine(game); e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); assert.equal(e.state.record.page, 1); const remaining = e.state.record.remaining; e.help(); const texts = e.textCalls; e.advance(24000); assert.equal(e.state.help, undefined); assert.equal(e.state.record.page, 1); assert.equal(e.state.record.remaining, remaining); assert.equal(e.state.rows[0].text, 'Four'); assert.equal(e.textCalls, texts + 2, 'only the help page transition changes text; record resumption does not'); e.advance(remaining); assert.equal(e.state.record, undefined); assert.equal(e.visible.length, 0); }); check(`${game} latest record replaces pending one without disturbing help`, () => { const e = engine(game); e.help(); e.advance(3000); const help = e.state.help; e.record(snapshot(['Old'])); e.record(snapshot(['Newest'])); assert.equal(e.state.help, help); assert.equal(e.state.record.page, 0); assert.equal(e.state.record.remaining, 12000); assert.equal(e.state.rows.filter(row => row.alpha > 0).length, 0); e.advance(21000); assert.equal(e.state.rows[0].text, 'Newest'); assert.equal(e.state.record.remaining, 12000); e.advance(11900); e.record(snapshot(['Newest'])); const texts = e.textCalls; e.advance(100); assert(e.state.record, 'previous expiry cannot hide newer request'); assert.equal(e.textCalls, texts); }); check(`${game} no record, unavailable and short final record page are distinct`, () => { const e = engine(game); e.record(undefined); assert.equal(e.state.unavailable.alpha, 1); assert.equal(e.state.empty.alpha, 0); e.record({ round: 0, lines: [] }); assert.equal(e.state.unavailable.alpha, 0); assert.equal(e.state.empty.alpha, 1); e.record(snapshot(['A', 'B', 'C', 'D'])); assert.equal(e.state.empty.alpha, 0); assert.equal(e.state.round_number.value, 82); e.advance(12000); assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]); assert.equal(e.state.rows[0].text, 'D'); }); for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => { const e = engine(game); e.record(snapshot()); e.help(); e.advance(2000); if (event === 'disconnect') e.event(event); else e.end(event); assert(e.state.closed); assert.equal(e.state.help, undefined); assert.equal(e.state.record, undefined); assert(e.elements.every(elem => elem.destroyCalls === 1)); const count = e.elements.length; e.advance(60000); if (event !== 'disconnect') { e.help(); e.record(snapshot()); e.event('disconnect'); assert.equal(e.elements.length, count, 'game end blocks new service requests'); } assert(e.elements.every(elem => elem.destroyCalls === 1)); }); check(`${game} ending before first service blocks lazy creation`, () => { const e = engine(game); e.end('end_game'); e.help(); e.record(snapshot()); assert.equal(e.elements.length, 0); }); check(`${game} no per-tick text updates, stable core origin and private elements`, () => { const e = engine(game); e.record(snapshot()); const calls = e.textCalls, count = e.elements.length; e.advance(11000); assert.equal(e.textCalls, calls); assert.equal(e.elements.length, count); assert.equal(e.state.background.horzAlign, 'right'); assert.equal(e.state.background.vertAlign, game === 't5' ? 'top' : 'fullscreen'); assert.equal(e.state.background.y, e.level.hud_panel_top + e.level.hud_service_y); assert(e.state.background.y > e.level.hud_panel_top + e.level.hud_core_height); assert.equal(e.state.commands[3].command, '.join'); assert.match(e.c.help_description(e.state.commands[3]), /newcomer/); assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d '); assert.equal(e.elements.length, 19, 'service allocation is bounded even when hidden'); assert(e.elements.every(elem => elem.archived === false)); }); check(`${game} nominal font boxes fit the fixed panels without row collisions`, () => { const e = engine(game); e.record(snapshot(['One', 'Two', 'Three'])); // Stock T6 _hud uses level.fontheight=12. This catches the original // 11-unit holder pitch / overflowing AFK footer, not actual glyph fit. const height = elem => Math.trunc(12 * elem.fontScale); const s = e.state; assert(s.holders_label.y + height(s.holders_label) <= s.rows[0].y); for (let i = 1; i < s.rows.length; i++) assert(s.rows[i - 1].y + height(s.rows[i - 1]) <= s.rows[i].y); assert(s.rows.at(-1).y + height(s.rows.at(-1)) <= s.record_footer.y); assert(s.record_footer.y + height(s.record_footer) < s.background.y + s.background.height); assert(e.level.hud_row_bar + e.level.hud_bar_height < e.level.hud_row_afk); assert(e.level.hud_row_afk + Math.trunc(12 * e.level.hud_font_afk) < e.level.hud_core_height); e.help(); const helpHeight = 4 * height(s.command_column); assert(s.command_column.y + helpHeight <= s.command_back.y + s.command_back.height); assert(s.command_column.y + helpHeight <= s.help_footer.y); }); check(`${game} script entrypoints and common references resolve`, () => { const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1])); const folder = new URL(`${game}/scripts/`, root); const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc')); assert.equal(scripts.length, 8); let connected = 0; for (const file of scripts) { const source = readFileSync(new URL(file, folder), 'utf8'); assert.match(source, /^init\(\)/m); if (game === 't6') { assert.match(source, /^main\(\)/m); assert(!/scripts\\sp\\|maps\\_utility|#"zombiemode"/.test(source)); } connected += (source.match(/waittill\("connected"/g) ?? []).length; for (const ref of source.matchAll(/tws_t[56]_common::(\w+)/g)) assert(functions.has(ref[1]), `${file} missing ${ref[1]}`); } assert.equal(connected, 1); }); } function eSource(game) { return readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); } check('paired service behavior remains identical', () => { const section = game => eSource(game).slice(eSource(game).indexOf('show_help()\n{'), eSource(game).indexOf('// The only connected listener;')); assert.equal(section('t5'), section('t6')); }); console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);