diff --git a/README.md b/README.md index 7f53028..1dbc38e 100644 --- a/README.md +++ b/README.md @@ -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. The new module privately announces the current map's fleet-wide highest recorded 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; 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. @@ -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 record resumes afterwards without overlapping it. Runtime linking and client 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) diff --git a/t5/README.md b/t5/README.md index a555060..f5f25e2 100644 --- a/t5/README.md +++ b/t5/README.md @@ -60,9 +60,13 @@ counter and health display. Typing either command again restarts that display ti | `.afk` | Pause with protection | | `.back` | Return from AFK | | `.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 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 diff --git a/t5/tws_t5_leaderboard.gsc b/t5/tws_t5_leaderboard.gsc index efd370f..454a5a9 100644 --- a/t5/tws_t5_leaderboard.gsc +++ b/t5/tws_t5_leaderboard.gsc @@ -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 common_scripts\utility; @@ -24,7 +24,9 @@ init() level.tws_lb_pending = undefined; level.tws_lb_cache = undefined; 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); + level thread lb_chat_listener(); level thread lb_worker(); } @@ -65,6 +67,7 @@ lb_worker() lb_welcome() { self endon("disconnect"); + self endon("tws_lb_requested"); level endon("end_game"); level endon("game_ended"); level endon("intermission"); @@ -84,18 +87,62 @@ lb_welcome() while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) 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)) - return; + return undefined; snapshot = level.tws_lb_cache; // Do not advertise an indefinitely stale record during an outage. 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); } 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("tws_lb_hud_done"); @@ -121,8 +168,9 @@ lb_show_hud(snapshot) elems[elems.size] = row; } - // Cleanup runs independently of the welcome thread's endon handlers. - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); + // Cleanup runs independently of the display and ends when it is replaced. + 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, "game_ended"); self thread lb_hud_end_game(elems, "intermission"); @@ -156,6 +204,7 @@ lb_show_hud(snapshot) break; } lb_hud_destroy(elems); + self.tws_lb_elems = undefined; 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; } +lb_hud_disconnect(elems) +{ + self endon("tws_lb_hud_done"); + self waittill("disconnect"); + lb_hud_destroy(elems); +} + lb_hud_end_game(elems, event) { self endon("disconnect"); self endon("tws_lb_hud_done"); level waittill(event); lb_hud_destroy(elems); + self.tws_lb_elems = undefined; self notify("tws_lb_hud_done"); } diff --git a/t6/README.md b/t6/README.md index 4e4ece2..ada3c53 100644 --- a/t6/README.md +++ b/t6/README.md @@ -72,11 +72,15 @@ counter and health display. Typing either command again restarts that display ti | `.afk` | Pause with protection | | `.back` | Return from AFK | | `.join` | Join mid-round (newcomers only) | +| `.leaders` | Show the map record again (leaderboard module required) | AFK and Join register their commands during `init()` through `tws_t6_common::register_help_command(command, description)`. If a module is omitted, its commands are omitted from `.help`. Register new commands alongside 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 diff --git a/t6/tws_t6_leaderboard.gsc b/t6/tws_t6_leaderboard.gsc index 488bf2a..0f8a0c1 100644 --- a/t6/tws_t6_leaderboard.gsc +++ b/t6/tws_t6_leaderboard.gsc @@ -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 common_scripts\utility; @@ -26,7 +26,9 @@ init() level.tws_lb_pending = undefined; level.tws_lb_cache = undefined; 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); + level thread lb_chat_listener(); level thread lb_worker(); } @@ -67,6 +69,7 @@ lb_worker() lb_welcome() { self endon("disconnect"); + self endon("tws_lb_requested"); level endon("end_game"); level endon("game_ended"); level endon("intermission"); @@ -87,18 +90,62 @@ lb_welcome() while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) 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)) - return; + return undefined; snapshot = level.tws_lb_cache; // Do not advertise an indefinitely stale record during an outage. 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); } 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("tws_lb_hud_done"); @@ -124,8 +171,9 @@ lb_show_hud(snapshot) elems[elems.size] = row; } - // Cleanup runs independently of the welcome thread's endon handlers. - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); + // Cleanup runs independently of the display and ends when it is replaced. + 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, "game_ended"); self thread lb_hud_end_game(elems, "intermission"); @@ -159,6 +207,7 @@ lb_show_hud(snapshot) break; } lb_hud_destroy(elems); + self.tws_lb_elems = undefined; 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; } +lb_hud_disconnect(elems) +{ + self endon("tws_lb_hud_done"); + self waittill("disconnect"); + lb_hud_destroy(elems); +} + lb_hud_end_game(elems, event) { self endon("disconnect"); self endon("tws_lb_hud_done"); level waittill(event); lb_hud_destroy(elems); + self.tws_lb_elems = undefined; self notify("tws_lb_hud_done"); } diff --git a/tools/check_leaderboard.mjs b/tools/check_leaderboard.mjs index 332b09a..002479c 100644 --- a/tools/check_leaderboard.mjs +++ b/tools/check_leaderboard.mjs @@ -44,7 +44,7 @@ function functions(source) { function engine(game) { const source = readFileSync(new URL(`${game}/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']); + 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 = []; 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' }; @@ -52,6 +52,7 @@ function engine(game) { const context = vm.createContext({ level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table }, GetDvar: (key) => dvars[key] ?? '', + getTime: () => 1000000, spawnStruct: () => ({}), isDefined: (value) => value !== undefined && value !== null, 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')); 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`, () => { 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é 🧟');