Add .leaders command to replay map records in T5 and T6

This commit is contained in:
2026-10-07 22:49:55 -04:00
parent dec51a7032
commit 58399d2191
6 changed files with 156 additions and 11 deletions
+8
View File
@@ -29,6 +29,10 @@ Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
**along with the updated common module** for that game. **along with the updated common module** for that game.
The new module privately announces the current map's fleet-wide highest recorded The new module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers. round and every tied holder once per connection, including spectator newcomers.
Type `.leaders` to replay the record privately from the latest usable cache.
Repeating the command restarts the announcement from its first page. It appears
in `.help` when the leaderboard module is enabled. If no usable record is
available, the command asks you to try again shortly.
It reads Electric Cherry's public `/api/leaderboard` endpoint without a token; It reads Electric Cherry's public `/api/leaderboard` endpoint without a token;
only completed matches contribute. Ties may come from the same run or separate only completed matches contribute. Ties may come from the same run or separate
runs. Names are the API's latest player names, with players identified by GUID. runs. Names are the API's latest player names, with players identified by GUID.
@@ -83,6 +87,10 @@ mid-announcement and confirm messages stop. Verify existing HUD, `.help`, `.join
and telemetry still work. Open `.help` during a record page and check that the and telemetry still work. Open `.help` during a record page and check that the
record resumes afterwards without overlapping it. Runtime linking and client record resumes afterwards without overlapping it. Runtime linking and client
readiness require Plutonium. readiness require Plutonium.
Try `.leaders` and `.LEADERS` after the announcement ends and during a later
page: each should start one private announcement from the first page. Repeat
while help is open, then disconnect or end the game during a replay to check
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
## Telemetry authentication (T5 and T6) ## Telemetry authentication (T5 and T6)
+4
View File
@@ -60,9 +60,13 @@ counter and health display. Typing either command again restarts that display ti
| `.afk` | Pause with protection | | `.afk` | Pause with protection |
| `.back` | Return from AFK | | `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) | | `.join` | Join mid-round (newcomers only) |
| `.leaders` | Show the map record again (leaderboard module required) |
Each module registers its own help entries; removing AFK or join also removes Each module registers its own help entries; removing AFK or join also removes
those entries from `.help` after a map restart. those entries from `.help` after a map restart.
The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page;
help temporarily hides the record until its own display ends.
## Dependency graph ## Dependency graph
+62 -5
View File
@@ -1,4 +1,4 @@
/* tws_t5_leaderboard.gsc - private map record announcement, once per connection. */ /* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */
#include maps\_utility; #include maps\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -24,7 +24,9 @@ init()
level.tws_lb_pending = undefined; level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined; level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0; level.tws_lb_attempt = 0;
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome); scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker(); level thread lb_worker();
} }
@@ -65,6 +67,7 @@ lb_worker()
lb_welcome() lb_welcome()
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_requested");
level endon("end_game"); level endon("end_game");
level endon("game_ended"); level endon("game_ended");
level endon("intermission"); level endon("intermission");
@@ -84,18 +87,62 @@ lb_welcome()
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
wait 0.25; wait 0.25;
self lb_request(false);
}
lb_chat_listener()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".leaders")
player thread lb_request(true);
}
}
lb_cached_record()
{
if (!isDefined(level.tws_lb_cache)) if (!isDefined(level.tws_lb_cache))
return; return undefined;
snapshot = level.tws_lb_cache; snapshot = level.tws_lb_cache;
// Do not advertise an indefinitely stale record during an outage. // Do not advertise an indefinitely stale record during an outage.
if (getTime() - snapshot.saved_at > 900000) if (getTime() - snapshot.saved_at > 900000)
return; return undefined;
return snapshot;
}
lb_request(manual)
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
snapshot = lb_cached_record();
if (!isDefined(snapshot))
{
if (manual)
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
return;
}
// A manual replay replaces any welcome announcement still waiting to run.
if (manual)
self notify("tws_lb_requested");
self lb_show_hud(snapshot); self lb_show_hud(snapshot);
} }
lb_show_hud(snapshot) lb_show_hud(snapshot)
{ {
// Stop the previous display and its cleanup threads before replacing it.
self notify("tws_lb_hud_done");
if (isDefined(self.tws_lb_elems))
lb_hud_destroy(self.tws_lb_elems);
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_hud_done"); self endon("tws_lb_hud_done");
@@ -121,8 +168,9 @@ lb_show_hud(snapshot)
elems[elems.size] = row; elems[elems.size] = row;
} }
// Cleanup runs independently of the welcome thread's endon handlers. // Cleanup runs independently of the display and ends when it is replaced.
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); self.tws_lb_elems = elems;
self thread lb_hud_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game"); self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended"); self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission"); self thread lb_hud_end_game(elems, "intermission");
@@ -156,6 +204,7 @@ lb_show_hud(snapshot)
break; break;
} }
lb_hud_destroy(elems); lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done"); self notify("tws_lb_hud_done");
} }
@@ -165,12 +214,20 @@ lb_hud_help_visible()
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
} }
lb_hud_disconnect(elems)
{
self endon("tws_lb_hud_done");
self waittill("disconnect");
lb_hud_destroy(elems);
}
lb_hud_end_game(elems, event) lb_hud_end_game(elems, event)
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_hud_done"); self endon("tws_lb_hud_done");
level waittill(event); level waittill(event);
lb_hud_destroy(elems); lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done"); self notify("tws_lb_hud_done");
} }
+4
View File
@@ -72,11 +72,15 @@ counter and health display. Typing either command again restarts that display ti
| `.afk` | Pause with protection | | `.afk` | Pause with protection |
| `.back` | Return from AFK | | `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) | | `.join` | Join mid-round (newcomers only) |
| `.leaders` | Show the map record again (leaderboard module required) |
AFK and Join register their commands during `init()` through AFK and Join register their commands during `init()` through
`tws_t6_common::register_help_command(command, description)`. If a module is `tws_t6_common::register_help_command(command, description)`. If a module is
omitted, its commands are omitted from `.help`. Register new commands alongside omitted, its commands are omitted from `.help`. Register new commands alongside
their module's initialization so the list stays current. their module's initialization so the list stays current.
The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page;
help temporarily hides the record until its own display ends.
## Defaults, matching T5 ## Defaults, matching T5
+62 -5
View File
@@ -1,4 +1,4 @@
/* tws_t6_leaderboard.gsc - private map record announcement, once per connection. */ /* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */
#include maps\mp\_utility; #include maps\mp\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -26,7 +26,9 @@ init()
level.tws_lb_pending = undefined; level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined; level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0; level.tws_lb_attempt = 0;
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome); scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker(); level thread lb_worker();
} }
@@ -67,6 +69,7 @@ lb_worker()
lb_welcome() lb_welcome()
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_requested");
level endon("end_game"); level endon("end_game");
level endon("game_ended"); level endon("game_ended");
level endon("intermission"); level endon("intermission");
@@ -87,18 +90,62 @@ lb_welcome()
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
wait 0.25; wait 0.25;
self lb_request(false);
}
lb_chat_listener()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".leaders")
player thread lb_request(true);
}
}
lb_cached_record()
{
if (!isDefined(level.tws_lb_cache)) if (!isDefined(level.tws_lb_cache))
return; return undefined;
snapshot = level.tws_lb_cache; snapshot = level.tws_lb_cache;
// Do not advertise an indefinitely stale record during an outage. // Do not advertise an indefinitely stale record during an outage.
if (getTime() - snapshot.saved_at > 900000) if (getTime() - snapshot.saved_at > 900000)
return; return undefined;
return snapshot;
}
lb_request(manual)
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
snapshot = lb_cached_record();
if (!isDefined(snapshot))
{
if (manual)
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
return;
}
// A manual replay replaces any welcome announcement still waiting to run.
if (manual)
self notify("tws_lb_requested");
self lb_show_hud(snapshot); self lb_show_hud(snapshot);
} }
lb_show_hud(snapshot) lb_show_hud(snapshot)
{ {
// Stop the previous display and its cleanup threads before replacing it.
self notify("tws_lb_hud_done");
if (isDefined(self.tws_lb_elems))
lb_hud_destroy(self.tws_lb_elems);
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_hud_done"); self endon("tws_lb_hud_done");
@@ -124,8 +171,9 @@ lb_show_hud(snapshot)
elems[elems.size] = row; elems[elems.size] = row;
} }
// Cleanup runs independently of the welcome thread's endon handlers. // Cleanup runs independently of the display and ends when it is replaced.
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); self.tws_lb_elems = elems;
self thread lb_hud_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game"); self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended"); self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission"); self thread lb_hud_end_game(elems, "intermission");
@@ -159,6 +207,7 @@ lb_show_hud(snapshot)
break; break;
} }
lb_hud_destroy(elems); lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done"); self notify("tws_lb_hud_done");
} }
@@ -168,12 +217,20 @@ lb_hud_help_visible()
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
} }
lb_hud_disconnect(elems)
{
self endon("tws_lb_hud_done");
self waittill("disconnect");
lb_hud_destroy(elems);
}
lb_hud_end_game(elems, event) lb_hud_end_game(elems, event)
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_lb_hud_done"); self endon("tws_lb_hud_done");
level waittill(event); level waittill(event);
lb_hud_destroy(elems); lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done"); self notify("tws_lb_hud_done");
} }
+16 -1
View File
@@ -44,7 +44,7 @@ function functions(source) {
function engine(game) { function engine(game) {
const source = readFileSync(new URL(`${game}/tws_${game}_leaderboard.gsc`, root), 'utf8'); const source = readFileSync(new URL(`${game}/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source); 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']); 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 table = []; const table = [];
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i; 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 dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
@@ -52,6 +52,7 @@ function engine(game) {
const context = vm.createContext({ const context = vm.createContext({
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table }, level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
GetDvar: (key) => dvars[key] ?? '', GetDvar: (key) => dvars[key] ?? '',
getTime: () => 1000000,
spawnStruct: () => ({}), spawnStruct: () => ({}),
isDefined: (value) => value !== undefined && value !== null, isDefined: (value) => value !== undefined && value !== null,
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array' gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
@@ -96,6 +97,20 @@ for (const game of ['t5', 't6']) {
if (game === 't6') assert(!all.get('lb_welcome').includes('self.hasSpawned')); if (game === 't6') assert(!all.get('lb_welcome').includes('self.hasSpawned'));
assert(source.includes('register_connect_handler(::lb_welcome)')); assert(source.includes('register_connect_handler(::lb_welcome)'));
}); });
check(`${game} record replay uses only a fresh complete cache`, () => {
assert.equal(c.lb_cached_record(), undefined);
const snapshot = { saved_at: 1000000, round: 52, lines: ['Alice'] };
c.level.tws_lb_cache = snapshot;
assert.equal(c.lb_cached_record(), snapshot);
snapshot.saved_at = 100000;
assert.equal(c.lb_cached_record(), snapshot);
snapshot.saved_at--;
assert.equal(c.lb_cached_record(), undefined);
c.level.tws_lb_cache = { saved_at: 1000000, round: 0, lines: [] };
assert.equal(c.lb_cached_record().round, 0);
c.level.tws_lb_cache = undefined;
assert.equal(queries.length, 0);
});
check(`${game} unicode and URL encoding`, () => { 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(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é 🧟'); assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');