// Execute the actual common GSC service functions with HUD/event shims. // These fixtures do not emulate the GSC VM, fonts or HUD pool limits. Geometry // checks model the 640x480 fullscreen placement transform, not a live renderer. 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', 'destroy_on_disconnect']); 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', 'show_record_reminder', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].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(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '') .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|SetTextUnlimited|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, withHud = false) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); let now = 0, running, peakLive = 0; const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = []; const players = new Set([self]); 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(players.has(owner), 'every HUD element must be owned by a known client'); const element = { owner, alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0, SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; }, SetTextUnlimited(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); assert(Number.isInteger(width) && Number.isInteger(height), 'SetShader requires integer dimensions'); Object.assign(this, { shader, width, height }); }, Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; }, }; elements.push(element); peakLive = Math.max(peakLive, elements.filter(elem => !elem.destroyCalls).length); return element; } const c = vm.createContext({ self, level, NewClientHudElem: newElement, getTime: () => now, spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null && !v.destroyCalls, 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); if (withHud) c.register_help_command('.hud', 'Toggle core HUD'); for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]); return { game, c, self, level, elements, tasks, advance, source, createCore() { assert.equal(game, 't5', 'combined fixture currently covers T5 construction'); for (const module of ['hud', 'afk']) vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c); c.hud_connect(); c.afk_hud_create(); }, addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; }, help: () => c.show_help(), record: snapshot => c.show_record(snapshot), reminder: (snapshot, message) => c.show_record_reminder(snapshot, message), event: name => emit(self, name), end: name => emit(level, name), get state() { return self.tws_service; }, get live() { return elements.filter(e => !e.destroyCalls); }, get peakLive() { return peakLive; }, get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); }, get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); }, get helpCommands() { return Array.from(self.tws_service.command_rows).filter(row => row.alpha > 0).map(row => row.text); }, get helpDescriptions() { return Array.from(self.tws_service.description_rows).filter(row => row.alpha > 0).map(row => row.text); }, }; } const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines }); function assertCounter(e, page, pages) { const counter = e.state.page_counter; if (pages === 1) assert.equal(counter, undefined, 'single-page services do not allocate a counter'); else { assert.equal(counter.alpha, 1); assert.equal(counter.text, `${page} / ${pages}`); } } function assertTextOnly(e) { assert(e.state.elems.every(elem => elem.shader === undefined && elem.archived === false), '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`, () => { const e = engine(game, 8, true); e.help(); assert.deepEqual(e.helpCommands, ['.help', '.hud', '.afk', '.back', '.join', '.leaders', '.b', '.d ', '.w ']); assert.deepEqual(e.helpDescriptions, ['Show commands', 'Toggle core HUD', 'Protect & freeze', 'Return to play', 'Join as newcomer', 'View records', 'Bank balance', 'Deposit', 'Withdraw']); assert.equal(e.state.help.pages, 1); assertCounter(e, 1, 1); assert.equal(e.live.length, 19); assertTextOnly(e); e.advance(12000); assert.equal(e.live.length, 0); const withoutHud = engine(game); withoutHud.help(); assert(!withoutHud.state.commands.some(entry => entry.command === '.hud')); }); 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); const bar = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0, 11); const label = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]); label.SetText('48:16'); const other = e.addPlayer(); const otherLabel = e.c.make_core_overlay_text(other, 6, 8, 'left', 1.4, [1, 1, 1]); e.record(snapshot()); e.help(); const serviceAlpha = Array.from(e.state.elems, elem => elem.alpha); const help = e.state.help, record = e.state.record; e.c.toggle_core_hud(); assert.equal(e.self.tws_hud_hidden, true); assert([track, bar, label].every(elem => elem.alpha === 0)); assert.equal(otherLabel.alpha, 1); assert.equal(other.tws_hud_hidden, undefined); e.c.set_core_alpha(e.self, bar, 1); const afk = e.c.make_core_overlay_text(e.self, 320, 421, 'center', 1.0, [1, 1, 1]); assert.equal(afk.alpha, 0, 'late core elements inherit the hidden state'); e.c.set_core_alpha(e.self, afk, 0); const count = e.elements.length; e.c.toggle_core_hud(); assert.deepEqual([track.alpha, bar.alpha, label.alpha, afk.alpha], [0.8, 1, 1, 0]); assert.equal(label.text, '48:16', 'toggle does not rewrite displayed data'); assert.equal(e.elements.length, count); assert.deepEqual(Array.from(e.state.elems, elem => elem.alpha), serviceAlpha); assert.equal(e.state.help, help); assert.equal(e.state.record, record); e.c.toggle_core_hud(); e.c.set_core_alpha(e.self, bar, 0); e.c.set_core_alpha(e.self, afk, 1); assert.equal(afk.alpha, 0); e.c.toggle_core_hud(); assert.equal(bar.alpha, 0); assert.equal(afk.alpha, 1); label.Destroy(); e.c.toggle_core_hud(); assert(!e.self.tws_core_hud_elems.includes(label), 'toggle prunes destroyed elements'); assert.equal(label.destroyCalls, 1, 'registry never owns element destruction'); }); check(`${game} hiding before construction survives late core creation and prunes dead registrations`, () => { const e = engine(game); e.c.toggle_core_hud(); const old = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]); assert.equal(old.alpha, 0); old.Destroy(); const fresh = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]); assert.equal(fresh.alpha, 0); assert.equal(e.self.tws_core_hud_elems.length, 1); e.c.toggle_core_hud(); assert.equal(fresh.alpha, 1); assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined); const reconnected = e.addPlayer(); const newLabel = e.c.make_core_overlay_text(reconnected, 6, 8, 'left', 1.4, [1, 1, 1]); assert.equal(newLabel.alpha, 1, 'a new player defaults to visible'); }); for (const count of [0, 1, 4, 8, 9]) check(`${game} ${count} registry entries 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; assert.equal(s.help.pages, 1); assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0])); assertCounter(e, 1, 1); assert.deepEqual(e.helpCommands, Array.from(s.commands, entry => e.c.help_command_label(entry.command))); assert.deepEqual(e.helpDescriptions, Array.from(s.commands, entry => e.c.help_description(entry))); assert.equal(e.live.length, count * 2 + 1); assertTextOnly(e); e.advance(12000); assert.equal(s.help, undefined); assert.equal(e.live.length, 0, 'idle must release even hidden elements'); }); check(`${game} overflow help pages reuse rows and repeated help resets expiry`, () => { const e = engine(game, 9, true); e.help(); assert.equal(e.state.help.pages, 2); assert.equal(e.live.length, 20); assertCounter(e, 1, 2); const rows = [...e.state.command_rows, ...e.state.description_rows]; 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); assert.deepEqual(e.helpCommands, ['.x']); assertCounter(e, 2, 2); assert.deepEqual(Array.from(e.state.command_rows, row => row.alpha), [1, 0, 0, 0, 0, 0, 0, 0, 0]); 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'); assert.deepEqual([...e.state.command_rows, ...e.state.description_rows], rows); e.advance(11900); assertCounter(e, 2, 2); e.advance(12000); assert.equal(e.state.help, undefined); assert.equal(e.elements.length, count); assert.equal(e.live.length, 0); }); 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(); e.advance(12000); 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.live.length, 5); const resumedTexts = e.textCalls; e.c.service_render(e.state); assert.equal(e.textCalls, resumedTexts, 'subsequent ticks must reuse the rendered view'); e.advance(remaining); assert.equal(e.state.record, undefined); assert.equal(e.live.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.length, 0, 'pending records allocate no holder elements'); e.advance(9000); assert.equal(e.state.rows[0].text, 'Newest'); assert.equal(e.state.record.remaining, 12000); assertCounter(e, 1, 1); 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} automatic reminders use two elements and expire after six seconds`, () => { const e = engine(game); assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true); const request = e.state.record; assert.equal(request.kind, 'reminder'); assert.equal(request.pages, 1); assert.equal(request.remaining, 6000); assert.equal(e.state.record_title.text, 'Map record | Round 82'); assert.equal(e.state.rows.length, 1); assert.equal(e.state.rows[0].text, '5 rounds to tie'); assert.equal(e.live.length, 2); assert.equal(e.visible.length, 2); assertCounter(e, 1, 1); assertTextOnly(e); const calls = e.textCalls, allocated = e.elements.length; e.advance(5900); assert.equal(e.state.record, request); assert.equal(request.remaining, 100); assert.equal(e.reminder({ round: 83 }, 'Newer target'), false); assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint'); assert.equal(request.remaining, 100); assert.equal(e.textCalls, calls); assert.equal(e.elements.length, allocated); e.advance(100); assert.equal(e.state.record, undefined); assert.equal(e.live.length, 0); assert(e.elements.every(elem => elem.destroyCalls === 1)); assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true); assert.equal(e.state.record.remaining, 6000); assert.equal(e.live.length, 2); assert.equal(e.peakLive, 2); }); check(`${game} automatic reminders never replace or queue behind help and full records`, () => { const e = engine(game); e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(1000); const record = e.state.record; const generation = e.state.generation, allocations = e.elements.length; assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false); assert.equal(e.state.record, record); assert.equal(record.remaining, 11000); assert.equal(e.state.generation, generation); assert.equal(e.elements.length, allocations); e.help(); e.advance(1000); const help = e.state.help, withHelp = e.elements.length; assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false); assert.equal(e.state.help, help); assert.equal(e.state.record, record); assert.equal(e.elements.length, withHelp); e.advance(11000); assert.equal(e.state.record, record); assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time'); e.advance(23000); assert.equal(e.state.record, undefined); assert.equal(e.live.length, 0, 'skipped hints do not appear later'); e.help(); assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false); assert.equal(e.state.record, undefined, 'help alone must not queue a hint'); e.advance(12000); assert.equal(e.live.length, 0); }); check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => { const e = engine(game); e.reminder({ round: 82 }, '5 rounds to tie'); const reminderElements = [...e.live]; e.advance(2000); e.help(); assert.equal(e.state.record, undefined); assert(reminderElements.every(elem => elem.destroyCalls === 1)); e.advance(12000); assert.equal(e.state.help, undefined); assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help'); e.reminder({ round: 82 }, '4 rounds to tie'); const replaced = [...e.live]; e.advance(5900); e.record(snapshot(['One', 'Two', 'Three', 'Four'])); assert.equal(e.state.record.kind, 'record'); assert.equal(e.state.record.remaining, 12000); assert.equal(e.state.record.pages, 2); assert.equal(e.live.length, 5); assert(replaced.every(elem => elem.destroyCalls === 1)); e.advance(100); assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay'); assert.equal(e.state.record.remaining, 11900); e.advance(11900); assert.equal(e.state.record.page, 1); assert.equal(e.state.rows[0].text, 'Four'); e.advance(12000); assert.equal(e.live.length, 0); }); check(`${game} invalid reminders do not initialize a service or allocate elements`, () => { const e = engine(game); for (const bad of [undefined, {}, { round: 0 }, { round: -1 }]) assert.equal(e.reminder(bad, '5 rounds to tie'), false); for (const message of [undefined, '']) assert.equal(e.reminder({ round: 82 }, message), false); assert.equal(e.state, undefined); assert.equal(e.elements.length, 0); }); for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans a reminder and blocks reopening`, () => { const e = engine(game); e.reminder({ round: 82 }, '5 rounds to tie'); e.advance(1000); if (event === 'disconnect') e.event(event); else e.end(event); assert.equal(e.state.closed, true); assert.equal(e.state.record, undefined); assert.equal(e.live.length, 0); assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false); e.advance(12000); assert.equal(e.elements.length, 2); assert(e.elements.every(elem => elem.destroyCalls === 1)); }); check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => { const e = engine(game); e.record(undefined); assert.equal(e.state.record_title.alpha, 1); assert.equal(e.state.record_title.text, 'Map record unavailable'); assert.equal(e.live.length, 4); assertCounter(e, 1, 1); const first = [...e.live]; e.record({ round: 0, lines: [] }); assert.equal(e.state.record_title.text, 'No recorded high round yet'); assert.deepEqual(e.live, first); assertCounter(e, 1, 1); e.record(snapshot(['A', 'B', 'C', 'D'])); assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first'); assert.equal(e.state.record_title.text, 'Map record | Round 82'); assert.equal(e.live.length, 5); const paged = [...e.live]; assertCounter(e, 1, 2); e.advance(12000); assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]); assert.equal(e.state.rows[0].text, 'D'); assertCounter(e, 2, 2); assert.deepEqual(e.live, paged, 'page changes reuse the same allocation'); e.record(snapshot(['Single page'])); assert(paged.every(elem => elem.destroyCalls === 1)); assertCounter(e, 1, 1); assert.equal(e.state.rows[0].text, 'Single page'); assert.equal(e.live.length, 4); assert.equal(e.peakLive, 5, 'new allocations never overlap the released view'); assertTextOnly(e); }); 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.reminder({ round: 82 }, '5 rounds to tie'), false); assert.equal(e.state, undefined); assert.equal(e.elements.length, 0); }); check(`${game} rendered records cache text and never allocate on timer ticks`, () => { 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.record_title.text, 'Map record | Round 82'); 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, 4, 'record allocation includes all hidden holder rows'); assertTextOnly(e); }); check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => { const e = engine(game, 8, true); e.record(snapshot(['One', 'Two', 'Three'])); const s = e.state; assert.deepEqual([s.record_title.x, s.record_title.y, s.record_title.alignX], [633, 36, 'right']); assert.deepEqual(Array.from(s.rows, row => [row.x, row.y, row.alignX]), [[633, 60, 'right'], [633, 78, 'right'], [633, 96, 'right']]); const records = [...s.elems]; e.help(); assert.deepEqual([s.help_title.x, s.help_title.y], [432, 54]); for (const [i, row] of s.command_rows.entries()) { assert.equal(row.x, 432); assert.equal(row.y, 80 + i * 24); assert.equal(s.description_rows[i].x, 538); assert.equal(s.description_rows[i].y, row.y); } for (const [width, height] of [[1920, 1080], [1280, 720], [1600, 1200], [3440, 1440]]) { const project = elem => ({ x: elem.x * width / 640, y: elem.y * height / 480 }); for (const elem of [...records, ...s.elems]) { assert.equal(elem.horzAlign, 'fullscreen'); assert.equal(elem.vertAlign, 'fullscreen'); const point = project(elem); assert(point.x > width / 2 && point.x < width); assert(point.y < height * 0.6, 'leave scores and ammo clear'); } assert.equal(width - project(records[0]).x, 7 * width / 640); if (width === 1920) assert(project(records[0]).y > 27, 'clear the reference Plutonium tag'); } }); check(`${game} new and legacy shader helpers retain integer dimensions`, () => { const e = engine(game); // Native GSC max returns VAR_FLOAT even for integral values. Boxed // numbers retain that distinction here; the explicit int() unboxes it. e.c.max = (...args) => new Number(Math.max(...args)); const legacy = e.c.make_panel_shader(e.self, 0, 0, 220, 100, e.level.hud_c_panel, e.level.hud_panel_alpha, 0); assert.equal(legacy.width, 165); assert.equal(legacy.alpha, 0.72); assert.deepEqual(Array.from(legacy.color), [0.07, 0.10, 0.08]); const overlay = e.c.make_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0.8, 10); assert.deepEqual([overlay.x, overlay.y, overlay.width, overlay.height], [242, 466, 156, 4]); for (const [design, panelWidth, overlayWidth] of [[200, 150, 200], [40, 30, 40], [65.9, 49, 65], [0, 1, 1], [0.1, 1, 1]]) { assert.equal(e.c.make_panel_width(design), panelWidth); assert.equal(e.c.make_overlay_width(design), overlayWidth); legacy.SetShader('white', e.c.make_panel_width(design), 4); overlay.SetShader('white', e.c.make_overlay_width(design), 4); } assert.throws(() => overlay.SetShader('white', e.c.max(1, 156), 4), /requires integer dimensions/); const oldCore = e.c.make_core_text(e.self, 10, 10, 'left', 1.1, [1, 1, 1]); const oldShader = e.c.make_core_shader(e.self, 10, 80, 200, 4, [1, 1, 1], 0.5, 10); e.c.toggle_core_hud(); assert.equal(oldCore.alpha, 0); assert.equal(oldShader.alpha, 0); e.c.toggle_core_hud(); assert.equal(oldCore.alpha, 1); assert.equal(oldShader.alpha, 0.5); }); check(`${game} explicit service rows fit nominal font bounds without newline dependence`, () => { const e = engine(game, 8, true); e.record(snapshot(['One', 'Two', 'Three'])); const height = elem => Math.trunc(12 * elem.fontScale); let s = e.state; assert(s.record_title.y + height(s.record_title) <= 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); e.help(); const rows = [...s.command_rows, ...s.description_rows]; assert.equal(new Set(rows).size, 18); assert.equal(new Set(s.command_rows.map(row => row.y)).size, 9); assert(rows.every(row => row.alpha === 1 && !/[\r\n]/.test(row.text))); for (let i = 0; i < s.command_rows.length; i++) { const command = s.command_rows[i], description = s.description_rows[i]; assert.equal(command.y, description.y); if (i > 0) assert(s.command_rows[i - 1].y + height(s.command_rows[i - 1]) < command.y); assert(command.y + height(command) < 320); } assert(e.elements.filter(elem => elem.fontScale !== undefined).every(elem => elem.fontScale >= 1.0)); assert.equal(e.c.make_text_elem(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0); assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0); assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 1.6, [1, 1, 1]).fontScale, 1.6); }); check(`${game} help rebuilds after records, caching each new view without allocation overlap`, () => { const e = engine(game, 8, true); e.help(); const rows = [...e.state.command_rows, ...e.state.description_rows]; const calls = e.textCalls; e.help(); e.advance(11000); assert.equal(e.textCalls, calls); e.record(snapshot()); e.advance(1000); assert(rows.every(row => row.destroyCalls === 1)); assert.equal(e.live.length, 4); e.help(); assert.equal(e.live.length, 19); assert(e.state.command_rows.every(row => !rows.includes(row))); assert.equal(e.peakLive, 19); const recreatedCalls = e.textCalls; e.help(); assert.equal(e.textCalls, recreatedCalls); e.event('disconnect'); assert(e.elements.every(row => row.destroyCalls === 1)); }); for (const event of ['disconnect', 'end_game', 'game_ended', 'intermission']) check(`${game} ${event} after idle cannot double-destroy or recreate a released view`, () => { const e = engine(game); e.record(snapshot()); e.advance(12000); assert.equal(e.live.length, 0); const allocated = e.elements.length; if (event === 'disconnect') e.event(event); else e.end(event); assert(e.state.closed); e.advance(60000); if (event !== 'disconnect') { e.help(); e.record(snapshot()); e.event('disconnect'); } assert.equal(e.elements.length, allocated); assert(e.elements.every(elem => elem.destroyCalls === 1)); }); 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, 9); 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('T5 service transitions preserve actual core element identities, styles, data and visibility', () => { const e = engine('t5', 8, true); e.createCore(); const core = Array.from(e.self.tws_core_hud_elems); assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown'); assert.equal(e.live.length, 12); e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES'); e.self.tws_hud_health.SetValue(100); e.self.tws_hud_maxhealth.SetValue(100); e.c.set_core_alpha(e.self, e.self.tws_hud_bar, 1); const state = () => core.map(elem => ({ alpha: elem.alpha, text: elem.text, value: elem.value, x: elem.x, y: elem.y, width: elem.width, height: elem.height, shader: elem.shader, color: elem.color, fontScale: elem.fontScale, alignX: elem.alignX, textCalls: elem.textCalls, valueCalls: elem.valueCalls, destroyCalls: elem.destroyCalls })); const initial = state(); e.advance(3000); e.record(snapshot(['Jeff', 'Lokester', 'Moniialx'])); const controller = e.state, tasks = e.tasks.length; assert.equal(e.live.length, 16); assert.deepEqual(state(), initial); e.advance(12000); assert.equal(e.state, controller); assert.equal(e.state.closed, false, 'idle releases the view, not its controller'); assert.equal(e.state.elems.length, 0); assert.equal(e.live.length, 12); assert.deepEqual(state(), initial); for (let i = 0; i < 4; i++) { e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help(); assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion'); assert.equal(e.live.filter(elem => elem.archived === false).length, 19); e.c.toggle_core_hud(); assert(core.every(elem => elem.alpha === 0)); const allocations = e.elements.length; e.record(snapshot(['Latest'])); assert.equal(e.elements.length, allocations, 'a pending record must not allocate another view'); e.advance(12000); assert.equal(e.state.rows[0].text, 'Latest'); assert.equal(e.state.record.remaining, 12000); assert.equal(e.live.length, 16); assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0)); e.c.toggle_core_hud(); assert.deepEqual(state(), initial); e.advance(12000); assert.equal(e.live.length, 12); assert.equal(e.state.elems.length, 0); assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers'); assert.deepEqual(state(), initial); } assert.equal(e.peakLive, 31); assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core); e.event('disconnect'); assert.equal(e.live.length, 0); assert(e.elements.every(elem => elem.destroyCalls === 1)); }); check('paired service requests, rendering and lifecycle remain identical', () => { const sections = game => { const source = eSource(game); const service = source.slice(source.indexOf('show_help()\n{'), source.indexOf('on_player_connect_dispatch()\n{')); assert(service.length > 500, 'parity comparison must include actual service functions'); return service; }; assert.deepEqual(sections('t5'), sections('t6')); }); console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);