Add proximity-based map record reminders

This commit is contained in:
2026-10-17 21:54:13 -04:00
parent 6781a4ed5a
commit c19bef6dc6
11 changed files with 738 additions and 32 deletions
+118 -1
View File
@@ -24,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', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'show_record_reminder', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
@@ -133,6 +133,7 @@ function engine(game, count = 8, withHud = false) {
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
get live() { return elements.filter(e => !e.destroyCalls); },
@@ -322,6 +323,120 @@ for (const game of ['t5', 't6']) {
assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts);
});
check(`${game} automatic reminders use two elements and expire after six seconds`, () => {
const e = engine(game);
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true);
const request = e.state.record;
assert.equal(request.kind, 'reminder');
assert.equal(request.pages, 1);
assert.equal(request.remaining, 6000);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.rows.length, 1);
assert.equal(e.state.rows[0].text, '5 rounds to tie');
assert.equal(e.live.length, 2);
assert.equal(e.visible.length, 2);
assertCounter(e, 1, 1);
assertTextOnly(e);
const calls = e.textCalls, allocated = e.elements.length;
e.advance(5900);
assert.equal(e.state.record, request);
assert.equal(request.remaining, 100);
assert.equal(e.reminder({ round: 83 }, 'Newer target'), false);
assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint');
assert.equal(request.remaining, 100);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, allocated);
e.advance(100);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true);
assert.equal(e.state.record.remaining, 6000);
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 2);
});
check(`${game} automatic reminders never replace or queue behind help and full records`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(1000);
const record = e.state.record;
const generation = e.state.generation, allocations = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000);
assert.equal(e.state.generation, generation);
assert.equal(e.elements.length, allocations);
e.help();
e.advance(1000);
const help = e.state.help, withHelp = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.help, help);
assert.equal(e.state.record, record);
assert.equal(e.elements.length, withHelp);
e.advance(11000);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time');
e.advance(23000);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0, 'skipped hints do not appear later');
e.help();
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, undefined, 'help alone must not queue a hint');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
const reminderElements = [...e.live];
e.advance(2000);
e.help();
assert.equal(e.state.record, undefined);
assert(reminderElements.every(elem => elem.destroyCalls === 1));
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help');
e.reminder({ round: 82 }, '4 rounds to tie');
const replaced = [...e.live];
e.advance(5900);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
assert.equal(e.state.record.kind, 'record');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.record.pages, 2);
assert.equal(e.live.length, 5);
assert(replaced.every(elem => elem.destroyCalls === 1));
e.advance(100);
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
assert.equal(e.state.record.remaining, 11900);
e.advance(11900);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.rows[0].text, 'Four');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
const e = engine(game);
for (const bad of [undefined, {}, { round: 0 }, { round: -1 }])
assert.equal(e.reminder(bad, '5 rounds to tie'), false);
for (const message of [undefined, ''])
assert.equal(e.reminder({ round: 82 }, message), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect'])
check(`${game} ${event} cleans a reminder and blocks reopening`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
e.advance(1000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert.equal(e.state.closed, true);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false);
e.advance(12000);
assert.equal(e.elements.length, 2);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game);
e.record(undefined);
@@ -372,6 +487,8 @@ for (const game of ['t5', 't6']) {
check(`${game} ending before first service blocks lazy creation`, () => {
const e = engine(game);
e.end('end_game'); e.help(); e.record(snapshot());
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
+233 -7
View File
@@ -1,4 +1,4 @@
// Execute the actual data-only GSC functions with small engine-value shims.
// Execute actual leaderboard GSC functions with small data, player and clock shims.
// This is a fixture check, not a GSC VM, linker or gameplay test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -34,9 +34,13 @@ 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]));
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length').replace(/\bwait\s+[\d.]+;/g, ';');
const coroutine = match[1] === 'lb_round_watcher';
body = body.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 };');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
found.set(match[1], `function${coroutine ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
@@ -44,15 +48,17 @@ 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_chat_listener', 'lb_request']);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener',
'lb_request', 'lb_round_watcher', 'lb_round_start', 'lb_remind_player']);
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' };
const logs = [], queries = [], responses = [];
let now = 1000000;
const context = vm.createContext({
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
GetDvar: (key) => dvars[key] ?? '',
getTime: () => 1000000,
getTime: () => now,
spawnStruct: () => ({}),
isDefined: (value) => value !== undefined && value !== null,
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
@@ -69,7 +75,76 @@ function engine(game) {
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
});
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
return { context, all, source, dvars, responses, queries, logs };
return { context, all, source, dvars, responses, queries, logs,
get now() { return now; }, setTime(value) { now = value; } };
}
function roundRuntime(game) {
const e = engine(game), c = e.context;
const players = [], attempts = [], shown = [], records = [], notifications = [], endEvents = [];
const current = (player, run) => {
const prior = c.self;
c.self = player;
try { return run(); } finally { c.self = prior; }
};
c.getPlayers = () => players;
c.endon = (entity, event) => {
assert.equal(entity, c.level);
endEvents.push(event);
};
c.playerReminder = (player, snapshot, message) => current(player, () => c.lb_remind_player(snapshot, message));
c.reminderService = (player, snapshot, message) => {
const notice = { player, snapshot, message, round: c.level.round_number };
attempts.push(notice);
if (player.busy) return false;
shown.push(notice);
return true;
};
c.recordService = (player, snapshot) => records.push({ player, snapshot });
c.playerNotify = (player, event) => notifications.push({ player, event });
for (const name of ['lb_round_start', 'lb_remind_player', 'lb_request', 'lb_round_watcher']) {
const code = e.all.get(name)
.replace(/\b(?:self|level) endon\([^;]*\);/g, declaration =>
name === 'lb_round_watcher' ? declaration.replace('level endon(', 'endon(level, ') : '')
.replace(/players\[i\] lb_remind_player\(/g, 'playerReminder(players[i], ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record_reminder\(/g, 'reminderService(self, ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(self, ')
.replace(/self notify\(/g, 'playerNotify(self, ');
vm.runInContext(code, c);
}
let watcher, stopped = false;
const resumeWatcher = () => {
const step = watcher.next();
assert.equal(step.done, false);
assert.equal(step.value.entity, c.level);
assert.equal(step.value.event, 'start_of_round');
};
const startWatcher = () => {
if (watcher) return;
watcher = c.lb_round_watcher();
resumeWatcher();
};
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 };
players.push(player);
return player;
},
cache(round, saved_at = e.now) {
return (c.level.tws_lb_cache = { round, saved_at, lines: ['Holder'] });
},
round(round) { c.level.round_number = round; c.lb_round_start(round); },
request(player, manual) { return current(player, () => c.lb_request(manual)); },
startWatcher,
startRoundEvent(round) {
c.level.round_number = round;
if (stopped) return;
startWatcher();
resumeWatcher();
},
end(event) { if (endEvents.includes(event)) { stopped = true; watcher.return(); } },
};
}
function mapOption(game) {
@@ -147,6 +222,157 @@ for (const game of ['t5', 't6']) {
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
c.level.tws_lb_cache = undefined;
});
for (const [record, expected] of [
[50, [30, 35, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51]],
[53, [33, 38, 43, 45, 47, 48, 49, 50, 51, 52, 53, 54]],
]) check(`${game} record ${record} reminders accelerate relative to the target`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 1; round <= record + 10; round++) e.round(round);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
assert.equal(e.shown[0].message, '20 rounds away from matching it');
assert.equal(e.shown.at(-3).message, '1 round away from matching it');
assert.equal(e.shown.at(-2).message, 'Record round reached!');
assert.equal(e.shown.at(-1).message, 'Recorded high round passed!');
assert.equal(e.context.level.tws_lb_chase_finished, true);
assert.equal(e.queries.length, 0);
});
check(`${game} low records avoid round-one history and handle short approaches`, () => {
for (const record of [0, 1, 2, 6]) {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 0; round <= record + 4; round++) e.round(round);
const expected = record < 2 ? [] : Array.from({ length: record + 1 }, (_, i) => i + 1);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
}
});
check(`${game} absent, expired and empty caches skip reminders without fetching`, () => {
const e = roundRuntime(game);
e.readyPlayer();
for (const cached of [undefined, { round: 0, saved_at: e.now }, { round: 1, saved_at: e.now },
{ round: 50, saved_at: e.now - 900001 }]) {
e.context.level.tws_lb_cache = cached;
e.round(30); e.round(51);
assert.equal(e.context.level.tws_lb_chase_finished, undefined);
}
assert.equal(e.attempts.length, 0);
const stillUsable = e.cache(50, e.now - 900000);
e.round(30);
assert.equal(e.shown.length, 1);
assert.equal(e.shown[0].snapshot, stillUsable);
assert.equal(e.queries.length, 0);
});
check(`${game} the next round uses the latest cache without a mid-round announcement`, () => {
const e = roundRuntime(game);
e.readyPlayer();
const first = e.cache(50);
e.round(35);
const raised = e.cache(60);
assert.equal(e.shown.length, 1);
e.round(40);
const corrected = e.cache(55);
e.round(45);
assert.deepEqual(e.shown.map(notice => notice.snapshot), [first, raised, corrected]);
assert.deepEqual(e.shown.map(notice => notice.message), [
'15 rounds away from matching it', '20 rounds away from matching it', '10 rounds away from matching it',
]);
assert.equal(e.queries.length, 0);
});
check(`${game} overtaking finishes this match's chase even after cache changes or a busy client`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.round(50); e.round(51); e.round(52);
e.cache(70);
e.round(55); e.round(70); e.round(71);
assert.deepEqual(e.shown.map(notice => notice.round), [50, 51]);
const busy = roundRuntime(game);
const player = busy.readyPlayer({ busy: true });
busy.cache(50); busy.round(51);
assert.equal(busy.attempts.length, 1);
assert.equal(busy.shown.length, 0);
player.busy = false;
busy.round(52);
assert.equal(busy.attempts.length, 1, 'a skipped milestone cannot become a delayed celebration');
});
check(`${game} each client must be ready and thirty seconds past welcome or manual replay`, () => {
const e = roundRuntime(game);
const playing = e.readyPlayer();
const spectator = e.readyPlayer({ sessionstate: 'spectator' });
e.readyPlayer({ tws_lb_last_request: undefined });
e.readyPlayer({ tws_lb_last_request: e.now - 29999 });
for (const sessionstate of [undefined, 'connecting', 'dead'])
e.readyPlayer({ sessionstate });
e.cache(50); e.round(45);
assert.deepEqual(e.shown.map(notice => notice.player), [playing, spectator]);
assert.equal(e.context.lb_player_ready({ ...playing, tws_lb_last_request: e.now - 30000 }), true);
});
check(`${game} welcome attempts and manual requests reset the reminder cooldown`, () => {
const e = roundRuntime(game);
const player = e.readyPlayer({ tws_lb_last_request: undefined });
e.request(player, false);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 0, 'missing initial cache still releases the welcome readiness gate');
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1);
assert.equal(e.context.lb_player_ready(player), true);
e.cache(50); e.round(45);
e.request(player, true);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 1);
assert.equal(e.notifications[0].event, 'tws_lb_requested');
e.round(46);
assert.equal(e.shown.length, 1);
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1); e.round(47);
assert.deepEqual(e.shown.map(notice => notice.round), [45, 47]);
});
check(`${game} reminder readiness follows the engine's spectator/introscreen rules`, () => {
const e = roundRuntime(game), c = e.context;
const player = e.readyPlayer({ sessionstate: 'spectator', hasSpawned: false });
if (game === 't5') {
assert.equal(c.lb_player_ready(player), false);
player.hasSpawned = true;
assert.equal(c.lb_player_ready(player), true);
delete player.hasSpawned;
assert.equal(c.lb_player_ready(player), true);
} else {
assert.equal(c.lb_player_ready(player), true, 'T6 spectator newcomers keep hasSpawned false');
c.level.passed_introscreen = false;
assert.equal(c.lb_player_ready(player), false);
c.level.passed_introscreen = true;
assert.equal(c.lb_player_ready(player), true);
}
});
check(`${game} watcher waits for real starts and suppresses repeats, decreases and skipped starts`, () => {
const e = roundRuntime(game), observed = [];
e.context.lb_round_start = round => observed.push(round);
e.context.level.round_number = 30;
e.startWatcher();
assert.deepEqual(observed, [], 'loading mid-round must not synthesize an announcement');
e.context.level.round_number = 31;
assert.deepEqual(observed, [], 'a counter change before the actual start must not announce');
for (const round of [undefined, -1, 31, 31, 29, 30, 31, 34, 34]) e.startRoundEvent(round);
assert.deepEqual(observed, [31, 34], 'a skipped round emits only the newly observed start');
const fresh = roundRuntime(game), initial = [];
fresh.context.lb_round_start = round => initial.push(round);
fresh.startRoundEvent(0); fresh.startRoundEvent(1); fresh.startRoundEvent(1);
assert.deepEqual(initial, [1]);
assert(source.includes('level thread lb_round_watcher();'));
assert(!all.get('lb_round_watcher').includes('hud_round_changed'));
assert(!all.get('lb_round_watcher').includes('tws_t5_hud'));
assert(!all.get('lb_round_watcher').includes('tws_t6_hud'));
});
for (const event of ['end_game', 'game_ended', 'intermission'])
check(`${game} ${event} stops the independent round watcher`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.context.level.round_number = 44; e.startWatcher(); e.startRoundEvent(45);
assert.deepEqual(e.endEvents, ['end_game', 'game_ended', 'intermission']);
e.end(event); e.startRoundEvent(46);
assert.deepEqual(e.shown.map(notice => notice.round), [45]);
});
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é 🧟');
@@ -288,7 +514,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'].includes(name)) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
assert.equal(normalized, fn, name);
}