Show personal map records on join and replay

This commit is contained in:
2026-10-19 14:11:54 -04:00
parent 9ef5932d36
commit e93182cd02
11 changed files with 620 additions and 200 deletions
+65 -35
View File
@@ -132,7 +132,7 @@ function engine(game, count = 8, withHud = false) {
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),
help: () => c.show_help(), record: (snapshot, personal) => c.show_record(snapshot, personal),
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; },
@@ -295,18 +295,18 @@ for (const game of ['t5', 't6']) {
assert.equal(e.elements.length, count);
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
check(`${game} help pauses the complete record and resumes its remaining time`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(15000);
assert.equal(e.state.record.page, 1);
e.advance(3000);
assert.equal(e.state.record.page, 0);
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.page, 0);
assert.equal(e.state.record.remaining, remaining);
assert.equal(e.state.rows[0].text, 'Four');
assert.deepEqual(Array.from(e.state.rows, row => row.text), ['One', 'Two', 'Three', 'Four']);
assert.equal(e.live.length, 5);
const resumedTexts = e.textCalls;
e.c.service_render(e.state);
@@ -344,7 +344,7 @@ for (const game of ['t5', 't6']) {
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.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);
@@ -416,16 +416,14 @@ for (const game of ['t5', 't6']) {
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.state.record.pages, 1);
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.state.record, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
@@ -451,35 +449,67 @@ for (const game of ['t5', 't6']) {
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`, () => {
check(`${game} all holders precede a separate personal heading without record pages`, () => {
const e = engine(game);
e.record(snapshot(['Player 1', 'Player 2', 'Player 3', 'Player 4']), 'Your Best | Round 15');
assert.equal(e.state.record.pages, 1);
assert.equal(e.live.length, 6);
assertCounter(e, 1, 1);
assert.deepEqual(e.visible.map(x => x.text), [
'Map Record | Round 82', 'Player 1', 'Player 2', 'Player 3', 'Player 4', 'Your Best | Round 15',
]);
const personal = e.state.personal_title, header = e.state.record_title;
assert.equal(personal.y, 150, 'leave a blank row after all four holders');
assert.deepEqual(personal.color, header.color);
assert.equal(personal.fontScale, header.fontScale);
assert.notDeepEqual(personal.color, e.state.rows[0].color, 'personal best uses the heading style');
e.advance(11000);
assert.equal(e.state.personal_title, personal);
assert.equal(e.visible.length, 6, 'all holders stay visible until dismissal');
e.advance(1000);
assert.equal(e.live.length, 0);
e.record({ round: 0, lines: [] }, 'Your Best | None yet');
assert.equal(e.state.personal_title.text, 'Your Best | None yet');
assert.equal(e.state.personal_title.alpha, 1);
e.record(undefined, 'Your Best | Unavailable');
assert.equal(e.state.personal_title.text, 'Your Best | Unavailable');
assert.equal(e.state.personal_title.alpha, 1);
e.record(snapshot(['A', 'B', 'C']), 'Your Best | Round 10');
e.advance(2000); e.help(); e.advance(12000);
assert.equal(e.state.record.remaining, 10000);
assert.equal(e.state.personal_title.text, 'Your Best | Round 10');
e.record(snapshot(['A', 'B', 'C']));
assert.equal(e.state.personal_title, undefined, 'legacy callers clear the personal heading');
e.event('disconnect');
assert.equal(e.live.length, 0);
});
check(`${game} record allocation follows the full holder count and reuses unchanged layouts`, () => {
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);
assert.equal(e.state.record_title.text, 'Map Record unavailable');
assert.equal(e.live.length, 1);
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);
e.record(snapshot(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']));
assert(first.every(elem => elem.destroyCalls === 1), 'holder count changes release the old view first');
assert.equal(e.live.length, 9);
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));
const full = [...e.live];
e.advance(1000);
assert(e.state.rows.every(row => row.alpha === 1));
e.record(snapshot(['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']));
assert.deepEqual(e.live, full);
assert.deepEqual(Array.from(e.state.rows, row => row.text), ['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']);
e.record(snapshot(['Single holder']));
assert(full.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');
assert.equal(e.state.rows[0].text, 'Single holder');
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 9, '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`, () => {
@@ -512,11 +542,11 @@ for (const game of ['t5', 't6']) {
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.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 <amount|all>');
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
assert.equal(e.elements.length, 2, 'record allocation includes only the header and actual holders');
assertTextOnly(e);
});
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
@@ -606,7 +636,7 @@ for (const game of ['t5', 't6']) {
assert.equal(e.textCalls, calls);
e.record(snapshot()); e.advance(1000);
assert(rows.every(row => row.destroyCalls === 1));
assert.equal(e.live.length, 4);
assert.equal(e.live.length, 2);
e.help();
assert.equal(e.live.length, 19);
assert(e.state.command_rows.every(row => !rows.includes(row)));
@@ -677,7 +707,7 @@ check('T5 service transitions preserve actual core element identities, styles, d
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();
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(3000); 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();
@@ -688,7 +718,7 @@ check('T5 service transitions preserve actual core element identities, styles, d
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.equal(e.live.length, 14);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
+90 -8
View File
@@ -34,8 +34,8 @@ function functions(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
// Each GSC function has its own implicit locals, including recursion.
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
const coroutine = match[1] === 'lb_round_watcher';
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
const coroutine = ['lb_round_watcher', 'lb_get'].includes(match[1]);
body = body.replace(/player getGuid\(/g, 'player.getGuid(').replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
if (coroutine)
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };');
@@ -127,7 +127,7 @@ function roundRuntime(game) {
return { ...e, players, attempts, shown, records, notifications, endEvents,
get now() { return e.now; },
readyPlayer(fields = {}) {
const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
const player = { getGuid: () => undefined, sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
players.push(player);
return player;
},
@@ -188,7 +188,7 @@ for (const game of ['t5', 't6']) {
});
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(`self ${commonPath}::show_record(snapshot, personal);`));
assert(!source.includes('lb_show_hud'));
assert(!source.includes('lb_hud_'));
assert(!source.includes('tws_help_elems'));
@@ -198,7 +198,7 @@ for (const game of ['t5', 't6']) {
});
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
const calls = [];
c.self = { notify: (event) => calls.push(['notify', event]) };
c.self = { getGuid: () => undefined, 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, '')
@@ -406,6 +406,88 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_page_valid(value, 0, 1), false);
}
});
check(`${game} HTTP callers serialize and native timeouts retain the slot until collection`, () => {
const e = engine(game), c = e.context;
const started = [], pending = [];
c.httpRequest = (url) => { started.push(url); return {}; };
c.collect = (request, job) => pending.push(job);
c.jsonParse = JSON.parse;
vm.runInContext(e.all.get('lb_get')
.replace(/http::request|httpPost/g, 'httpRequest')
.replace(/level thread lb_collect\(/g, 'collect('), c);
const first = c.lb_get('&first');
assert.equal(first.next().done, false);
const second = c.lb_get('&second');
assert.equal(second.next().done, false);
assert.equal(started.length, 1);
e.setTime(e.now + 15000);
assert.equal(first.next().done, true);
assert.equal(second.next().done, true);
assert.equal(started.length, 1, 'timed-out queue wait must not start another native request');
assert.equal(c.level.tws_lb_pending, pending[0]);
const third = c.lb_get('&third');
assert.equal(third.next().done, false);
Object.assign(pending[0], { done: true, success: true, body: '{}' });
c.level.tws_lb_pending = undefined;
assert.equal(third.next().done, false);
assert.equal(started.length, 2);
Object.assign(pending[1], { done: true, success: true, body: '{"ok":true}' });
c.level.tws_lb_pending = undefined;
const result = third.next();
assert.equal(result.done, true);
assert.equal(result.value.ok, true);
});
check(`${game} personal best finds exact GUID below the top rank across search pages`, () => {
const guid = '00012345678901234567890';
responses.push(page(option, [entry(option, 'prefix' + guid), entry(option, 'other', guid), entry(option, 'third')], 4),
page(option, [entry(option, guid, 'Me', 27)], 4, 3));
assert.equal(c.lb_personal_fetch(option.key, guid), 27);
assert(queries.at(-1).includes(`q=${guid}&limit=3&offset=3`));
assert(queries.at(-1).includes(`map=${encodeURIComponent(option.key)}`));
});
check(`${game} personal absence requires a complete search, failures stay unavailable`, () => {
responses.push(page(option, [entry(option, 'not-me', 'me')], 1));
assert.equal(c.lb_personal_fetch(option.key, 'me'), 0);
responses.push(page(option, [], 0));
assert.equal(c.lb_personal_fetch(option.key, 'me'), 0);
for (const response of [undefined, { ok: false }, page(option, [], 1),
page(option, [entry(option, 'me')], 0),
page(option, [{ ...entry(option, 'me'), guid: 123 }], 1),
page(option, [entry({ ...option, key: 'wrong' }, 'me')], 1),
page(option, [entry({ ...option, game: 'wrong' }, 'me')], 1),
page(option, [{ ...entry(option, 'me'), highestRound: 27 }], 1)]) {
responses.push(response);
assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined);
}
responses.push(page(option, [entry(option, 'a'), entry(option, 'b'), entry(option, 'c')], 4),
page(option, [entry(option, 'me')], 5, 3));
assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined);
});
check(`${game} personal identity, per-player caching, expiry and unavailable retries`, () => {
const player = { guid: '00012345678901234567890', getGuid() { return this.guid; } };
const other = { guid: 'other', getGuid() { return this.guid; } };
const snapshot = { key: option.key, round: 52 };
assert.equal(c.lb_player_guid(player), player.guid);
player.guid = 123;
assert.equal(c.lb_player_guid(player), '123');
player.guid = '00012345678901234567890';
responses.push(page(option, [entry(option, player.guid, 'Me', 27)], 1));
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27');
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27');
responses.push(page(option, [], 0));
assert.equal(c.lb_personal_record(other, snapshot), 'Your Best | None yet');
player.tws_lb_personal.saved_at -= 300000;
responses.push(undefined);
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
player.tws_lb_personal.saved_at -= 30000;
responses.push(page(option, [entry(option, player.guid, 'Me', 30)], 1));
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 30');
assert.equal(c.lb_personal_record(player, undefined), 'Your Best | Unavailable');
assert.equal(c.lb_personal_record(player, { round: 0 }), 'Your Best | None yet');
player.guid = undefined;
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
});
check(`${game} one holder`, () => {
responses.push(discovery(option), page(option, [entry(option, '000123', 'Alice')], 1));
const result = c.lb_refresh();
@@ -455,13 +537,13 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_refresh(), undefined);
c.level.tws_lb_override = '';
});
check(`${game} all holders fit bounded service rows`, () => {
check(`${game} each holder gets a separate bounded service row`, () => {
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
const lines = Array.from(c.lb_holder_lines(names));
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']);
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),
@@ -514,7 +596,7 @@ check('paired data helpers stay identical', () => {
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
for (const [name, fn] of t5) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready', 'lb_player_guid'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
assert.equal(normalized, fn, name);
}