From e93182cd029232a337c5ef87c3963b9a57eb2131 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 19 Oct 2026 14:11:54 -0400 Subject: [PATCH] Show personal map records on join and replay --- CHANGELOG.md | 44 +++++++++ README.md | 98 ++++++++++++++------- t5/scripts/README.md | 42 +++++---- t5/scripts/tws_t5_common.gsc | 55 ++++++++---- t5/scripts/tws_t5_leaderboard.gsc | 142 ++++++++++++++++++++++++------ t6/scripts/README.md | 36 ++++---- t6/scripts/tws_t6_common.gsc | 55 ++++++++---- t6/scripts/tws_t6_leaderboard.gsc | 142 ++++++++++++++++++++++++------ tools/check_hud.mjs | 100 +++++++++++++-------- tools/check_leaderboard.mjs | 98 +++++++++++++++++++-- versions.json | 8 +- 11 files changed, 620 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1eca5..63eb5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,50 @@ T5/T6 script and each native plugin. Current versions are recorded in ## 2026-10-18 +### Show each player's personal map record on join and replay + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_leaderboard.gsc` | `1.3.1` | `t5/scripts/tws_t5_leaderboard.gsc` | +| `tws_t6_leaderboard.gsc` | `1.3.1` | `t6/scripts/tws_t6_leaderboard.gsc` | +| `tws_t5_common.gsc` | `1.6.1` | `t5/scripts/tws_t5_common.gsc` | +| `tws_t6_common.gsc` | `1.6.1` | `t6/scripts/tws_t6_common.gsc` | + +- Add a private `Your Best | Round N` heading to join announcements and `.leaders`, + using the same resolved map variant as the fleet record. Distinguish confirmed + empty history (`None yet`) from failed/malformed lookups (`Unavailable`). +- Show `Map Record | Round N`, every tied holder on a separate line, a blank row, + then the personal heading. Both headings use the brighter heading colour and + size, distinct from player names. Remove record pagination and counters: show + the entire record for 12 seconds, retaining help priority and pause/resume. + Allocate one element per holder plus the two headings, resize when the holder + count changes, and release the complete view on expiry, interruption or cleanup. + Help retains its existing command pagination; compact reminders are unchanged. +- Query the existing public leaderboard search by GUID, paginate past name and + substring matches, and accept only exact string identity with matching game/map + evidence. Cache successes per connection for five minutes and failures for + 30 seconds. Bound pagination time and queue waits; serialize personal and map + HTTP requests, retaining the native slot until collection even after a caller + times out or disconnects. New manual replays cancel older waiting announcements. +- Apply matching behavior to T5 and T6 using their existing GUID access and HTTP + APIs. Round reminders still use only the map cache. Common's new personal text + argument is optional for existing consumers. +- Compatibility: leaderboard `1.3.1` requires matching common `1.6.1+`. No new + backend, native-plugin or upstream dependency requirement. +- Validation: 177 leaderboard, 76 service HUD, 14 core HUD, 42 bank, 17 join, + 23 telemetry, 76 lock and 47 AFK checks passed, plus both moderation checks. + Record fixtures verify all-holder rendering, heading order/style/spacing, no + record counter, resizing, expiry and help interruption. + Changed T6 scripts compiled and assembled with upstream gsc-tool `8f1d6bd` + libraries. These are engine-shim checks; live Windows/Plutonium rendering, + concurrent joins and backend integration remain unverified. T5 has no gsc-tool + backend. JSON uniqueness, script manifest coverage, matching versions/changelog, + changed documentation's local links and `git diff --check` passed. +- Deployment: copy the updated leaderboard and common scripts together for each + installed game and restart the map. No full server-process restart or additional + deployment action is required. No deployment was performed. + + ### Add majority votes to lock and unlock the lobby | Component | Version | Affected source | diff --git a/README.md b/README.md index d8a6ea1..9b9a6ed 100644 --- a/README.md +++ b/README.md @@ -458,12 +458,13 @@ count appears only when more than one page is needed. Each page lasts command restarts page one. Both engines position rows explicitly, avoiding T5's newline-rendering problem. -Records use the same service area, right aligned: `Map record | Round N` -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. Round-start reminders use just the record +Records use the same service area, right aligned: `Map Record | Round N`, +followed by every tied holder on a separate line. After a blank row, +`Your Best | Round N` uses the same heading style as the map record, distinct +from the holder names. The entire announcement is visible at once for 12 seconds; +records have no pages, counter or footer. Help pauses a visible record and +resumes its remaining time when help ends. `.leaders` during help replaces the +pending record with a fresh replay. 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 @@ -472,7 +473,8 @@ 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 +counter; full records use one element per holder plus the map and personal +headings. Hidden elements 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. @@ -484,7 +486,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.2.0` requires matching common `1.5.0+` for round-start reminders. +Leaderboard `1.3.1` requires matching common `1.6.1+` for personal records +and 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 @@ -526,10 +529,9 @@ Before rollout, validate both engines in Plutonium: exercise the nine-row page limit, page counter and repeat-to-restart behavior. Remove optional modules and restart the map to confirm filtering, including `.hud` disappearing without HUD, then restore them. -4. Interrupt records with help and check that the interrupted page resumes for +4. Interrupt records with help and check that the full announcement resumes for its remaining time. Repeat `.leaders` while help is open; only the latest - replay should start, at page one. Check long holder names, ties spanning - pages, no records, and unavailable records. Repeat without the HUD installed. + replay should start. Check long holder names, all tied holders visible at once, no records, and unavailable records. Repeat without the HUD installed. 5. Disconnect or end the game while help/records are active, reconnect and request them again. Verify no duplicate elements, stale timeout closures or surviving service windows. Confirm `.join`, bank commands and lobby lock @@ -548,13 +550,14 @@ 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.5.0+`** for that game when using leaderboard `1.2.0`. +**along with common `1.6.1+`** for that game when using leaderboard `1.3.1`. 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. +round, every tied holder and the joining player’s personal best on that map once +per connection, including spectator newcomers. Type `.leaders` to replay both +records privately. Personal bests use the same game and exact map variant. 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 service identifies the record as unavailable; this differs from @@ -580,18 +583,32 @@ exist in the API's map list and match the current game and engine map. Clear it when changing variants; restart the map after changing these settings. The announcement starts five seconds after player readiness and waits for the -initial record fetch if necessary. It appears as right-aligned text in the -common-owned upper-right service area: `Map record | Round N`, followed by up -to three holder lines. There is no background or replay footer, and a page -count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining -page time until help closes. A `.leaders` request during help replaces any -pending replay and starts from page one after help closes. +initial map and personal record fetches if necessary. It appears as right-aligned +text in the common-owned upper-right service area, all at once: + +```text +Map Record | Round 25 +Player 1 +Player 2 +Player 3 +Player 4 + +Your Best | Round 15 +``` + +Each holder has a separate line. The personal best has its own heading below a +blank row and uses the map heading's brighter colour and font size. A confirmed +empty personal history shows `Your Best | None yet`; a failed lookup shows +`Your Best | Unavailable`. There is no background, replay footer, pagination or +page counter. The whole view lasts 12 seconds. Opening `.help` pauses its +remaining time until help closes. A `.leaders` request during help replaces any +pending replay and starts a fresh 12-second view after help closes. All holders are included; colour/control codes are removed, and overlong -individual names are abbreviated without splitting UTF-8 characters. Bounded -lines and additional pages preserve every tied holder. The window closes when -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. +individual names are abbreviated without splitting UTF-8 characters. The window +closes when the announcement finishes. Both games release their view elements +when idle or changing service kinds, and resize the holder list when a new record +has a different number of holders. 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: @@ -607,10 +624,10 @@ the latest usable cached record. The cadence is measured back from that record: 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. +one short proximity or milestone line for six seconds, without the holder list. 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. +in one 12-second view. `.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. @@ -628,10 +645,21 @@ Partial page results never replace the cache. The API has no pagination snapshot token: changed totals/record rounds reject the refresh, but concurrent renames can still move entries between pages. Refreshes deduplicate GUIDs. +Personal lookups use the existing public endpoint’s `q` search, scoped to the +resolved map key. Search results can match names or partial GUIDs, so only an +exact string GUID with matching game/map record evidence is accepted. Successful +personal results cache for five minutes per connection; failures cache as +unavailable for 30 seconds. Searches paginate in groups of three and stop +starting further pages after 15 seconds; incomplete searches are unavailable. +A valid empty map history needs no personal request. No backend or plugin update +is needed. Round reminders continue to use only the map cache. + Requests have a 15-second GSC wait limit. The TWS plugins also enforce native 5-second connection and 15-second transfer limits. The leaderboard waits for native -completion before starting another request; -player threads never wait directly on HTTP. Responses at the plugins' 20 KiB +completion before starting another request. Personal lookups share this request +slot with the map worker, with a 15-second queue wait limit. Disconnect, game +end and a newer manual replay cancel the waiting announcement; the level +collector still drains any native request already started. Responses at the plugins' 20 KiB truncation boundary are rejected. The complete JSON grammar, UTF-8, string escapes and response shape are checked before consuming data. These guards also reject HTML/error bodies. Leaderboard still uses body validation; the new native HTTP @@ -640,14 +668,16 @@ status diagnostics are consumed by telemetry. Run the portable data/structure checks with `node tools/check_leaderboard.mjs`. They execute the scripts' data helpers with engine-value shims, not a game VM. Live checks: join while playing and spectating; die/respawn (no repeat); reconnect -(one new announcement); compare Town/TranZit/Grief; check one holder, ties across -pages, no records, and a slow/unavailable/malformed API. Disconnect or end the game +(one new announcement); compare Town/TranZit/Grief; check one holder, multiple tied holders, no records, and a slow/unavailable/malformed API. Check a personal best +below the map record, a tied holder, a new player, and two players with different +bests. Confirm GUID substring/name matches cannot supply someone else’s record, +and unavailable lookups never claim an empty history. Disconnect or end the game 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 announcement 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 +Try `.leaders` and `.LEADERS` after the announcement ends and while it is +visible: each should restart one complete private announcement. 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. diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 57795fe..dda04f4 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) | Automatic lobby lock plus majority `.lock` / `.unlock` votes | | [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 record on connection, `.leaders` replay and adaptive round-start reminders | +| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map and personal records 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+ | @@ -83,14 +83,16 @@ continue updating while hidden; game-over values remain frozen. Help, records and the stock HUD are unaffected. `.hud` requires the HUD module. Common provides one unboxed upper-right service area below the Plutonium tag. -Records are right aligned, with `Map record | Round N` followed by up to three -holder lines; there is no replay footer or single-page counter. Help displays +Records are right aligned: `Map Record | Round N`, every tied holder on its own +line, then a blank row and a separate `Your Best | Round N` heading. The whole +record is shown at once for 12 seconds, with no pages, counter or footer. Help displays the installed command list in pages of nine rows in aligned rows. Services remain private, leave the 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), 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 +(20 with a multi-page counter), one element per holder plus two headings for +full records, or two for compact round-start reminders. Help 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,15 +101,15 @@ 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.2.0` requires common `1.5.0+` for its -round-start reminders. AFK `2.0.0` requires the matching +full restarts first. Leaderboard `1.3.1` requires common `1.6.1+` for its +personal records and 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 `1.2.0` requires -common `1.5.0+`; this script update adds no native-plugin or backend dependency +`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.3.1` requires +common `1.6.1+`; 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 @@ -124,10 +126,14 @@ 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. +seconds. Join and manual announcements show your personal best on the exact map variant +below all holders, with one player per line and a blank row before the personal +heading. Everything is visible at once for 12 seconds, with no record pages or +counter. New players see `Your Best | None yet`; failed personal lookups show +`Your Best | Unavailable`. 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 +of pausing it. Round reminders make no extra HTTP requests. Follow the shared live checks for cadence, interruption, cleanup and operation without the optional HUD. ## Player commands @@ -149,7 +155,7 @@ explicit row positions so T5 does not concatenate newline-separated text. | `.afk` | Enter protected AFK using your time reserve | | `.back` | Return from AFK | | `.join` | Join mid-round (newcomers only) | -| `.leaders` | Show the map record again (leaderboard module required) | +| `.leaders` | Show map and personal records (leaderboard module required) | | `.b` | Check bank balance (bank module required) | | `.d ` | Deposit points (bank module required) | | `.w ` | Withdraw points (bank module required) | @@ -162,9 +168,9 @@ also removes its entries from `.help` after a map restart. Lock and extended AFK commands appear on additional pages. Bank entries retain the full `.d ` / `.w ` syntax. Removing HUD removes `.hud` from the list. The enabled leaderboard module registers `.leaders`, which replays the latest -cached map record privately. Repeating it restarts the record from the first page; -help pauses the record's remaining page time and resumes it after the final -help page. `.leaders` during help replaces the pending record replay from page one. +map and personal records privately. Repeating it restarts the full 12-second +announcement; help pauses the record's remaining time and resumes it after the +final help page. `.leaders` during help replaces the pending record replay. ## Dependency graph @@ -224,7 +230,7 @@ connect listener anywhere else crashes the T5 SP handshake. - Spawn-handler registry - Connection-handler registry for optional modules that serve spectator newcomers - `.help` / `.commands` listener and ordered help registry populated by installed modules -- One service controller per player, with help/record pagination, +- One service controller per player, with help pagination and complete record views, interruption/resume, request replacement and disconnect/game-end cleanup; display elements exist only for the active view and are released when idle - Core element registry with per-element alpha state and a per-player visibility mask @@ -272,8 +278,8 @@ connect listener anywhere else crashes the T5 SP handshake. `.help` and the remaining commands still work. Restore the scripts afterward. 3. Verify help closes after 12 seconds. Add custom registered commands to test the nine-row page limit, counter and repeat-to-restart behavior. Interrupt a record - with help and verify remaining page time resumes; repeat `.leaders` during - help and verify only the latest replay starts from page one. + with help and verify remaining display time resumes; repeat `.leaders` during + help and verify only the latest complete replay starts. 4. Reopen after expiry, die/respawn and reconnect; verify no duplicate text or leftover elements. Repeat without the HUD module. Follow the complete [HUD validation guide](../../README.md#hud-validation) before rollout. diff --git a/t5/scripts/tws_t5_common.gsc b/t5/scripts/tws_t5_common.gsc index 61b1646..4ba3ed6 100644 --- a/t5/scripts/tws_t5_common.gsc +++ b/t5/scripts/tws_t5_common.gsc @@ -179,18 +179,16 @@ show_help() } // An undefined snapshot is the explicit unavailable view for manual requests. -show_record(snapshot) +show_record(snapshot, personal) { if (isDefined(level.tws_service_ended)) return; self service_ensure(); state = self.tws_service; service_advance(state, getTime()); - count = 0; - if (isDefined(snapshot)) - count = snapshot.lines.size; state.generation++; - state.record = service_request("record", service_page_count(count, 3), state.generation); + state.record = service_request("record", 1, state.generation); + state.record.personal = personal; state.record.snapshot = snapshot; service_render(state); } @@ -348,12 +346,21 @@ service_ensure() self thread service_end_game(state); } +service_record_rows(request) +{ + if (request.kind == "reminder") + return 1; + if (request.kind == "record" && isDefined(request.snapshot) && request.snapshot.round > 0) + return request.snapshot.lines.size; + return 0; +} + // Allocate only the active view. Alpha-zero elements still occupy HUD slots. service_view_create(state, request) { service_view_destroy(state); state.view_kind = request.kind; - state.view_paged = request.pages > 1; + state.view_paged = request.kind == "help" && request.pages > 1; if (state.view_paged) state.page_counter = service_text(self, state, level.hud_service_right, level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim); @@ -379,15 +386,16 @@ 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); - rows = 3; - if (request.kind == "reminder") - rows = 1; + rows = service_record_rows(request); 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); - if (isDefined(state.page_counter)) - state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch; + // Give the personal record its own heading after every holder and a blank row. + if (isDefined(request.personal)) + state.personal_title = service_text(self, state, level.hud_service_right, + level.hud_service_holders_y + (rows + 1) * level.hud_service_holders_pitch, + "right", 1.1, "", level.hud_c_count); } service_view_destroy(state) @@ -405,6 +413,8 @@ service_view_destroy(state) state.command_rows = []; state.description_rows = []; state.record_title = undefined; + state.personal_title = undefined; + state.personal_text = undefined; state.rows = []; state.page_text = undefined; state.record_header_text = undefined; @@ -445,8 +455,10 @@ service_render(state) service_view_destroy(state); return; } - paged = request.pages > 1; - if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged) + paged = request.kind == "help" && request.pages > 1; + if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged || + state.rows.size != service_record_rows(request) || + isDefined(state.personal_title) != isDefined(request.personal)) service_view_create(state, request); for (i = 0; i < state.elems.size; i++) state.elems[i].alpha = 0; @@ -482,13 +494,13 @@ service_render(state) return; } - header = "Map record unavailable"; + header = "Map Record unavailable"; snapshot = request.snapshot; if (isDefined(snapshot)) { header = "No recorded high round yet"; if (snapshot.round > 0) - header = "Map record | Round " + snapshot.round; + header = "Map Record | Round " + snapshot.round; } if (!isDefined(state.record_header_text) || state.record_header_text != header) { @@ -496,6 +508,15 @@ service_render(state) state.record_header_text = header; } state.record_title.alpha = 1; + if (isDefined(request.personal)) + { + if (!isDefined(state.personal_text) || state.personal_text != request.personal) + { + state.personal_title SetText(request.personal); + state.personal_text = request.personal; + } + state.personal_title.alpha = 1; + } if (!isDefined(snapshot) || snapshot.round == 0) return; if (request.kind == "reminder") @@ -504,9 +525,9 @@ service_render(state) state.rows[0].alpha = 1; return; } - for (i = 0; i < 3; i++) + for (i = 0; i < state.rows.size; i++) { - index = page * 3 + i; + index = i; if (index < snapshot.lines.size) { text = snapshot.lines[index]; diff --git a/t5/scripts/tws_t5_leaderboard.gsc b/t5/scripts/tws_t5_leaderboard.gsc index 182e1e3..5c8a366 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 records, round reminders and .leaders replay. */ +/* tws_t5_leaderboard.gsc - private map/personal records, round reminders and .leaders replay. */ #include maps\_utility; #include common_scripts\utility; @@ -25,7 +25,7 @@ init() 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_help_command(".leaders", "Show map and personal records"); scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome); level thread lb_chat_listener(); level thread lb_worker(); @@ -212,14 +212,113 @@ lb_request(manual) // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); + self endon("tws_lb_requested"); self.tws_lb_last_request = getTime(); snapshot = lb_cached_record(); if (!isDefined(snapshot) && !manual) return; - // Common owns pagination, help priority and the single service window. + personal = lb_personal_record(self, snapshot); + self.tws_lb_last_request = getTime(); + // Common owns display lifetime, help priority and the single service window. // Undefined snapshots produce a private unavailable page for manual replay. - self scripts\sp\tws_t5_common::show_record(snapshot); + self scripts\sp\tws_t5_common::show_record(snapshot, personal); +} + +// Cache per connection; a failed lookup is unavailable, never an empty history. +lb_personal_record(player, snapshot) +{ + if (!isDefined(snapshot)) + return "Your Best | Unavailable"; + if (snapshot.round == 0) + return "Your Best | None yet"; + guid = lb_player_guid(player); + if (!isDefined(guid)) + return "Your Best | Unavailable"; + cached = player.tws_lb_personal; + if (isDefined(cached) && cached.key == snapshot.key && cached.guid == guid && + getTime() - cached.saved_at < cached.ttl) + return cached.text; + + round = lb_personal_fetch(snapshot.key, guid); + cached = spawnStruct(); + cached.key = snapshot.key; + cached.guid = guid; + cached.saved_at = getTime(); + cached.ttl = 30000; + cached.text = "Your Best | Unavailable"; + if (isDefined(round)) + { + cached.ttl = 300000; + cached.text = "Your Best | None yet"; + if (round > 0) + cached.text = "Your Best | Round " + round; + } + player.tws_lb_personal = cached; + return cached.text; +} + +lb_player_guid(player) +{ + guid = player getGuid(); + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return undefined; + guid = "" + guid; + if (guid.size == 0 || guid.size > 64) + return undefined; + return guid; +} + +// Search also matches names and GUID substrings. Only exact string identity +// with matching game/map evidence can supply this player's personal best. +lb_personal_fetch(key, guid) +{ + offset = 0; + total = -1; + highest = -1; + started = getTime(); + for (;;) + { + // Bound broad substring searches; incomplete searches are unavailable. + if (getTime() - started >= 15000) + return undefined; + page = lb_get("&map=" + lb_url_encode(key) + "&q=" + lb_url_encode(guid) + "&limit=3&offset=" + offset); + if (!isDefined(page) || !lb_page_valid(page, offset, 3)) + return undefined; + if (offset == 0) + { + total = page["total"]; + highest = page["summary"]["highestRound"]; + } + if (page["total"] != total || page["summary"]["highestRound"] != highest || + offset + page["entries"].size > total) + return undefined; + for (i = 0; i < page["entries"].size; i++) + { + entry = page["entries"][i]; + if (typeof(entry) != "array" || typeof(entry["guid"]) != "string") + return undefined; + if (entry["guid"] != guid) + continue; + if (!lb_uint(entry["highestRound"]) || entry["highestRound"] < 2 || + entry["highestRound"] > highest || typeof(entry["records"]) != "array") + return undefined; + evidence = entry["records"]["highestRound"]; + if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" || + typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"])) + return undefined; + if (evidence["game"] != level.tws_lb_game || evidence["mapKey"] != key || + evidence["value"] != entry["highestRound"]) + return undefined; + return entry["highestRound"]; + } + offset += page["entries"].size; + if (offset == total) + return 0; + if (page["entries"].size == 0 || offset > 1000000) + return undefined; + wait 0.05; + } } lb_refresh() @@ -239,6 +338,7 @@ lb_refresh() return undefined; snapshot = spawnStruct(); + snapshot.key = selection.key; snapshot.name = lb_display_text(selection.name, 64); snapshot.round = 0; snapshot.names = []; @@ -288,9 +388,18 @@ lb_selection() return selection; } -// All HTTP and parsing work belongs to the level worker, never the join thread. +// Map refreshes and personal lookups share one native request slot. lb_get(query) { + // A disconnect/replay can abandon a caller, but the collector still drains + // its native request. Bound queue wait and never overlap that request. + queued_at = getTime(); + while (isDefined(level.tws_lb_pending)) + { + if (getTime() - queued_at >= 15000) + return undefined; + wait 0.1; + } options = []; options["method"] = "GET"; headers = []; @@ -461,28 +570,9 @@ lb_uint(value) lb_holder_lines(names) { lines = []; - line = ""; - columns = 0; + // Each tied player gets a separate row in the full record announcement. for (i = 0; i < names.size; i++) - { - name = lb_holder_name(names[i]); - width = lb_holder_width(name); - if (line != "" && columns + width + 2 > 16) - { - lines[lines.size] = line; - line = ""; - columns = 0; - } - if (line != "") - { - line += ", "; - columns += 2; - } - line += name; - columns += width; - } - if (line != "") - lines[lines.size] = line; + lines[lines.size] = lb_holder_name(names[i]); return lines; } diff --git a/t6/scripts/README.md b/t6/scripts/README.md index f474df1..dd615dc 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -13,7 +13,7 @@ private server token. | `tws_t6_lock.gsc` | Auto-lock at round 20 plus majority `.lock` / `.unlock` votes; clear 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 record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils | +| `tws_t6_leaderboard.gsc` | Private map and personal records on connection, `.leaders` replay and adaptive round-start reminders | Common 1.6.1+, 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+ | @@ -96,14 +96,16 @@ continue updating while hidden; game-over values remain frozen. Help, records and the stock HUD are unaffected. `.hud` requires the HUD module. Common provides one unboxed upper-right service area below the Plutonium tag. -Records are right aligned, with `Map record | Round N` followed by up to three -holder lines; there is no replay footer or single-page counter. Help displays +Records are right aligned: `Map Record | Round N`, every tied holder on its own +line, then a blank row and a separate `Your Best | Round N` heading. The whole +record is shown at once for 12 seconds, with no pages, counter or footer. Help displays the installed command list in pages of nine rows in aligned rows. Services remain private, leave the 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), 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 +(20 with a multi-page counter), one element per holder plus two headings for +full records, or two for compact round-start reminders. Help 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,8 +114,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.2.0` requires common `1.5.0+` for its -round-start reminders. AFK `2.0.0` requires the matching +full restarts first. Leaderboard `1.3.1` requires common `1.6.1+` for its +personal records and 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 @@ -122,7 +124,7 @@ 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 +Leaderboard `1.3.1` requires common `1.6.1+`. 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. @@ -131,10 +133,14 @@ 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. +seconds. Join and manual announcements show your personal best on the exact map variant +below all holders, with one player per line and a blank row before the personal +heading. Everything is visible at once for 12 seconds, with no record pages or +counter. New players see `Your Best | None yet`; failed personal lookups show +`Your Best | Unavailable`. 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 +of pausing it. Round reminders make no extra HTTP requests. 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 @@ -168,7 +174,7 @@ explicit row positions so T5 does not concatenate newline-separated text. | `.afk` | Enter protected AFK using your time reserve | | `.back` | Return from AFK | | `.join` | Join mid-round (newcomers only) | -| `.leaders` | Show the map record again (leaderboard module required) | +| `.leaders` | Show map and personal records (leaderboard module required) | | `.b` | Check bank balance (bank module required) | | `.d ` | Deposit points (bank module required) | | `.w ` | Withdraw points (bank module required) | @@ -184,9 +190,9 @@ Lock and extended AFK commands appear on additional pages. Bank entries retain the full `.d ` / `.w ` syntax. Removing HUD removes `.hud` from the list. The enabled leaderboard module registers `.leaders`, which replays the latest -cached map record privately. Repeating it restarts the record from the first page; -help pauses the record's remaining page time and resumes it after the final -help page. `.leaders` during help replaces the pending record replay from page one. +map and personal records privately. Repeating it restarts the full 12-second +announcement; help pauses the record's remaining time and resumes it after the +final help page. `.leaders` during help replaces the pending record replay. ## Defaults, matching T5 @@ -288,7 +294,7 @@ another mod installed one first. ## Validation Run the shared [portable and live HUD checks](../../README.md#hud-validation) -for core placement, service pagination, record interruptions and command regressions. +for core placement, help pagination, record interruptions and command regressions. To compile all nine scripts for T6 PC with gsc-tool: diff --git a/t6/scripts/tws_t6_common.gsc b/t6/scripts/tws_t6_common.gsc index 573f5b8..01c7a71 100644 --- a/t6/scripts/tws_t6_common.gsc +++ b/t6/scripts/tws_t6_common.gsc @@ -175,18 +175,16 @@ show_help() } // An undefined snapshot is the explicit unavailable view for manual requests. -show_record(snapshot) +show_record(snapshot, personal) { if (isDefined(level.tws_service_ended)) return; self service_ensure(); state = self.tws_service; service_advance(state, getTime()); - count = 0; - if (isDefined(snapshot)) - count = snapshot.lines.size; state.generation++; - state.record = service_request("record", service_page_count(count, 3), state.generation); + state.record = service_request("record", 1, state.generation); + state.record.personal = personal; state.record.snapshot = snapshot; service_render(state); } @@ -344,12 +342,21 @@ service_ensure() self thread service_end_game(state); } +service_record_rows(request) +{ + if (request.kind == "reminder") + return 1; + if (request.kind == "record" && isDefined(request.snapshot) && request.snapshot.round > 0) + return request.snapshot.lines.size; + return 0; +} + // Allocate only the active view. Alpha-zero elements still occupy HUD slots. service_view_create(state, request) { service_view_destroy(state); state.view_kind = request.kind; - state.view_paged = request.pages > 1; + state.view_paged = request.kind == "help" && request.pages > 1; if (state.view_paged) state.page_counter = service_text(self, state, level.hud_service_right, level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim); @@ -375,15 +382,16 @@ 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); - rows = 3; - if (request.kind == "reminder") - rows = 1; + rows = service_record_rows(request); 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); - if (isDefined(state.page_counter)) - state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch; + // Give the personal record its own heading after every holder and a blank row. + if (isDefined(request.personal)) + state.personal_title = service_text(self, state, level.hud_service_right, + level.hud_service_holders_y + (rows + 1) * level.hud_service_holders_pitch, + "right", 1.1, "", level.hud_c_count); } service_view_destroy(state) @@ -401,6 +409,8 @@ service_view_destroy(state) state.command_rows = []; state.description_rows = []; state.record_title = undefined; + state.personal_title = undefined; + state.personal_text = undefined; state.rows = []; state.page_text = undefined; state.record_header_text = undefined; @@ -441,8 +451,10 @@ service_render(state) service_view_destroy(state); return; } - paged = request.pages > 1; - if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged) + paged = request.kind == "help" && request.pages > 1; + if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged || + state.rows.size != service_record_rows(request) || + isDefined(state.personal_title) != isDefined(request.personal)) service_view_create(state, request); for (i = 0; i < state.elems.size; i++) state.elems[i].alpha = 0; @@ -478,13 +490,13 @@ service_render(state) return; } - header = "Map record unavailable"; + header = "Map Record unavailable"; snapshot = request.snapshot; if (isDefined(snapshot)) { header = "No recorded high round yet"; if (snapshot.round > 0) - header = "Map record | Round " + snapshot.round; + header = "Map Record | Round " + snapshot.round; } if (!isDefined(state.record_header_text) || state.record_header_text != header) { @@ -492,6 +504,15 @@ service_render(state) state.record_header_text = header; } state.record_title.alpha = 1; + if (isDefined(request.personal)) + { + if (!isDefined(state.personal_text) || state.personal_text != request.personal) + { + state.personal_title SetText(request.personal); + state.personal_text = request.personal; + } + state.personal_title.alpha = 1; + } if (!isDefined(snapshot) || snapshot.round == 0) return; if (request.kind == "reminder") @@ -500,9 +521,9 @@ service_render(state) state.rows[0].alpha = 1; return; } - for (i = 0; i < 3; i++) + for (i = 0; i < state.rows.size; i++) { - index = page * 3 + i; + index = i; if (index < snapshot.lines.size) { text = snapshot.lines[index]; diff --git a/t6/scripts/tws_t6_leaderboard.gsc b/t6/scripts/tws_t6_leaderboard.gsc index a843e78..f4d8f6d 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 records, round reminders and .leaders replay. */ +/* tws_t6_leaderboard.gsc - private map/personal records, round reminders and .leaders replay. */ #include maps\mp\_utility; #include common_scripts\utility; @@ -27,7 +27,7 @@ init() 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_help_command(".leaders", "Show map and personal records"); scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome); level thread lb_chat_listener(); level thread lb_worker(); @@ -215,14 +215,113 @@ lb_request(manual) // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); + self endon("tws_lb_requested"); self.tws_lb_last_request = getTime(); snapshot = lb_cached_record(); if (!isDefined(snapshot) && !manual) return; - // Common owns pagination, help priority and the single service window. + personal = lb_personal_record(self, snapshot); + self.tws_lb_last_request = getTime(); + // Common owns display lifetime, help priority and the single service window. // Undefined snapshots produce a private unavailable page for manual replay. - self scripts\zm\tws_t6_common::show_record(snapshot); + self scripts\zm\tws_t6_common::show_record(snapshot, personal); +} + +// Cache per connection; a failed lookup is unavailable, never an empty history. +lb_personal_record(player, snapshot) +{ + if (!isDefined(snapshot)) + return "Your Best | Unavailable"; + if (snapshot.round == 0) + return "Your Best | None yet"; + guid = lb_player_guid(player); + if (!isDefined(guid)) + return "Your Best | Unavailable"; + cached = player.tws_lb_personal; + if (isDefined(cached) && cached.key == snapshot.key && cached.guid == guid && + getTime() - cached.saved_at < cached.ttl) + return cached.text; + + round = lb_personal_fetch(snapshot.key, guid); + cached = spawnStruct(); + cached.key = snapshot.key; + cached.guid = guid; + cached.saved_at = getTime(); + cached.ttl = 30000; + cached.text = "Your Best | Unavailable"; + if (isDefined(round)) + { + cached.ttl = 300000; + cached.text = "Your Best | None yet"; + if (round > 0) + cached.text = "Your Best | Round " + round; + } + player.tws_lb_personal = cached; + return cached.text; +} + +lb_player_guid(player) +{ + guid = player.guid; + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return undefined; + guid = "" + guid; + if (guid.size == 0 || guid.size > 64) + return undefined; + return guid; +} + +// Search also matches names and GUID substrings. Only exact string identity +// with matching game/map evidence can supply this player's personal best. +lb_personal_fetch(key, guid) +{ + offset = 0; + total = -1; + highest = -1; + started = getTime(); + for (;;) + { + // Bound broad substring searches; incomplete searches are unavailable. + if (getTime() - started >= 15000) + return undefined; + page = lb_get("&map=" + lb_url_encode(key) + "&q=" + lb_url_encode(guid) + "&limit=3&offset=" + offset); + if (!isDefined(page) || !lb_page_valid(page, offset, 3)) + return undefined; + if (offset == 0) + { + total = page["total"]; + highest = page["summary"]["highestRound"]; + } + if (page["total"] != total || page["summary"]["highestRound"] != highest || + offset + page["entries"].size > total) + return undefined; + for (i = 0; i < page["entries"].size; i++) + { + entry = page["entries"][i]; + if (typeof(entry) != "array" || typeof(entry["guid"]) != "string") + return undefined; + if (entry["guid"] != guid) + continue; + if (!lb_uint(entry["highestRound"]) || entry["highestRound"] < 2 || + entry["highestRound"] > highest || typeof(entry["records"]) != "array") + return undefined; + evidence = entry["records"]["highestRound"]; + if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" || + typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"])) + return undefined; + if (evidence["game"] != level.tws_lb_game || evidence["mapKey"] != key || + evidence["value"] != entry["highestRound"]) + return undefined; + return entry["highestRound"]; + } + offset += page["entries"].size; + if (offset == total) + return 0; + if (page["entries"].size == 0 || offset > 1000000) + return undefined; + wait 0.05; + } } lb_refresh() @@ -242,6 +341,7 @@ lb_refresh() return undefined; snapshot = spawnStruct(); + snapshot.key = selection.key; snapshot.name = lb_display_text(selection.name, 64); snapshot.round = 0; snapshot.names = []; @@ -336,9 +436,18 @@ lb_t6_selection(selection, mode, location) return undefined; } -// All HTTP and parsing work belongs to the level worker, never the join thread. +// Map refreshes and personal lookups share one native request slot. lb_get(query) { + // A disconnect/replay can abandon a caller, but the collector still drains + // its native request. Bound queue wait and never overlap that request. + queued_at = getTime(); + while (isDefined(level.tws_lb_pending)) + { + if (getTime() - queued_at >= 15000) + return undefined; + wait 0.1; + } options = []; options["method"] = "GET"; headers = []; @@ -510,28 +619,9 @@ lb_uint(value) lb_holder_lines(names) { lines = []; - line = ""; - columns = 0; + // Each tied player gets a separate row in the full record announcement. for (i = 0; i < names.size; i++) - { - name = lb_holder_name(names[i]); - width = lb_holder_width(name); - if (line != "" && columns + width + 2 > 16) - { - lines[lines.size] = line; - line = ""; - columns = 0; - } - if (line != "") - { - line += ", "; - columns += 2; - } - line += name; - columns += width; - } - if (line != "") - lines[lines.size] = line; + lines[lines.size] = lb_holder_name(names[i]); return lines; } diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs index 2be6d0f..0298a7b 100644 --- a/tools/check_hud.mjs +++ b/tools/check_hud.mjs @@ -132,7 +132,7 @@ function engine(game, count = 8, withHud = false) { c.hud_connect(); c.afk_hud_create(); }, addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; }, - help: () => c.show_help(), record: snapshot => c.show_record(snapshot), + help: () => c.show_help(), record: (snapshot, personal) => c.show_record(snapshot, personal), reminder: (snapshot, message) => c.show_record_reminder(snapshot, message), event: name => emit(self, name), end: name => emit(level, name), get state() { return self.tws_service; }, @@ -295,18 +295,18 @@ for (const game of ['t5', 't6']) { assert.equal(e.elements.length, count); assert.equal(e.live.length, 0); }); - check(`${game} help pauses record time and resumes the same page`, () => { + check(`${game} help pauses the complete record and resumes its remaining time`, () => { const e = engine(game); e.record(snapshot(['One', 'Two', 'Three', 'Four'])); - e.advance(15000); - assert.equal(e.state.record.page, 1); + e.advance(3000); + assert.equal(e.state.record.page, 0); const remaining = e.state.record.remaining; e.help(); e.advance(12000); assert.equal(e.state.help, undefined); - assert.equal(e.state.record.page, 1); + assert.equal(e.state.record.page, 0); assert.equal(e.state.record.remaining, remaining); - assert.equal(e.state.rows[0].text, 'Four'); + assert.deepEqual(Array.from(e.state.rows, row => row.text), ['One', 'Two', 'Three', 'Four']); assert.equal(e.live.length, 5); const resumedTexts = e.textCalls; e.c.service_render(e.state); @@ -344,7 +344,7 @@ for (const game of ['t5', 't6']) { assert.equal(request.kind, 'reminder'); assert.equal(request.pages, 1); assert.equal(request.remaining, 6000); - assert.equal(e.state.record_title.text, 'Map record | Round 82'); + assert.equal(e.state.record_title.text, 'Map Record | Round 82'); assert.equal(e.state.rows.length, 1); assert.equal(e.state.rows[0].text, '5 rounds to tie'); assert.equal(e.live.length, 2); @@ -416,16 +416,14 @@ for (const game of ['t5', 't6']) { e.record(snapshot(['One', 'Two', 'Three', 'Four'])); assert.equal(e.state.record.kind, 'record'); assert.equal(e.state.record.remaining, 12000); - assert.equal(e.state.record.pages, 2); + assert.equal(e.state.record.pages, 1); assert.equal(e.live.length, 5); assert(replaced.every(elem => elem.destroyCalls === 1)); e.advance(100); assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay'); assert.equal(e.state.record.remaining, 11900); e.advance(11900); - assert.equal(e.state.record.page, 1); - assert.equal(e.state.rows[0].text, 'Four'); - e.advance(12000); + assert.equal(e.state.record, undefined); assert.equal(e.live.length, 0); }); check(`${game} invalid reminders do not initialize a service or allocate elements`, () => { @@ -451,35 +449,67 @@ for (const game of ['t5', 't6']) { assert.equal(e.elements.length, 2); assert(e.elements.every(elem => elem.destroyCalls === 1)); }); - check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => { + check(`${game} all holders precede a separate personal heading without record pages`, () => { + const e = engine(game); + e.record(snapshot(['Player 1', 'Player 2', 'Player 3', 'Player 4']), 'Your Best | Round 15'); + assert.equal(e.state.record.pages, 1); + assert.equal(e.live.length, 6); + assertCounter(e, 1, 1); + assert.deepEqual(e.visible.map(x => x.text), [ + 'Map Record | Round 82', 'Player 1', 'Player 2', 'Player 3', 'Player 4', 'Your Best | Round 15', + ]); + const personal = e.state.personal_title, header = e.state.record_title; + assert.equal(personal.y, 150, 'leave a blank row after all four holders'); + assert.deepEqual(personal.color, header.color); + assert.equal(personal.fontScale, header.fontScale); + assert.notDeepEqual(personal.color, e.state.rows[0].color, 'personal best uses the heading style'); + e.advance(11000); + assert.equal(e.state.personal_title, personal); + assert.equal(e.visible.length, 6, 'all holders stay visible until dismissal'); + e.advance(1000); + assert.equal(e.live.length, 0); + e.record({ round: 0, lines: [] }, 'Your Best | None yet'); + assert.equal(e.state.personal_title.text, 'Your Best | None yet'); + assert.equal(e.state.personal_title.alpha, 1); + e.record(undefined, 'Your Best | Unavailable'); + assert.equal(e.state.personal_title.text, 'Your Best | Unavailable'); + assert.equal(e.state.personal_title.alpha, 1); + e.record(snapshot(['A', 'B', 'C']), 'Your Best | Round 10'); + e.advance(2000); e.help(); e.advance(12000); + assert.equal(e.state.record.remaining, 10000); + assert.equal(e.state.personal_title.text, 'Your Best | Round 10'); + e.record(snapshot(['A', 'B', 'C'])); + assert.equal(e.state.personal_title, undefined, 'legacy callers clear the personal heading'); + e.event('disconnect'); + assert.equal(e.live.length, 0); + }); + check(`${game} record allocation follows the full holder count and reuses unchanged layouts`, () => { const e = engine(game); e.record(undefined); assert.equal(e.state.record_title.alpha, 1); - assert.equal(e.state.record_title.text, 'Map record unavailable'); - assert.equal(e.live.length, 4); + assert.equal(e.state.record_title.text, 'Map Record unavailable'); + assert.equal(e.live.length, 1); assertCounter(e, 1, 1); const first = [...e.live]; e.record({ round: 0, lines: [] }); assert.equal(e.state.record_title.text, 'No recorded high round yet'); assert.deepEqual(e.live, first); + e.record(snapshot(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'])); + assert(first.every(elem => elem.destroyCalls === 1), 'holder count changes release the old view first'); + assert.equal(e.live.length, 9); assertCounter(e, 1, 1); - e.record(snapshot(['A', 'B', 'C', 'D'])); - assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first'); - assert.equal(e.state.record_title.text, 'Map record | Round 82'); - assert.equal(e.live.length, 5); - const paged = [...e.live]; - assertCounter(e, 1, 2); - e.advance(12000); - assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]); - assert.equal(e.state.rows[0].text, 'D'); - assertCounter(e, 2, 2); - assert.deepEqual(e.live, paged, 'page changes reuse the same allocation'); - e.record(snapshot(['Single page'])); - assert(paged.every(elem => elem.destroyCalls === 1)); + const full = [...e.live]; + e.advance(1000); + assert(e.state.rows.every(row => row.alpha === 1)); + e.record(snapshot(['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'])); + assert.deepEqual(e.live, full); + assert.deepEqual(Array.from(e.state.rows, row => row.text), ['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']); + e.record(snapshot(['Single holder'])); + assert(full.every(elem => elem.destroyCalls === 1)); assertCounter(e, 1, 1); - assert.equal(e.state.rows[0].text, 'Single page'); - assert.equal(e.live.length, 4); - assert.equal(e.peakLive, 5, 'new allocations never overlap the released view'); + assert.equal(e.state.rows[0].text, 'Single holder'); + assert.equal(e.live.length, 2); + assert.equal(e.peakLive, 9, 'new allocations never overlap the released view'); assertTextOnly(e); }); for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => { @@ -512,11 +542,11 @@ for (const game of ['t5', 't6']) { e.advance(11000); assert.equal(e.textCalls, calls); assert.equal(e.elements.length, count); - assert.equal(e.state.record_title.text, 'Map record | Round 82'); + assert.equal(e.state.record_title.text, 'Map Record | Round 82'); assert.equal(e.state.commands[3].command, '.join'); assert.match(e.c.help_description(e.state.commands[3]), /newcomer/); assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d '); - assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows'); + assert.equal(e.elements.length, 2, 'record allocation includes only the header and actual holders'); assertTextOnly(e); }); check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => { @@ -606,7 +636,7 @@ for (const game of ['t5', 't6']) { assert.equal(e.textCalls, calls); e.record(snapshot()); e.advance(1000); assert(rows.every(row => row.destroyCalls === 1)); - assert.equal(e.live.length, 4); + assert.equal(e.live.length, 2); e.help(); assert.equal(e.live.length, 19); assert(e.state.command_rows.every(row => !rows.includes(row))); @@ -677,7 +707,7 @@ check('T5 service transitions preserve actual core element identities, styles, d assert.equal(e.live.length, 12); assert.deepEqual(state(), initial); for (let i = 0; i < 4; i++) { - e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help(); + e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(3000); e.help(); assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion'); assert.equal(e.live.filter(elem => elem.archived === false).length, 19); e.c.toggle_core_hud(); @@ -688,7 +718,7 @@ check('T5 service transitions preserve actual core element identities, styles, d e.advance(12000); assert.equal(e.state.rows[0].text, 'Latest'); assert.equal(e.state.record.remaining, 12000); - assert.equal(e.live.length, 16); + assert.equal(e.live.length, 14); assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0)); e.c.toggle_core_hud(); assert.deepEqual(state(), initial); diff --git a/tools/check_leaderboard.mjs b/tools/check_leaderboard.mjs index ccafbd6..9bb41e1 100644 --- a/tools/check_leaderboard.mjs +++ b/tools/check_leaderboard.mjs @@ -34,8 +34,8 @@ function functions(source) { let body = protectedSource.slice(headers.lastIndex, end - 1); // Each GSC function has its own implicit locals, including recursion. const locals = new Set([...body.matchAll(/(? x[1])); - const coroutine = match[1] === 'lb_round_watcher'; - body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length') + const coroutine = ['lb_round_watcher', 'lb_get'].includes(match[1]); + body = body.replace(/player getGuid\(/g, 'player.getGuid(').replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length') .replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';'); if (coroutine) body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };'); @@ -127,7 +127,7 @@ function roundRuntime(game) { return { ...e, players, attempts, shown, records, notifications, endEvents, get now() { return e.now; }, readyPlayer(fields = {}) { - const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields }; + const player = { getGuid: () => undefined, sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields }; players.push(player); return player; }, @@ -188,7 +188,7 @@ for (const game of ['t5', 't6']) { }); check(`${game} common owns the record service window`, () => { const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common'; - assert(source.includes(`self ${commonPath}::show_record(snapshot);`)); + assert(source.includes(`self ${commonPath}::show_record(snapshot, personal);`)); assert(!source.includes('lb_show_hud')); assert(!source.includes('lb_hud_')); assert(!source.includes('tws_help_elems')); @@ -198,7 +198,7 @@ for (const game of ['t5', 't6']) { }); check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => { const calls = []; - c.self = { notify: (event) => calls.push(['notify', event]) }; + c.self = { getGuid: () => undefined, notify: (event) => calls.push(['notify', event]) }; c.recordService = (snapshot) => calls.push(['record', snapshot]); const request = all.get('lb_request') .replace(/\b(?:self|level) endon\([^;]*\);/g, '') @@ -406,6 +406,88 @@ for (const game of ['t5', 't6']) { assert.equal(c.lb_page_valid(value, 0, 1), false); } }); + check(`${game} HTTP callers serialize and native timeouts retain the slot until collection`, () => { + const e = engine(game), c = e.context; + const started = [], pending = []; + c.httpRequest = (url) => { started.push(url); return {}; }; + c.collect = (request, job) => pending.push(job); + c.jsonParse = JSON.parse; + vm.runInContext(e.all.get('lb_get') + .replace(/http::request|httpPost/g, 'httpRequest') + .replace(/level thread lb_collect\(/g, 'collect('), c); + const first = c.lb_get('&first'); + assert.equal(first.next().done, false); + const second = c.lb_get('&second'); + assert.equal(second.next().done, false); + assert.equal(started.length, 1); + e.setTime(e.now + 15000); + assert.equal(first.next().done, true); + assert.equal(second.next().done, true); + assert.equal(started.length, 1, 'timed-out queue wait must not start another native request'); + assert.equal(c.level.tws_lb_pending, pending[0]); + const third = c.lb_get('&third'); + assert.equal(third.next().done, false); + Object.assign(pending[0], { done: true, success: true, body: '{}' }); + c.level.tws_lb_pending = undefined; + assert.equal(third.next().done, false); + assert.equal(started.length, 2); + Object.assign(pending[1], { done: true, success: true, body: '{"ok":true}' }); + c.level.tws_lb_pending = undefined; + const result = third.next(); + assert.equal(result.done, true); + assert.equal(result.value.ok, true); + }); + check(`${game} personal best finds exact GUID below the top rank across search pages`, () => { + const guid = '00012345678901234567890'; + responses.push(page(option, [entry(option, 'prefix' + guid), entry(option, 'other', guid), entry(option, 'third')], 4), + page(option, [entry(option, guid, 'Me', 27)], 4, 3)); + assert.equal(c.lb_personal_fetch(option.key, guid), 27); + assert(queries.at(-1).includes(`q=${guid}&limit=3&offset=3`)); + assert(queries.at(-1).includes(`map=${encodeURIComponent(option.key)}`)); + }); + check(`${game} personal absence requires a complete search, failures stay unavailable`, () => { + responses.push(page(option, [entry(option, 'not-me', 'me')], 1)); + assert.equal(c.lb_personal_fetch(option.key, 'me'), 0); + responses.push(page(option, [], 0)); + assert.equal(c.lb_personal_fetch(option.key, 'me'), 0); + for (const response of [undefined, { ok: false }, page(option, [], 1), + page(option, [entry(option, 'me')], 0), + page(option, [{ ...entry(option, 'me'), guid: 123 }], 1), + page(option, [entry({ ...option, key: 'wrong' }, 'me')], 1), + page(option, [entry({ ...option, game: 'wrong' }, 'me')], 1), + page(option, [{ ...entry(option, 'me'), highestRound: 27 }], 1)]) { + responses.push(response); + assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined); + } + responses.push(page(option, [entry(option, 'a'), entry(option, 'b'), entry(option, 'c')], 4), + page(option, [entry(option, 'me')], 5, 3)); + assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined); + }); + check(`${game} personal identity, per-player caching, expiry and unavailable retries`, () => { + const player = { guid: '00012345678901234567890', getGuid() { return this.guid; } }; + const other = { guid: 'other', getGuid() { return this.guid; } }; + const snapshot = { key: option.key, round: 52 }; + assert.equal(c.lb_player_guid(player), player.guid); + player.guid = 123; + assert.equal(c.lb_player_guid(player), '123'); + player.guid = '00012345678901234567890'; + responses.push(page(option, [entry(option, player.guid, 'Me', 27)], 1)); + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27'); + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27'); + responses.push(page(option, [], 0)); + assert.equal(c.lb_personal_record(other, snapshot), 'Your Best | None yet'); + player.tws_lb_personal.saved_at -= 300000; + responses.push(undefined); + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable'); + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable'); + player.tws_lb_personal.saved_at -= 30000; + responses.push(page(option, [entry(option, player.guid, 'Me', 30)], 1)); + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 30'); + assert.equal(c.lb_personal_record(player, undefined), 'Your Best | Unavailable'); + assert.equal(c.lb_personal_record(player, { round: 0 }), 'Your Best | None yet'); + player.guid = undefined; + assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable'); + }); check(`${game} one holder`, () => { responses.push(discovery(option), page(option, [entry(option, '000123', 'Alice')], 1)); const result = c.lb_refresh(); @@ -455,13 +537,13 @@ for (const game of ['t5', 't6']) { assert.equal(c.lb_refresh(), undefined); c.level.tws_lb_override = ''; }); - check(`${game} all holders fit bounded service rows`, () => { + check(`${game} each holder gets a separate bounded service row`, () => { const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`); const lines = Array.from(c.lb_holder_lines(names)); assert(lines.every((line) => c.lb_holder_width(line) <= 16)); assert.deepEqual(lines.join(', ').split(', '), names); assert.deepEqual(Array.from(c.lb_holder_lines([])), []); - assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']); + assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex', 'Sam', 'Jamie']); }); check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => { const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5), @@ -514,7 +596,7 @@ check('paired data helpers stay identical', () => { const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8')); const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8')); for (const [name, fn] of t5) { - if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) { + if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready', 'lb_player_guid'].includes(name)) { const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::'); assert.equal(normalized, fn, name); } diff --git a/versions.json b/versions.json index 480f9dc..f7cb4bb 100644 --- a/versions.json +++ b/versions.json @@ -1,18 +1,18 @@ { "tws_t5_afk.gsc": "2.1.0", "tws_t5_bank.gsc": "2.0.3", - "tws_t5_common.gsc": "1.5.0", + "tws_t5_common.gsc": "1.6.1", "tws_t5_hud.gsc": "1.4.2", "tws_t5_join.gsc": "1.0.0", - "tws_t5_leaderboard.gsc": "1.2.0", + "tws_t5_leaderboard.gsc": "1.3.1", "tws_t5_lock.gsc": "1.1.0", "tws_t5_telemetry.gsc": "1.0.0", "tws_t6_afk.gsc": "2.1.0", "tws_t6_bank.gsc": "2.0.3", - "tws_t6_common.gsc": "1.5.0", + "tws_t6_common.gsc": "1.6.1", "tws_t6_hud.gsc": "1.4.2", "tws_t6_join.gsc": "1.0.1", - "tws_t6_leaderboard.gsc": "1.2.0", + "tws_t6_leaderboard.gsc": "1.3.1", "tws_t6_lock.gsc": "1.1.0", "tws_t6_telemetry.gsc": "1.0.0", "t5-plugin": "1.4.0",