Add fixed core HUD and shared service window for T5 and T6

This commit is contained in:
2026-10-14 15:55:22 -04:00
parent d15bc50b7d
commit 929bf0354b
18 changed files with 1973 additions and 804 deletions
+95
View File
@@ -0,0 +1,95 @@
// Execute selected core HUD functions with engine-value shims.
// These checks are not a GSC VM, compiler, linker, or live layout test.
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) { const ch = source[end++]; if (ch === '{') depth++; if (ch === '}') depth--; }
return [match[1], source.slice(at, end - 1)];
}
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`, () => {
assert(source.includes('register_connect_handler(::hud_connect)'));
assert(source.includes('register_spawn_handler(::hud_start)'));
assert(!source.includes('waittill("connected"'));
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
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'));
});
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}: 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}: 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);
const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes);
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_bar.color, 'red');
self.health = 0; run.next(); assert.equal(self.tws_hud_bar.alpha, 0); assert.equal(self.tws_hud_health.value, 0);
self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green');
assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1);
});
}
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
+302
View File
@@ -0,0 +1,302 @@
// Execute the actual common GSC service functions with HUD/event shims.
// These fixtures do not emulate the GSC VM, fonts, safe areas or HUD pool limits.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import vm from 'node:vm';
import './check_core_hud.mjs';
const root = new URL('../', import.meta.url);
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via']);
function translate(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm;
const result = [];
for (let match; (match = headers.exec(protectedSource));) {
const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
assert(end < protectedSource.length, `unclosed ${name}`);
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body
.replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]')
.replace(/\.size\b/g, '.length')
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
.replace(/self thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);')
.replace(/(self|level) waittill\(([^;]+)\);/g, 'yield { entity: $1, event: $2 };')
.replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);')
.replace(/self (\w+)\(/g, '$1(')
.replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };')
.replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetValue|SetShader|Destroy)\(/g, '$1.$2(')
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return result.join('\n');
}
const commands = [
['.help', 'List available commands'], ['.afk', 'Pause with protection'],
['.back', 'Return from AFK'], ['.join', 'Join mid-round (newcomers only)'],
['.leaders', 'Show the map record again'], ['.b', 'Show bank balance'],
['.d <amount|all>', 'Deposit points'], ['.w <amount|all>', 'Withdraw points'],
['.x', 'Extra command'],
];
function engine(game, count = 8) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
let now = 0, running;
const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = [];
function resume(task) {
const prior = running;
running = task;
const result = task.generator.next();
task.done = result.done;
task.wait = result.value;
task.at = result.value?.milliseconds !== undefined ? now + result.value.milliseconds : Infinity;
running = prior;
}
function startThread(fn, args) {
const task = { generator: fn(...args), ends: [] };
tasks.push(task);
resume(task);
}
function emit(entity, event) {
const listeners = tasks.filter(t => !t.done);
for (const task of listeners) if (task.ends.some(([e, n]) => e === entity && n === event)) task.done = true;
for (const task of listeners) if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
}
function advance(milliseconds) {
const until = now + milliseconds;
for (;;) {
const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
resume(next);
}
now = until;
}
function newElement(owner) {
assert.equal(owner, self, 'every HUD element must be client-owned');
const element = {
alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0,
SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; },
SetShader(shader, width, height) { assert(!this.destroyCalls); Object.assign(this, { shader, width, height }); },
Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; },
};
elements.push(element);
return element;
}
const c = vm.createContext({
self, level, NewClientHudElem: newElement, getTime: () => now,
spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null,
max: Math.max, int: Math.trunc,
getSubStr: (text, begin, end) => { assert(end <= text.length); return text.slice(begin, end); },
endon: (entity, event) => running.ends.push([entity, event]), startThread, emit,
});
vm.runInContext(translate(source), c);
c.common_config();
for (const [command, description] of commands.slice(0, count).reverse()) c.register_help_command(command, description);
for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]);
return { c, self, level, elements, tasks, advance, source,
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); },
get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); },
};
}
const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines });
for (const game of ['t5', 't6']) {
for (const count of [1, 4, 5, 8, 9]) check(`${game} ${count} registry entries paginate, order and dismiss`, () => {
const e = engine(game, count);
assert.equal(e.elements.length, 0, 'service is lazy and independent of core');
e.help();
const s = e.state, background = [s.background.x, s.background.y, s.background.width, s.background.height];
const pages = Math.ceil(count / 4);
assert.equal(s.help.pages, pages);
assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0]));
for (let page = 0; page < pages; page++) {
assert.equal(s.help.page, page);
assert.equal(s.page_number.value, page + 1);
assert.equal(s.page_total.value, pages);
assert.equal(s.command_column.text.split('\n').length, Math.min(4, count - page * 4));
assert.equal(s.description_column.text.split('\n').length, Math.min(4, count - page * 4));
assert.deepEqual([s.background.x, s.background.y, s.background.width, s.background.height], background);
e.advance(12000);
}
assert.equal(s.help, undefined);
assert.equal(e.visible.length, 0);
});
check(`${game} repeated help reuses strings/elements and resets page one`, () => {
const e = engine(game);
e.help();
const count = e.elements.length, texts = e.textCalls;
e.help();
assert.equal(e.textCalls, texts, 'repeating current page does not reassign text');
e.advance(12000);
assert.equal(e.state.help.page, 1);
e.help();
assert.equal(e.state.help.page, 0);
e.advance(11900);
e.help();
e.advance(100);
assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement');
e.advance(23900);
assert.equal(e.state.help, undefined);
assert.equal(e.elements.length, count);
assert.equal(e.textCalls, texts + 6, 'only three page transitions update the two text columns');
});
check(`${game} help pauses record time and resumes the same page`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(15000);
assert.equal(e.state.record.page, 1);
const remaining = e.state.record.remaining;
e.help();
const texts = e.textCalls;
e.advance(24000);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.record.remaining, remaining);
assert.equal(e.state.rows[0].text, 'Four');
assert.equal(e.textCalls, texts + 2, 'only the help page transition changes text; record resumption does not');
e.advance(remaining);
assert.equal(e.state.record, undefined);
assert.equal(e.visible.length, 0);
});
check(`${game} latest record replaces pending one without disturbing help`, () => {
const e = engine(game);
e.help();
e.advance(3000);
const help = e.state.help;
e.record(snapshot(['Old']));
e.record(snapshot(['Newest']));
assert.equal(e.state.help, help);
assert.equal(e.state.record.page, 0);
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.rows.filter(row => row.alpha > 0).length, 0);
e.advance(21000);
assert.equal(e.state.rows[0].text, 'Newest');
assert.equal(e.state.record.remaining, 12000);
e.advance(11900);
e.record(snapshot(['Newest']));
const texts = e.textCalls;
e.advance(100);
assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts);
});
check(`${game} no record, unavailable and short final record page are distinct`, () => {
const e = engine(game);
e.record(undefined);
assert.equal(e.state.unavailable.alpha, 1);
assert.equal(e.state.empty.alpha, 0);
e.record({ round: 0, lines: [] });
assert.equal(e.state.unavailable.alpha, 0);
assert.equal(e.state.empty.alpha, 1);
e.record(snapshot(['A', 'B', 'C', 'D']));
assert.equal(e.state.empty.alpha, 0);
assert.equal(e.state.round_number.value, 82);
e.advance(12000);
assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]);
assert.equal(e.state.rows[0].text, 'D');
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => {
const e = engine(game);
e.record(snapshot());
e.help();
e.advance(2000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert(e.state.closed);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record, undefined);
assert(e.elements.every(elem => elem.destroyCalls === 1));
const count = e.elements.length;
e.advance(60000);
if (event !== 'disconnect') {
e.help(); e.record(snapshot()); e.event('disconnect');
assert.equal(e.elements.length, count, 'game end blocks new service requests');
}
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} ending before first service blocks lazy creation`, () => {
const e = engine(game);
e.end('end_game'); e.help(); e.record(snapshot());
assert.equal(e.elements.length, 0);
});
check(`${game} no per-tick text updates, stable core origin and private elements`, () => {
const e = engine(game);
e.record(snapshot());
const calls = e.textCalls, count = e.elements.length;
e.advance(11000);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, count);
assert.equal(e.state.background.horzAlign, 'right');
assert.equal(e.state.background.vertAlign, game === 't5' ? 'top' : 'fullscreen');
assert.equal(e.state.background.y, e.level.hud_panel_top + e.level.hud_service_y);
assert(e.state.background.y > e.level.hud_panel_top + e.level.hud_core_height);
assert.equal(e.state.commands[3].command, '.join');
assert.match(e.c.help_description(e.state.commands[3]), /newcomer/);
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <n|all>');
assert.equal(e.elements.length, 19, 'service allocation is bounded even when hidden');
assert(e.elements.every(elem => elem.archived === false));
});
check(`${game} nominal font boxes fit the fixed panels without row collisions`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three']));
// Stock T6 _hud uses level.fontheight=12. This catches the original
// 11-unit holder pitch / overflowing AFK footer, not actual glyph fit.
const height = elem => Math.trunc(12 * elem.fontScale);
const s = e.state;
assert(s.holders_label.y + height(s.holders_label) <= s.rows[0].y);
for (let i = 1; i < s.rows.length; i++)
assert(s.rows[i - 1].y + height(s.rows[i - 1]) <= s.rows[i].y);
assert(s.rows.at(-1).y + height(s.rows.at(-1)) <= s.record_footer.y);
assert(s.record_footer.y + height(s.record_footer) < s.background.y + s.background.height);
assert(e.level.hud_row_bar + e.level.hud_bar_height < e.level.hud_row_afk);
assert(e.level.hud_row_afk + Math.trunc(12 * e.level.hud_font_afk) < e.level.hud_core_height);
e.help();
const helpHeight = 4 * height(s.command_column);
assert(s.command_column.y + helpHeight <= s.command_back.y + s.command_back.height);
assert(s.command_column.y + helpHeight <= s.help_footer.y);
});
check(`${game} script entrypoints and common references resolve`, () => {
const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1]));
const folder = new URL(`${game}/scripts/`, root);
const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc'));
assert.equal(scripts.length, 8);
let connected = 0;
for (const file of scripts) {
const source = readFileSync(new URL(file, folder), 'utf8');
assert.match(source, /^init\(\)/m);
if (game === 't6') {
assert.match(source, /^main\(\)/m);
assert(!/scripts\\sp\\|maps\\_utility|#"zombiemode"/.test(source));
}
connected += (source.match(/waittill\("connected"/g) ?? []).length;
for (const ref of source.matchAll(/tws_t[56]_common::(\w+)/g)) assert(functions.has(ref[1]), `${file} missing ${ref[1]}`);
}
assert.equal(connected, 1);
});
}
function eSource(game) { return readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); }
check('paired service behavior remains identical', () => {
const section = game => eSource(game).slice(eSource(game).indexOf('show_help()\n{'), eSource(game).indexOf('// The only connected listener;'));
assert.equal(section('t5'), section('t6'));
});
console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);
+61 -3
View File
@@ -44,7 +44,7 @@ function functions(source) {
function engine(game) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy', 'lb_chat_listener', 'lb_request', 'lb_hud_disconnect']);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']);
const table = [];
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
@@ -111,6 +111,42 @@ for (const game of ['t5', 't6']) {
c.level.tws_lb_cache = undefined;
assert.equal(queries.length, 0);
});
check(`${game} common owns the record service window`, () => {
const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common';
assert(source.includes(`self ${commonPath}::show_record(snapshot);`));
assert(!source.includes('lb_show_hud'));
assert(!source.includes('lb_hud_'));
assert(!source.includes('tws_help_elems'));
assert(!source.includes('iPrintLnBold'));
assert(!source.includes('make_text_elem'));
assert(!source.includes('tws_t5_hud') && !source.includes('tws_t6_hud'));
});
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
const calls = [];
c.self = { notify: (event) => calls.push(['notify', event]) };
c.recordService = (snapshot) => calls.push(['record', snapshot]);
const request = all.get('lb_request')
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
.replace(/\bself notify\(/g, 'self.notify(')
.replace(/\bself scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(');
vm.runInContext(request, c);
for (const cached of [undefined, { saved_at: 1, round: 52, lines: ['Alice'] }]) {
c.level.tws_lb_cache = cached;
c.lb_request(false);
assert.deepEqual(calls, []);
c.lb_request(true);
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', undefined]]);
calls.length = 0;
}
const snapshot = { saved_at: 1000000, round: 0, lines: [] };
c.level.tws_lb_cache = snapshot;
c.lb_request(false);
assert.deepEqual(calls, [['record', snapshot]]);
calls.length = 0;
c.lb_request(true);
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
c.level.tws_lb_cache = undefined;
});
check(`${game} unicode and URL encoding`, () => {
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
@@ -193,11 +229,33 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_refresh(), undefined);
c.level.tws_lb_override = '';
});
check(`${game} all holders fit bounded messages`, () => {
check(`${game} all holders fit bounded service rows`, () => {
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
const lines = Array.from(c.lb_holder_lines(names));
assert(lines.every((line) => line.length <= 100));
assert(lines.every((line) => c.lb_holder_width(line) <= 16));
assert.deepEqual(lines.join(', ').split(', '), names);
assert.deepEqual(Array.from(c.lb_holder_lines([])), []);
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']);
});
check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => {
const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5),
'abcdefghijklééé', '^1Alice\n^7 René 🧟', '^1\n\t'];
for (const sample of samples) {
const value = c.lb_holder_name(bytes(sample));
assert(c.lb_holder_width(value) <= 16, sample);
assert(c.lb_utf8_valid(value), sample);
assert(!text(value).includes('\uFFFD'), sample);
assert(!/[\x00-\x1f\x7f^]/.test(value), sample);
assert(value.length > 0, sample);
}
assert.equal(c.lb_holder_name('M'.repeat(64)), 'M'.repeat(13) + '...');
assert.equal(text(c.lb_holder_name(bytes('🧟'.repeat(16)))), '🧟'.repeat(6) + '...');
assert.equal(c.lb_holder_name('^1\n\t'), 'Unknown player');
assert.equal(c.lb_holder_width(bytes('René 🧟')), 8);
const names = Array(100).fill(bytes('同じ名前'.repeat(5)));
const rows = Array.from(c.lb_holder_lines(names));
assert.equal(rows.length, names.length, 'identical long names still represent every holder');
assert(rows.every((line) => c.lb_holder_width(line) <= 16 && c.lb_utf8_valid(line)));
});
if (game === 't6') {
const variants = [