275 lines
17 KiB
JavaScript
275 lines
17 KiB
JavaScript
// 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';
|
|
const root = new URL('../', import.meta.url);
|
|
let checks = 0;
|
|
function check(name, fn) { fn(); checks++; console.log(name); }
|
|
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) {
|
|
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');
|
|
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_core_overlay_text'));
|
|
assert(afk.includes('level.hud_overlay_afk_y'));
|
|
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
|
|
});
|
|
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}: 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}: 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], 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/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
|