Simplify T5 and T6 HUD overlays and service displays

This commit is contained in:
2026-10-15 21:07:10 -04:00
parent 929bf0354b
commit 2b5b1955bc
15 changed files with 1934 additions and 643 deletions
+238 -59
View File
@@ -1,5 +1,6 @@
// Execute selected core HUD functions with engine-value shims.
// Execute selected core HUD/AFK functions with engine-value shims.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
@@ -10,86 +11,264 @@ function body(source, name) {
const match = new RegExp(`^${name}\\(([^\\n]*)\\)\\s*\\n\\{`, 'm').exec(source);
assert(match, name);
let at = match.index + match[0].length, end = at, depth = 1;
while (depth) { const ch = source[end++]; if (ch === '{') depth++; if (ch === '}') depth--; }
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
return [match[1], source.slice(at, end - 1)];
}
function install(context, source, names) {
for (const name of names) {
const [args, original] = body(source, name);
let code = original.replace(/\/\/[^\n]*/g, '');
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+|--)/g)].map(match => match[1]))];
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
.replace(/self (\w+)\(/g, 'self.$1(')
.replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
.replace(/level waittill\(([^;]*)\);/g, 'yield { event: $1 };')
.replace(/\.size\b/g, '.length')
.replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]')
.replace(/\bwait\s+([^;]+);/g, 'yield $1;');
vm.runInContext(`function${code.includes('yield ') ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context);
}
}
function engine(game, source, afk, hidden = false) {
const queued = [], elems = [], actions = [];
let now = 123456;
const self = { active: true };
if (hidden) self.tws_hud_hidden = true;
for (const name of ['enableInvulnerability', 'disableInvulnerability', 'freezeControls',
'disableWeapons', 'disableOffhandWeapons', 'enableWeapons', 'enableOffhandWeapons', 'iPrintLnBold']) {
self[name] = (...args) => actions.push([name, ...args]);
}
function element(args, alpha = 1) {
const result = { args, alpha, writes: [],
SetText(value) { this.text = value; this.writes.push(['text', value]); },
SetValue(value) { this.value = value; this.writes.push(['value', value]); },
SetShader(...args) { this.shader = args; },
SetTimerUp(value) { this.timer = value; this.writes.push(['timer', value]); },
};
elems.push(result);
return result;
}
function register(player, elem) {
(player.tws_core_hud_elems ??= []).push(elem);
common.set_core_alpha(player, elem, elem.alpha);
return elem;
}
const common = {
make_core_overlay_text: (...args) => register(args[0], element(args)),
make_core_overlay_shader: (...args) => register(args[0], element(args, args[6])),
make_overlay_width: width => Math.trunc(Math.max(1, width)),
set_core_alpha(player, elem, alpha) {
if (!elem) return;
elem.tws_core_alpha = alpha;
elem.alpha = player.tws_hud_hidden ? 0 : alpha;
},
toggle_core_hud() {
self.tws_hud_hidden = !self.tws_hud_hidden;
for (const elem of self.tws_core_hud_elems ?? []) common.set_core_alpha(self, elem, elem.tws_core_alpha);
},
player_active: player => player.active,
format_mmss: ms => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`,
};
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2,
hud_overlay_timer_x: 6, hud_overlay_match_y: 8, hud_overlay_round_y: 30,
hud_overlay_brand_x: 320, hud_overlay_brand_y: 8,
hud_overlay_count_x: 300, hud_overlay_count_y: 440,
hud_overlay_count_label_x: 306, hud_overlay_count_label_y: 446,
hud_overlay_bar_x: 242, hud_overlay_bar_y: 466,
hud_overlay_bar_width: 156, hud_overlay_bar_height: 4,
hud_overlay_health_y: 460, hud_overlay_health_x: 414, hud_overlay_slash_x: 418,
hud_overlay_maxhealth_x: 445, hud_overlay_hp_x: 449,
hud_overlay_afk_x: 320, hud_overlay_afk_y: 421,
hud_c_count: 'white', hud_c_dim: 'dim', hud_c_afk: 'yellow',
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
const context = vm.createContext({ self, level, common, queued, getTime: () => now,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
};
}
for (const game of ['t5', 't6']) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_hud.gsc`, root), 'utf8');
const afk = readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`, root), 'utf8');
check(`${game}: shared lifecycle, frozen clocks and engine-specific timer seeds`, () => {
const make = hidden => engine(game, source, afk, hidden);
check(`${game}: native .hud chat and shared lifecycle retain engine-specific clock seeds`, () => {
assert(source.includes('register_help_command(".hud", "Toggle core HUD")'));
assert(source.includes('register_connect_handler(::hud_connect)'));
assert(source.includes('register_spawn_handler(::hud_start)'));
assert(!source.includes('waittill("connected"'));
const chat = body(source, 'hud_chat_listener')[1];
assert(chat.includes('level waittill("say", message, player)'));
assert(chat.includes('toLower(message) == ".hud"'));
assert(chat.includes('player thread hud_toggle()'), 'disconnect scope stays in the player worker');
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
assert(!source.includes('tws_hud_hidden ='), 'spawn/construction must not reset the preference');
for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind'])
assert(body(source, name)[1].includes('level endon("hud_game_over")'));
const bind = body(source, 'match_timer_bind')[1];
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
assert(afk.includes('make_panel_text'));
assert(afk.includes('level.hud_row_afk'));
assert(afk.includes('make_core_overlay_text'));
assert(afk.includes('level.hud_overlay_afk_y'));
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
});
const queued = [], elems = [];
const common = {
make_panel_text: (...args) => element(args),
make_panel_shader: (...args) => element(args),
};
function element(args) {
const result = { args, alpha: 1, writes: [], SetText(value) { this.text = value; this.writes.push(['text', value]); },
SetValue(value) { this.value = value; this.writes.push(['value', value]); },
SetShader(...args) { this.shader = args; }, SetTimerUp(value) { this.timer = value; } };
elems.push(result); return result;
}
const level = { round_number: 1, hud_panel_width: 220, hud_core_height: 100, hud_pad: 10,
hud_row_header: 10, hud_row_timers: 34, hud_row_stats: 59, hud_row_bar: 80,
hud_bar_width: 200, hud_bar_height: 4, hud_rate: .1,
hud_font_small: 1.1, hud_font_mid: 1.25,
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
const self = {};
const context = vm.createContext({ self, level, common, queued, getTime: () => 123456,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
for (const name of ['hud_connect', 'hud_text', 'hud_start', 'hud_timer_row', 'hud_health_display']) {
const [args, original] = body(source, name);
let code = original.replace(/\/\/[^\n]*/g, '');
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*=(?!=)/g)].map(match => match[1]))];
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, name, args) => `queued.push(['${name}', ${args}]);`)
.replace(/self (hud_\w+)\(/g, '$1(')
.replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
.replace(/\.size\b/g, '.length')
.replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]')
.replace(/wait \(([^;]*)\);/g, 'yield $1;');
vm.runInContext(`function${name === 'hud_health_display' ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context);
}
check(`${game}: connection constructs one fixed panel without seeding clocks`, () => {
context.hud_connect();
assert.equal(level.match_start_time, undefined); assert.equal(level.round_start_time, undefined);
assert.equal(self.tws_hud_match.text, '--:--'); assert.equal(self.tws_hud_health.text, '--');
const before = elems.length; context.hud_connect(); assert.equal(elems.length, before);
assert.equal(queued.length, 1); assert.equal(queued[0][0], 'common.destroy_on_disconnect');
assert.equal(queued[0][1].length, elems.length);
check(`${game}: connection places 11 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
assert.equal(e.elems.length, 11);
assert.equal(e.self.tws_core_hud_elems.length, 11);
e.context.hud_connect(); assert.equal(e.elems.length, 11);
assert.equal(e.queued.length, 1); assert.equal(e.queued[0][0], 'common.destroy_on_disconnect');
assert.equal(e.queued[0][1].length, e.elems.length);
const textAt = (text, x, y, align, scale, colour) => {
const elem = e.elems.find(item => item.text === text);
assert(elem, text); assert.deepEqual(elem.args.slice(1), [x, y, align, scale, colour]);
};
assert.deepEqual(e.self.tws_hud_match.args.slice(1), [6, 8, 'left', 1.4, 'white']);
assert.deepEqual(e.self.tws_hud_round.args.slice(1), [6, 30, 'left', 1.25, 'dim']);
textAt('TWS ZOMBIES · discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
assert.deepEqual(e.self.tws_hud_count.args.slice(1), [300, 440, 'right', 1.5, 'white']);
textAt('ZOMBIES', 306, 446, 'left', 1.05, 'dim');
textAt('HP', 449, 460, 'left', 1, 'white');
for (const [key, x, align] of [['health', 414, 'right'], ['slash', 418, 'left'], ['maxhealth', 445, 'right']]) {
const elem = e.self[`tws_hud_${key}`];
assert.deepEqual(elem.args.slice(1), [x, 460, align, 1, 'white']);
assert.equal(elem.alpha, 0); assert.equal(elem.tws_core_alpha, 0);
}
assert.equal(e.self.tws_hud_hp.alpha, 0);
const shaders = e.elems.filter(elem => elem.args.length === 8);
assert.equal(shaders.length, 2, 'only the health track and fill allocate shaders');
for (const elem of shaders) assert.deepEqual(elem.args.slice(1, 5), [242, 466, 156, 4]);
assert.equal(shaders[0].alpha, .8); assert.equal(shaders[1].alpha, 0);
assert(!e.elems.some(elem => elem.text === 'MATCH' || elem.text === 'ROUND'));
});
check(`${game}: repeated first-spawn binding reuses panel and launches workers once`, () => {
const before = elems.length; context.hud_start(); context.hud_start();
assert.equal(elems.length, before);
assert.deepEqual(queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
context.hud_timer_row(); assert.equal(level.match_start_time, 123456); assert.equal(level.round_start_time, 123456);
check(`${game}: toggling before first spawn preserves hidden state and binds workers once`, () => {
const e = make(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_hidden, true);
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.queued.length, 1, 'toggle only queues disconnect cleanup');
e.context.hud_start(); e.context.hud_start();
assert.equal(e.elems.length, 11); assert.equal(e.self.tws_hud_hidden, true);
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
e.context.hud_timer_row();
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
const threads = e.queued.length;
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 11);
});
check(`${game}: HP stays visible at full health and falls back without mutating maxhealth`, () => {
self.health = 100; const run = context.hud_health_display(); run.next();
assert.equal(self.maxhealth, undefined); assert.equal(self.tws_hud_health.value, 100);
assert.equal(self.tws_hud_maxhealth.value, 100); assert.equal(self.tws_hud_health.alpha, 1);
assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 200);
check(`${game}: health numbers appear only while damaged, follow the bar colour, and avoid config-string churn`, () => {
const e = make(); e.context.hud_connect(); const { self } = e;
const numbers = [self.tws_hud_health, self.tws_hud_slash, self.tws_hud_maxhealth, self.tws_hud_hp];
const run = e.context.hud_health_display(); run.next();
assert(numbers.every(elem => elem.alpha === 0), 'undefined health does not flash a damage readout');
assert.equal(self.health, undefined); assert.equal(self.maxhealth, undefined);
self.health = 100; run.next();
assert.equal(self.tws_hud_health.value, 100); assert.equal(self.tws_hud_maxhealth.value, 100);
assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 156);
const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes);
self.health = 35; run.next();
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'yellow'));
assert.equal(self.tws_hud_health.value, 35); assert.equal(self.tws_hud_bar.shader[1], 54);
self.maxhealth = 250; self.health = 50; run.next();
assert.equal(self.tws_hud_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 40);
assert.equal(self.tws_hud_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 31);
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'red'));
assert.equal(self.tws_hud_bar.color, 'red');
self.health = 0; run.next(); assert.equal(self.tws_hud_bar.alpha, 0); assert.equal(self.tws_hud_health.value, 0);
assert(numbers.every(elem => elem.alpha === 1));
self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green');
assert(numbers.every(elem => elem.alpha === 0 && elem.tws_core_alpha === 0));
self.health = 500; run.next(); assert.equal(self.tws_hud_bar.shader[1], 156);
assert(numbers.every(elem => elem.alpha === 0), 'overheal must not show the damage readout');
self.maxhealth = 0; self.health = 100; run.next();
assert.equal(self.maxhealth, 0, 'invalid maximum is display-only fallback');
assert.equal(self.tws_hud_maxhealth.value, 100); assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1);
});
check(`${game}: hidden health updates remain private and restore the current bar state`, () => {
const e = make(true), other = make();
e.context.hud_connect(); other.context.hud_connect();
e.self.health = 100; const run = e.context.hud_health_display(); run.next();
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.self.tws_hud_bar.tws_core_alpha, 1);
e.self.health = 0; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.value, 0); assert.equal(e.self.tws_hud_bar.alpha, 0);
assert.equal(e.elems[0].alpha, 1); assert.equal(e.elems.at(-2).alpha, .8);
e.context.hud_toggle(); e.self.health = 50; run.next();
assert.equal(e.self.tws_hud_bar.alpha, 0); assert.equal(e.self.tws_hud_bar.shader[1], 78);
e.context.hud_toggle();
assert.equal(e.self.tws_hud_bar.alpha, 1); assert.equal(e.self.tws_hud_health.value, 50);
assert.equal(other.self.tws_hud_hidden, undefined); assert.equal(other.self.tws_hud_health.alpha, 0);
assert.equal(e.elems.length, 11); assert.equal(e.queued.length, 1);
e.context.hud_toggle(); e.self.health = 100; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.alpha, 0, 'healing while hidden conceals the readout when shown');
assert.equal(e.self.tws_hud_hp.alpha, 0); assert.equal(e.self.tws_hud_bar.alpha, 1);
});
check(`${game}: toggling frozen clocks does not restart or reseed them`, () => {
const e = make(); e.context.hud_start(); e.context.hud_timer_row();
const match = e.self.tws_hud_match, round = e.self.tws_hud_round;
const freeze = e.context.freeze_timers_on_game_over(match, round);
assert.equal(freeze.next().value.event, 'hud_game_over');
e.context.hud_toggle(); e.setTime(248456); e.level.tws_ended = true; freeze.next();
assert.equal(match.text, '2:05'); assert.equal(round.text, '2:05');
const before = JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]);
e.setTime(999999); e.context.hud_toggle(); e.context.hud_start();
assert.equal(match.alpha, 1); assert.equal(round.alpha, 1);
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
});
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
const e = make(); e.context.hud_connect(); e.spawnAfk();
const indicator = e.self.hud_afk_elem;
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
e.context.hud_toggle(); e.context.afk_enter();
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
const actions = e.actions.length;
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
e.context.hud_toggle(); const exit = e.context.afk_exit();
assert.equal(exit.next().value, 2);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
exit.next(); assert.equal(e.self.ignoreme, false);
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
assert.equal(e.elems.length, 12);
});
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
watcher.next();
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true);
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
e.self.active = true; watcher.next();
assert.equal(e.self.tws_afk_controls_locked, false);
assert(e.actions.some(([name]) => name === 'enableWeapons'));
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
});
}
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
+350 -68
View File
@@ -1,5 +1,6 @@
// 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.
// 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';
@@ -11,7 +12,7 @@ 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']);
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 => {
@@ -23,7 +24,7 @@ function translate(source) {
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 (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
@@ -35,6 +36,7 @@ function translate(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => 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);')
@@ -57,10 +59,11 @@ const commands = [
['.d <amount|all>', 'Deposit points'], ['.w <amount|all>', 'Withdraw points'],
['.x', 'Extra command'],
];
function engine(game, count = 8) {
function engine(game, count = 8, withHud = false) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
let now = 0, running;
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;
@@ -91,76 +94,190 @@ function engine(game, count = 8) {
now = until;
}
function newElement(owner) {
assert.equal(owner, self, 'every HUD element must be client-owned');
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++; },
SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; },
SetShader(shader, width, height) { assert(!this.destroyCalls); Object.assign(this, { shader, width, height }); },
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,
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,
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 { c, self, level, elements, tasks, advance, source,
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_spawn();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
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 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']) {
for (const count of [1, 4, 5, 8, 9]) check(`${game} ${count} registry entries paginate, order and dismiss`, () => {
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 <amount|all>', '.w <amount|all>']);
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, 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);
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]));
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);
}
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.visible.length, 0);
assert.equal(e.live.length, 0, 'idle must release even hidden elements');
});
check(`${game} repeated help reuses strings/elements and resets page one`, () => {
const e = engine(game);
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');
e.advance(23900);
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.textCalls, texts + 6, 'only three page transitions update the two text columns');
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
const e = engine(game);
@@ -169,16 +286,18 @@ for (const game of ['t5', 't6']) {
assert.equal(e.state.record.page, 1);
const remaining = e.state.record.remaining;
e.help();
const texts = e.textCalls;
e.advance(24000);
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.textCalls, texts + 2, 'only the help page transition changes text; record resumption does not');
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.visible.length, 0);
assert.equal(e.live.length, 0);
});
check(`${game} latest record replaces pending one without disturbing help`, () => {
const e = engine(game);
@@ -190,10 +309,11 @@ for (const game of ['t5', 't6']) {
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.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;
@@ -201,26 +321,40 @@ for (const game of ['t5', 't6']) {
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`, () => {
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game);
e.record(undefined);
assert.equal(e.state.unavailable.alpha, 1);
assert.equal(e.state.empty.alpha, 0);
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.unavailable.alpha, 0);
assert.equal(e.state.empty.alpha, 1);
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.equal(e.state.empty.alpha, 0);
assert.equal(e.state.round_number.value, 82);
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);
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);
@@ -239,42 +373,131 @@ for (const game of ['t5', 't6']) {
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`, () => {
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.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.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 <n|all>');
assert.equal(e.elements.length, 19, 'service allocation is bounded even when hidden');
assert(e.elements.every(elem => elem.archived === false));
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
assertTextOnly(e);
});
check(`${game} nominal font boxes fit the fixed panels without row collisions`, () => {
const e = engine(game);
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
const e = engine(game, 8, true);
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);
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);
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);
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);
@@ -295,8 +518,67 @@ for (const game of ['t5', 't6']) {
});
}
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'));
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, 12, 'eleven core elements plus one optional AFK indicator');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetValue(6);
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, '12 core/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.`);