diff --git a/CHANGELOG.md b/CHANGELOG.md index f63f971..da1daa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ T5/T6 script and each native plugin. Current versions are recorded in ## 2026-10-17 +### Increase map-record reminders as players approach the target + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` | +| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` | +| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` | +| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` | + +- Add matching T5/T6 round-start reminders using the latest usable cached map + record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the + final five. Show matching/overtake milestones, then stop reminders after the + first overtake for the rest of the match. Milestones distinguish reaching a round + from saving the result after match completion; `.leaders` remains available. + Listen to the stock `start_of_round` event so reminders follow the actual + start, including on T6 where the counter advances during the round break. + Ignore duplicate starts and counter rewinds within the match. +- Common renders a compact record header and one proximity/milestone line for + six seconds, using two nonarchived elements in the existing service area. + Keep full join/`.leaders` announcements, all tied holders and 12-second pages. + Skip pending or active help/records, unready players and recent welcome/manual + record requests; opening help discards a reminder. Release elements on idle, + interruption, disconnect and game end. Both games work without optional HUD. +- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or + expired records suppress reminders; skipped reminders are not queued. +- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`. + There is no new native-plugin, backend or upstream dependency requirement. + Unchanged HUD, AFK and other scripts retain their individual versions. +- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join, + 23 telemetry and 39 AFK portable checks passed, as did both moderation + script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool + `8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked + after the event-timing fix. JSON uniqueness, all 18 script manifest entries, + version/changelog consistency, local documentation links and `git diff --check` + passed. Portable checks use engine shims; no live Plutonium validation or + backend integration was performed. T5 has no gsc-tool compiler backend. +- Deployment: copy the updated leaderboard and common scripts together for + each installed game, then restart the map. No full server-process restart is + required for this script-only update. No deployment performed. Live T5/T6 + rendering, readiness and round-transition validation remain required; see + [record validation](README.md#map-record-announcements). + ### Persistent AFK reserve and immediate expiration (Refs #12) | Component | Version | Affected source | diff --git a/README.md b/README.md index 267893a..316a55a 100644 --- a/README.md +++ b/README.md @@ -409,14 +409,18 @@ followed by up to three bounded holder lines. Single-page records have no counter or footer; ties spanning multiple pages retain a page count and 12 seconds per page. Help pauses a visible record and resumes its remaining page time when help ends. `.leaders` during help replaces the pending record -with a fresh replay from page one. One service is visible at a time. Services -remain private and work when the HUD module is omitted. +with a fresh replay from page one. Round-start reminders use just the record +header and one proximity or milestone line for six seconds. They skip busy +services; opening help discards a reminder instead of resuming it later. +One service is visible at a time. Services remain private and work when the +HUD module is omitted. Both games allocate only the active service view and release it when idle or before switching between help and records. Pages reuse the active view's elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page counter; records use four, or five with a multi-page counter. Hidden elements -still occupy slots. The controller and pending requests are separate from +still occupy slots. Compact reminders use two nonarchived elements. +The controller and pending requests are separate from display lifetime, so an idle service holds no HUD elements. HUD `1.4.2` requires matching common `1.4.0+` and @@ -426,7 +430,8 @@ avoiding dynamic counter strings in the text config-string pool. Numeric HP still uses `SetValue`. On r5334+, deploy the updated HUD script and restart the map; no full server-process restart is required. On older Plutonium revisions, update clients and server and fully restart them before deploying. -Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires plugin `1.4.0+`, +Leaderboard `1.2.0` requires matching common `1.5.0+` for round-start reminders. +AFK `2.0.0` requires plugin `1.4.0+`, common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing leaderboard plugin requirements still apply. Common retains its older panel helpers for unchanged consumers; the new layout uses fullscreen overlay helpers @@ -435,8 +440,9 @@ with the same integer shader-width handling. ### HUD validation Run `node tools/check_hud.mjs` for portable pagination, service arbitration, -request replacement and cleanup checks, and `node tools/check_leaderboard.mjs` -for record data handling. Run `node tools/check_bank.mjs`, +reminder rendering, request replacement and cleanup checks, and +`node tools/check_leaderboard.mjs` for record data handling and reminder cadence. +Run `node tools/check_bank.mjs`, `node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged command/lifecycle regressions. These checks use engine shims and do not prove GSC VM behavior. Follow the @@ -488,7 +494,10 @@ Before rollout, validate both engines in Plutonium: ## Map record announcements Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6, -**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`. +**along with common `1.5.0+`** for that game when using leaderboard `1.2.0`. +Copy both scripts for each installed game and restart the map. This update +adds no native-plugin or backend requirement and needs no full server-process +restart when existing leaderboard dependencies are already installed. The 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. @@ -530,6 +539,32 @@ the announcement finishes. Both games release their view elements when idle or changing service kinds. Disconnect or game end cleans up any remaining service elements. +At round start, reminders become more frequent as the current round approaches +the latest usable cached record. The cadence is measured back from that record: + +| Rounds below the record | Reminder rounds | +| --- | --- | +| More than 20 | None | +| 11–20 | At gaps of 20 and 15 rounds | +| 6–10 | At gaps of 10, 8 and 6 rounds | +| 1–5 | Every round | +| Record reached | Matching-round milestone | +| First round above the record | Overtake milestone, then stop for the match | + +For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and +45–49, with milestones at 50 and 51. Each uses the existing record header and +one short proximity or milestone line for six seconds, without holder pages. +Milestones describe the round reached; the new result is recorded only after +the match ends. The full join announcement and `.leaders` retain all holders +and their 12-second pages. `.leaders` remains available after the overtake. + +Reminders require player readiness and skip players with a pending welcome, +help or record view, or a welcome/manual record request in the last 30 seconds. +Skipped reminders are not queued. Opening help discards an active reminder. +Empty, unavailable and expired snapshots produce no reminders. The existing +cache worker supplies all record data; round starts do not trigger HTTP requests. +Both games use the same behavior, including with the optional HUD omitted. + One level worker fetches the map list and pages of three players, stopping when the highest-round tie ends. Successful snapshots refresh every five minutes. Failures retry after 30 seconds and retain the last complete snapshot for up to @@ -562,6 +597,19 @@ 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. +For reminders, use a known record on each engine and advance through all cadence +boundaries, the matching round and the first higher round. Confirm later rounds +remain quiet while `.leaders` still works, including after a cache refresh. +Check a record not divisible by five to verify the cadence follows the record. +Keep help or a full record open at an eligible round, join immediately before +that round, and request `.leaders` within 30 seconds of it; no reminder should +replace those views or appear later as a queued message. Open help during a +reminder and confirm it does not resume. Verify six-second dismissal, two-element +cleanup on disconnect/game end, and the same behavior without HUD installed. +Repeat with an empty or expired cache and compare request logs: eligible rounds +must add no HTTP traffic. Live T5/T6 rendering and round-transition validation +remain required even when portable checks and T6 compilation pass. + ## Telemetry authentication (T5 and T6) Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 74b0307..f9bbe99 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -16,7 +16,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends | | [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round | | [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | -| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection | +| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders | | [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands | | [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ | @@ -89,8 +89,8 @@ the installed command list in pages of nine rows in aligned rows. Services remai core in place and work without the HUD module installed. See the [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). Only the active service view holds HUD elements: 19 for nine-command help -(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page -counter). Pages reuse elements; switching views or closing the service releases +(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page +counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases them. Idle services hold no HUD elements, while the controller retains pending requests and interruption/resume behavior. Core + AFK use 10 + 2 elements. @@ -99,13 +99,16 @@ HUD `1.4.2` requires common `1.4.0+` and `SetTextUnlimited` counter, updated only when the count changes. Deploy the updated HUD script and restart the map on r5334+; no full server-process restart is required. Older Plutonium revisions need client/server updates and -full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching +full restarts first. Leaderboard `1.2.0` requires common `1.5.0+` for its +round-start reminders. AFK `2.0.0` requires the matching plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md). ## Map record announcements Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to -`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a +`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.2.0` requires +common `1.5.0+`; this script update adds no native-plugin or backend dependency +and requires no full server-process restart. Leaderboard requires a [t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing `http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade requires a full server-process restart. To omit leaderboard, remove its script; @@ -117,6 +120,16 @@ the same private announcement as playable joins. T5 matches its engine map to the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements) apply to both games. +Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and +6 rounds, then every round for the final five, followed by matching/overtake +milestones. After the first overtake, reminders stop for that match; `.leaders` +still works. A reminder shows the record and one short status line for six +seconds. Join and manual announcements keep all holders and 12-second pages. +Reminders skip busy help/record views, unready players, recent welcome/manual +requests and empty/unavailable/expired records. Help discards a reminder instead +of pausing it. No extra HTTP requests are made. Follow the shared live checks +for cadence, interruption, cleanup and operation without the optional HUD. + ## Player commands Type `.help` or `.commands` in chat to see available player commands and a short description. diff --git a/t5/scripts/tws_t5_common.gsc b/t5/scripts/tws_t5_common.gsc index c1a5bea..61b1646 100644 --- a/t5/scripts/tws_t5_common.gsc +++ b/t5/scripts/tws_t5_common.gsc @@ -170,6 +170,9 @@ show_help() self service_ensure(); state = self.tws_service; service_advance(state, getTime()); + // Round-start notices are timely hints, not requests to resume after help. + if (isDefined(state.record) && state.record.kind == "reminder") + state.record = undefined; state.generation++; state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation); service_render(state); @@ -192,6 +195,29 @@ show_record(snapshot) service_render(state); } +// Automatic round-start hints yield to any existing service and never queue. +show_record_reminder(snapshot, message) +{ + if (isDefined(level.tws_service_ended) || !isDefined(snapshot) || + !isDefined(snapshot.round) || snapshot.round <= 0 || + !isDefined(message) || message == "") + return false; + self service_ensure(); + state = self.tws_service; + if (state.closed) + return false; + service_advance(state, getTime()); + if (isDefined(state.help) || isDefined(state.record)) + return false; + + state.generation++; + state.record = service_request("reminder", 1, state.generation); + state.record.snapshot = snapshot; + state.record.message = message; + service_render(state); + return true; +} + service_page_count(count, rows) { return int((max(count, 1) + rows - 1) / rows); @@ -205,6 +231,8 @@ service_request(kind, pages, generation) request.page = 0; request.pages = pages; request.remaining = 12000; + if (kind == "reminder") + request.remaining = 6000; return request; } @@ -351,7 +379,10 @@ service_view_create(state, request) state.record_title = service_text(self, state, level.hud_service_right, level.hud_service_record_y, "right", 1.1, "", level.hud_c_count); - for (i = 0; i < 3; i++) + rows = 3; + if (request.kind == "reminder") + rows = 1; + for (i = 0; i < rows; i++) state.rows[i] = service_text(self, state, level.hud_service_right, level.hud_service_holders_y + i * level.hud_service_holders_pitch, "right", 1.05, "", level.hud_c_label); @@ -467,6 +498,12 @@ service_render(state) state.record_title.alpha = 1; if (!isDefined(snapshot) || snapshot.round == 0) return; + if (request.kind == "reminder") + { + state.rows[0] SetText(request.message); + state.rows[0].alpha = 1; + return; + } for (i = 0; i < 3; i++) { index = page * 3 + i; diff --git a/t5/scripts/tws_t5_leaderboard.gsc b/t5/scripts/tws_t5_leaderboard.gsc index f6b2b25..182e1e3 100644 --- a/t5/scripts/tws_t5_leaderboard.gsc +++ b/t5/scripts/tws_t5_leaderboard.gsc @@ -1,4 +1,4 @@ -/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */ +/* tws_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */ #include maps\_utility; #include common_scripts\utility; @@ -24,10 +24,12 @@ init() level.tws_lb_pending = undefined; level.tws_lb_cache = undefined; level.tws_lb_attempt = 0; + level.tws_lb_chase_finished = undefined; 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(); + level thread lb_round_watcher(); } lb_worker() @@ -64,6 +66,88 @@ lb_worker() } } +lb_round_watcher() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + last_round = 0; + for (;;) + { + // T6 advances its counter before the between-round pause is over. + // Both engines notify the actual start; never synthesize a late start. + level waittill("start_of_round"); + if (!isDefined(level.round_number) || level.round_number <= last_round) + continue; + last_round = level.round_number; + lb_round_start(last_round); + } +} + +lb_round_start(round) +{ + if (isDefined(level.tws_lb_chase_finished)) + return; + snapshot = lb_cached_record(); + if (!isDefined(snapshot) || snapshot.round < 2) + return; + message = lb_round_message(round, snapshot.round); + if (message == "") + return; + + // Celebrate at most once, even if a later refresh raises the fleet record. + // Busy clients skip the milestone too; no delayed mid-round announcements. + if (round > snapshot.round) + level.tws_lb_chase_finished = true; + players = getPlayers(); + for (i = 0; i < players.size; i++) + players[i] lb_remind_player(snapshot, message); +} + +lb_round_message(round, record) +{ + if (round < 1 || record < 2) + return ""; + gap = record - round; + if (gap < 0) + return "Recorded high round passed!"; + if (gap == 0) + return "Record round reached!"; + if (gap > 20) + return ""; + // Anchor the cadence to the target, not to round numbers divisible by 5/2. + if (gap > 10 && gap % 5 != 0) + return ""; + if (gap > 5 && gap <= 10 && gap % 2 != 0) + return ""; + if (gap == 1) + return "1 round away from matching it"; + return gap + " rounds away from matching it"; +} + +lb_player_ready(player) +{ + // A welcome attempt (even with no record) or manual request releases this + // gate. Give that full announcement at least 30 seconds before a reminder. + if (!isDefined(player.tws_lb_last_request) || + getTime() - player.tws_lb_last_request < 30000) + return false; + if (!isDefined(player.sessionstate) || + (player.sessionstate != "playing" && player.sessionstate != "spectator")) + return false; + if (isDefined(player.hasSpawned) && !player.hasSpawned) + return false; + return true; +} + +lb_remind_player(snapshot, message) +{ + if (!lb_player_ready(self)) + return; + self scripts\sp\tws_t5_common::show_record_reminder(snapshot, message); +} + lb_welcome() { self endon("disconnect"); @@ -128,6 +212,7 @@ lb_request(manual) // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); + self.tws_lb_last_request = getTime(); snapshot = lb_cached_record(); if (!isDefined(snapshot) && !manual) return; diff --git a/t6/scripts/README.md b/t6/scripts/README.md index 007be21..d46bac2 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -13,7 +13,7 @@ private server token. | `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD | | `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils | | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | -| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils | +| `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils | | `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 | | `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ | @@ -102,8 +102,8 @@ the installed command list in pages of nine rows in aligned rows. Services remai core in place and work without the HUD module installed. See the [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). Only the active service view holds HUD elements: 19 for nine-command help -(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page -counter). Pages reuse elements; switching views or closing the service releases +(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page +counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases them. Idle services hold no HUD elements, while the controller retains pending requests and interruption/resume behavior. Core + AFK use 10 + 2 elements. @@ -112,7 +112,8 @@ HUD `1.4.2` requires common `1.4.0+` and `SetTextUnlimited` counter, updated only when the count changes. Deploy the updated HUD script and restart the map on r5334+; no full server-process restart is required. Older Plutonium revisions need client/server updates and -full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching +full restarts first. Leaderboard `1.2.0` requires common `1.5.0+` for its +round-start reminders. AFK `2.0.0` requires the matching plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md). ## Map record announcements @@ -121,6 +122,21 @@ plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md). It works independently of telemetry and HUD, and privately announces records once per connection, including newcomers waiting in spectator mode. +Leaderboard `1.2.0` requires common `1.5.0+`. Copy both scripts to +`Plutonium/storage/t6/scripts/zm/` and restart the map. This script update adds +no native-plugin or backend dependency and requires no full server-process +restart when the existing plugin is installed. + +Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and +6 rounds, then every round for the final five, followed by matching/overtake +milestones. After the first overtake, reminders stop for that match; `.leaders` +still works. A reminder shows the record and one short status line for six +seconds. Join and manual announcements keep all holders and 12-second pages. +Reminders skip busy help/record views, unready players, recent welcome/manual +requests and empty/unavailable/expired records. Help discards a reminder instead +of pausing it. No extra HTTP requests are made. Follow the shared live checks +for cadence, interruption, cleanup and operation without the optional HUD. + Map selection uses stock `level.scr_zm_ui_gametype` and `level.scr_zm_map_start_location`, falling back to the corresponding `ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example, diff --git a/t6/scripts/tws_t6_common.gsc b/t6/scripts/tws_t6_common.gsc index 5bf1f20..573f5b8 100644 --- a/t6/scripts/tws_t6_common.gsc +++ b/t6/scripts/tws_t6_common.gsc @@ -166,6 +166,9 @@ show_help() self service_ensure(); state = self.tws_service; service_advance(state, getTime()); + // Round-start notices are timely hints, not requests to resume after help. + if (isDefined(state.record) && state.record.kind == "reminder") + state.record = undefined; state.generation++; state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation); service_render(state); @@ -188,6 +191,29 @@ show_record(snapshot) service_render(state); } +// Automatic round-start hints yield to any existing service and never queue. +show_record_reminder(snapshot, message) +{ + if (isDefined(level.tws_service_ended) || !isDefined(snapshot) || + !isDefined(snapshot.round) || snapshot.round <= 0 || + !isDefined(message) || message == "") + return false; + self service_ensure(); + state = self.tws_service; + if (state.closed) + return false; + service_advance(state, getTime()); + if (isDefined(state.help) || isDefined(state.record)) + return false; + + state.generation++; + state.record = service_request("reminder", 1, state.generation); + state.record.snapshot = snapshot; + state.record.message = message; + service_render(state); + return true; +} + service_page_count(count, rows) { return int((max(count, 1) + rows - 1) / rows); @@ -201,6 +227,8 @@ service_request(kind, pages, generation) request.page = 0; request.pages = pages; request.remaining = 12000; + if (kind == "reminder") + request.remaining = 6000; return request; } @@ -347,7 +375,10 @@ service_view_create(state, request) state.record_title = service_text(self, state, level.hud_service_right, level.hud_service_record_y, "right", 1.1, "", level.hud_c_count); - for (i = 0; i < 3; i++) + rows = 3; + if (request.kind == "reminder") + rows = 1; + for (i = 0; i < rows; i++) state.rows[i] = service_text(self, state, level.hud_service_right, level.hud_service_holders_y + i * level.hud_service_holders_pitch, "right", 1.05, "", level.hud_c_label); @@ -463,6 +494,12 @@ service_render(state) state.record_title.alpha = 1; if (!isDefined(snapshot) || snapshot.round == 0) return; + if (request.kind == "reminder") + { + state.rows[0] SetText(request.message); + state.rows[0].alpha = 1; + return; + } for (i = 0; i < 3; i++) { index = page * 3 + i; diff --git a/t6/scripts/tws_t6_leaderboard.gsc b/t6/scripts/tws_t6_leaderboard.gsc index 2f0f202..a843e78 100644 --- a/t6/scripts/tws_t6_leaderboard.gsc +++ b/t6/scripts/tws_t6_leaderboard.gsc @@ -1,4 +1,4 @@ -/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */ +/* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */ #include maps\mp\_utility; #include common_scripts\utility; @@ -26,10 +26,12 @@ init() level.tws_lb_pending = undefined; level.tws_lb_cache = undefined; level.tws_lb_attempt = 0; + level.tws_lb_chase_finished = undefined; 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(); + level thread lb_round_watcher(); } lb_worker() @@ -66,6 +68,88 @@ lb_worker() } } +lb_round_watcher() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + last_round = 0; + for (;;) + { + // T6 advances its counter before the between-round pause is over. + // Both engines notify the actual start; never synthesize a late start. + level waittill("start_of_round"); + if (!isDefined(level.round_number) || level.round_number <= last_round) + continue; + last_round = level.round_number; + lb_round_start(last_round); + } +} + +lb_round_start(round) +{ + if (isDefined(level.tws_lb_chase_finished)) + return; + snapshot = lb_cached_record(); + if (!isDefined(snapshot) || snapshot.round < 2) + return; + message = lb_round_message(round, snapshot.round); + if (message == "") + return; + + // Celebrate at most once, even if a later refresh raises the fleet record. + // Busy clients skip the milestone too; no delayed mid-round announcements. + if (round > snapshot.round) + level.tws_lb_chase_finished = true; + players = getPlayers(); + for (i = 0; i < players.size; i++) + players[i] lb_remind_player(snapshot, message); +} + +lb_round_message(round, record) +{ + if (round < 1 || record < 2) + return ""; + gap = record - round; + if (gap < 0) + return "Recorded high round passed!"; + if (gap == 0) + return "Record round reached!"; + if (gap > 20) + return ""; + // Anchor the cadence to the target, not to round numbers divisible by 5/2. + if (gap > 10 && gap % 5 != 0) + return ""; + if (gap > 5 && gap <= 10 && gap % 2 != 0) + return ""; + if (gap == 1) + return "1 round away from matching it"; + return gap + " rounds away from matching it"; +} + +lb_player_ready(player) +{ + // A welcome attempt (even with no record) or manual request releases this + // gate. Give that full announcement at least 30 seconds before a reminder. + if (!isDefined(player.tws_lb_last_request) || + getTime() - player.tws_lb_last_request < 30000) + return false; + if (!isDefined(player.sessionstate) || + (player.sessionstate != "playing" && player.sessionstate != "spectator")) + return false; + if (isDefined(level.passed_introscreen) && !level.passed_introscreen) + return false; + return true; +} + +lb_remind_player(snapshot, message) +{ + if (!lb_player_ready(self)) + return; + self scripts\zm\tws_t6_common::show_record_reminder(snapshot, message); +} + lb_welcome() { self endon("disconnect"); @@ -131,6 +215,7 @@ lb_request(manual) // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); + self.tws_lb_last_request = getTime(); snapshot = lb_cached_record(); if (!isDefined(snapshot) && !manual) return; diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs index b87163b..3869b00 100644 --- a/tools/check_hud.mjs +++ b/tools/check_hud.mjs @@ -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`, () => { diff --git a/tools/check_leaderboard.mjs b/tools/check_leaderboard.mjs index 332565d..ccafbd6 100644 --- a/tools/check_leaderboard.mjs +++ b/tools/check_leaderboard.mjs @@ -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(/(? 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); } diff --git a/versions.json b/versions.json index da205ad..eea65a2 100644 --- a/versions.json +++ b/versions.json @@ -1,18 +1,18 @@ { "tws_t5_afk.gsc": "2.0.0", "tws_t5_bank.gsc": "2.0.3", - "tws_t5_common.gsc": "1.4.1", + "tws_t5_common.gsc": "1.5.0", "tws_t5_hud.gsc": "1.4.2", "tws_t5_join.gsc": "1.0.0", - "tws_t5_leaderboard.gsc": "1.1.0", + "tws_t5_leaderboard.gsc": "1.2.0", "tws_t5_lock.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0", "tws_t6_afk.gsc": "2.0.0", "tws_t6_bank.gsc": "2.0.3", - "tws_t6_common.gsc": "1.4.1", + "tws_t6_common.gsc": "1.5.0", "tws_t6_hud.gsc": "1.4.2", "tws_t6_join.gsc": "1.0.1", - "tws_t6_leaderboard.gsc": "1.1.0", + "tws_t6_leaderboard.gsc": "1.2.0", "tws_t6_lock.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0", "t5-plugin": "1.4.0",