diff --git a/README.md b/README.md index 582fd1c..0001ce1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Combined working repo for the TWS Plutonium Zombies GSC scripts across two games ## Layout - [t5/](t5/) — Plutonium **T5** (Call of Duty: Black Ops 1) Zombies scripts. Most-developed baseline. See [t5/README.md](t5/README.md) and [t5/NOTES.md](t5/NOTES.md). -- [t6/](t6/) — Plutonium **T6** (Call of Duty: Black Ops 2) Zombies scripts. Ported from T5; at parity on common, HUD, AFK, lock, telemetry and join. Only `db` and `bank` remain T5-only. See [t6/README.md](t6/README.md), [t6/NOTES.md](t6/NOTES.md), and [t6/tools/check.ps1](t6/tools/check.ps1). +- [t6/](t6/) — Plutonium **T6** (Call of Duty: Black Ops 2) Zombies scripts. Ported from T5; at parity on common, HUD, AFK, lock, telemetry, join and leaderboard. Only `db` and `bank` remain T5-only. See [t6/README.md](t6/README.md), [t6/NOTES.md](t6/NOTES.md), and [t6/tools/check.ps1](t6/tools/check.ps1). ## Parity @@ -21,11 +21,73 @@ Modules currently in each tree: | lock | `t5/tws_lock.gsc` | `t6/tws_t6_lock.gsc` | | telemetry | `t5/tws_telemetry.gsc` | `t6/tws_t6_telemetry.gsc` — queued `game_start` / `heartbeat` / `round_change` / `game_end` matching T5 | | join | `t5/tws_join.gsc` | `t6/tws_t6_join.gsc` | +| leaderboard | `t5/tws_leaderboard.gsc` | `t6/tws_t6_leaderboard.gsc` | | db | `t5/tws_db.gsc` | *(not ported)* | | bank | `t5/tws_bank.gsc` | *(not ported)* | `db` and `bank` are T5-only for now. +## Map record announcements + +Install `tws_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6, +**along with the updated common module** for that game. +The new module privately announces the current map's fleet-wide highest recorded +round and every tied holder once per connection, including spectator newcomers. +It reads Electric Cherry's public `/api/leaderboard` endpoint without a token; +only completed matches contribute. Ties may come from the same run or separate +runs. Names are the API's latest player names, with players identified by GUID. + +Both versions default to `https://ec.thewrightserver.net/api/leaderboard`. +T5 discovers the map by engine identifier. T6 selects the exact variant using +the active mode/location, so Town Survival, TranZit and Town Grief stay separate. +See the game READMEs for plugin requirements and installation paths. + +| Dvar | Default | Purpose | +| --- | --- | --- | +| `tws_leaderboard_enabled` | enabled | `0` disables announcements and requests | +| `tws_leaderboard_url` | endpoint above | Full HTTPS API endpoint, without query parameters | +| `tws_leaderboard_map_key` | automatic | Exact, decoded API map key when automatic selection is unavailable | + +For example, `set tws_leaderboard_map_key "t6:zm_transit:town"` explicitly +selects Town Survival. Normally this setting is unnecessary. An override must +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 in the same HUD area as `.help`, +below the health bar, with a heading such as `Record: Round 23` and holder names +underneath. Each page shows up to three holder lines for 12 seconds. Opening +`.help` hides the record and pauses its display time until help closes. +All holders are included; colour/control codes are removed, and unusually long +names are shortened to keep messages readable. HUD elements are removed when +the announcement finishes, the player disconnects, or the game ends. + +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 +15 minutes. An empty selection says “No recorded high round yet”; unavailable, +malformed or ambiguous results are logged and skipped when no usable cache exists. +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. + +Requests have a 15-second GSC wait limit. The plugins cannot cancel a native +request, so a stalled request prevents new leaderboard requests until it finishes; +player threads never wait directly on HTTP. 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; the plugins do not expose HTTP status or response headers. + +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 +mid-announcement and confirm messages stop. Verify existing HUD, `.help`, `.join` +and telemetry still work. Open `.help` during a record page and check that the +record resumes afterwards without overlapping it. Runtime linking and client +readiness require Plutonium. + ## Validating T6 ```powershell diff --git a/t5/NOTES.md b/t5/NOTES.md index cfc14f7..12ef52b 100644 --- a/t5/NOTES.md +++ b/t5/NOTES.md @@ -13,10 +13,16 @@ Copy all `tws_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server ## Platform quirks (T5 SP engine, Plutonium) +- **Initialize locals before loops when reading them afterwards.** T5 rejected + leaderboard's `chosen` variable at load time when its only assignment was + inside the map-search loop, even though its later use was guarded by the + match count. It now starts as `undefined` before that loop in both versions. + The JavaScript fixture harness and T6 compiler did not catch this T5 error. - **One `level waittill("connected")` only.** `tws_common` owns it. A second listener breaks the T5 SP connect handshake. Per-player work goes - through `tws_common::register_spawn_handler` and gets dispatched after - `spawned_player`. + through `tws_common::register_spawn_handler` after `spawned_player`, or + `register_connect_handler` for spectator-safe work after the dispatcher yields. + Leaderboard uses the latter; its level worker owns all HTTP work. - **Chat uses `level waittill("say", ...)`**, not `callbackPlayerSay`. T5 SP dedicated disables the sound subsystem, cascading through the say-callback dispatch — `callbackPlayerSay` never fires. @@ -84,3 +90,4 @@ Copy all `tws_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server | `tws_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` | | `tws_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` | | `tws_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` | +| `tws_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` | diff --git a/t5/README.md b/t5/README.md index 83f6c6d..e00ea24 100644 --- a/t5/README.md +++ b/t5/README.md @@ -1,7 +1,7 @@ # tws-t5-aio -Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry for Plutonium T5 -(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files, +Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5 +(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files, each installable / removable in isolation (with `tws_common` as the shared hub). @@ -11,7 +11,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | File | Role | |---|---| -| [tws_common.gsc](tws_common.gsc) | Shared config, HUD helpers, `.help`, spawn-handler registry, single `connected` listener + dispatcher | +| [tws_common.gsc](tws_common.gsc) | Shared config, HUD helpers, `.help`, connect/spawn registries, single `connected` listener + dispatcher | | [tws_db.gsc](tws_db.gsc) | MariaDB pool config + reachability probe | | [tws_hud.gsc](tws_hud.gsc) | Bottom-left HUD stack: timers, zombie count, health bar | | [tws_bank.gsc](tws_bank.gsc) | Persistent per-player bank + console commands | @@ -19,6 +19,22 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | [tws_join.gsc](tws_join.gsc) | `.join` lets newcomers spawn before the current round ends | | [tws_lock.gsc](tws_lock.gsc) | Lobby `g_password` lock at a milestone round | | [tws_telemetry.gsc](tws_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | +| [tws_leaderboard.gsc](tws_leaderboard.gsc) | Private map high-round record and all tied holders on connection | + +## Map record announcements + +Copy `tws_leaderboard.gsc` and the updated `tws_common.gsc` to +`storage/t5/scripts/sp/`, then restart the map. 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; +the enable dvar does not bypass native linking dependencies. + +Records are read from the public Electric Cherry API with no credentials and +work independently of telemetry, HUD and the bank. Spectator newcomers receive +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. ## Player commands @@ -58,12 +74,14 @@ graph BT bank[tws_bank] lock[tws_lock] telemetry[tws_telemetry] + leaderboard[tws_leaderboard] db --> common hud --> common afk --> common join --> common telemetry --> common + leaderboard --> common bank -. db_probe_done .-> db lock -. hud_round_changed
hud_game_over .-> hud @@ -73,7 +91,7 @@ graph BT - All other cross-module coupling flows through level notifies (`db_probe_done`, `hud_round_changed`, `hud_match_started`, `hud_game_over`) or through spawn handlers registered with - `tws_common::register_spawn_handler`. + `tws_common::register_spawn_handler` / `register_connect_handler`. - Any file except `tws_common` can be removed; the rest still load. `tws_afk` has no downstream consumers — pull it independently. `tws_bank` needs `tws_db`; `tws_lock` needs `tws_hud`. @@ -99,6 +117,7 @@ connect listener anywhere else crashes the T5 SP handshake. - Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`, `hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold` - Spawn-handler registry +- Connection-handler registry for optional modules that serve spectator newcomers - `.help` listener and help registry populated by installed command modules - Connect listener + per-player dispatch: iterates `level.tws_spawn_handlers` after `spawned_player` and threads each on @@ -110,6 +129,7 @@ connect listener anywhere else crashes the T5 SP handshake. | `common_config()` | Populate every `level.hud_*` (idempotent) | | `ensure_config()` | No-op if config already set; called by every module's `init` | | `register_spawn_handler(fn)` | Appends `::fn` to `level.tws_spawn_handlers` | +| `register_connect_handler(fn)` | Threads `::fn` after connection dispatch yields, without waiting for playable spawn | | `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` | | `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline | | `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element | diff --git a/t5/tws_common.gsc b/t5/tws_common.gsc index 6a70d7f..c936d23 100644 --- a/t5/tws_common.gsc +++ b/t5/tws_common.gsc @@ -1,4 +1,4 @@ -/* tws_common.gsc — shared config, HUD helpers, spawn/help registries, connect dispatcher. */ +/* tws_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */ #include maps\_utility; #include common_scripts\utility; @@ -74,6 +74,15 @@ register_spawn_handler(fn) level.tws_spawn_handlers[level.tws_spawn_handlers.size] = fn; } +// Connection handlers also serve newcomers still waiting in spectator mode. +register_connect_handler(fn) +{ + if (!isDefined(level.tws_connect_handlers)) + level.tws_connect_handlers = []; + + level.tws_connect_handlers[level.tws_connect_handlers.size] = fn; +} + // Modules register their own entries so help only lists installed commands. register_help_command(command, description) { @@ -159,9 +168,23 @@ on_player_connect_dispatch() { level waittill("connected", player); player thread on_player_spawned_dispatch(); + player thread on_player_connected_dispatch(); } } +on_player_connected_dispatch() +{ + self endon("disconnect"); + // Leave the connection handshake before invoking optional modules. + wait 0.05; + + if (!isDefined(level.tws_connect_handlers)) + return; + + for (i = 0; i < level.tws_connect_handlers.size; i++) + self thread [[ level.tws_connect_handlers[i] ]](); +} + // handlers own their own self endon("disconnect") on_player_spawned_dispatch() { diff --git a/t5/tws_leaderboard.gsc b/t5/tws_leaderboard.gsc new file mode 100644 index 0000000..56c7c61 --- /dev/null +++ b/t5/tws_leaderboard.gsc @@ -0,0 +1,744 @@ +/* tws_leaderboard.gsc - private map record announcement, once per connection. */ + +#include maps\_utility; +#include common_scripts\utility; + +init() +{ + if (GetDvar(#"zombiemode") != "1") + return; + if (GetDvar("tws_leaderboard_enabled") == "0") + return; + + level.tws_lb_game = "t5"; + level.tws_lb_url = GetDvar("tws_leaderboard_url"); + if (level.tws_lb_url == "") + level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard"; + if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://") + { + lb_log("Disabled: tws_leaderboard_url must use HTTPS"); + return; + } + + level.tws_lb_override = GetDvar("tws_leaderboard_map_key"); + level.tws_lb_pending = undefined; + level.tws_lb_cache = undefined; + level.tws_lb_attempt = 0; + scripts\sp\tws_common::register_connect_handler(::lb_welcome); + level thread lb_worker(); +} + +lb_worker() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + // The same byte table supports UTF-8 validation and URL percent encoding. + level.tws_lb_bytes = []; + for (i = 1; i < 256; i++) + level.tws_lb_bytes[formatString("%c", i)] = i; + wait 1; + + for (;;) + { + // A timed-out native request cannot be cancelled. Never overlap it. + while (isDefined(level.tws_lb_pending)) + wait 1; + + snapshot = lb_refresh(); + level.tws_lb_attempt++; + if (isDefined(snapshot)) + { + snapshot.saved_at = getTime(); + level.tws_lb_cache = snapshot; + wait 300; + } + else + { + lb_log("Refresh failed; retaining the last complete record, if available"); + wait 30; + } + } +} + +lb_welcome() +{ + self endon("disconnect"); + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + if (isDefined(self.tws_lb_welcomed)) + return; + self.tws_lb_welcomed = true; + + // Spectator readiness matters too: do not wait for a playable spawn. + while (!isDefined(self.sessionstate) || + (self.sessionstate != "playing" && self.sessionstate != "spectator")) + wait 0.25; + while (isDefined(self.hasSpawned) && !self.hasSpawned) + wait 0.25; + wait 5; + + while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) + wait 0.25; + + if (!isDefined(level.tws_lb_cache)) + return; + snapshot = level.tws_lb_cache; + // Do not advertise an indefinitely stale record during an outage. + if (getTime() - snapshot.saved_at > 900000) + return; + + self lb_show_hud(snapshot); +} + +lb_show_hud(snapshot) +{ + self endon("disconnect"); + self endon("tws_lb_hud_done"); + + scripts\sp\tws_common::ensure_config(); + // Match .help's position, typography and row spacing below the health bar. + y = level.hud_row_bar + level.hud_bar_height + 20; + elems = []; + title = scripts\sp\tws_common::make_text_elem( + self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk); + title.alpha = 0; + if (snapshot.round == 0) + title SetText("No recorded high round yet"); + else + title SetText("Record: Round " + snapshot.round); + elems[elems.size] = title; + + for (i = 0; i < 3; i++) + { + row = scripts\sp\tws_common::make_text_elem( + self, level.hud_x, y + 22 + i * 18, + "left", level.hud_font_small, level.hud_c_count); + row.alpha = 0; + elems[elems.size] = row; + } + + // Cleanup runs independently of the welcome thread's endon handlers. + self thread scripts\sp\tws_common::destroy_on_disconnect(elems); + self thread lb_hud_end_game(elems, "end_game"); + self thread lb_hud_end_game(elems, "game_ended"); + self thread lb_hud_end_game(elems, "intermission"); + + offset = 0; + for (;;) + { + // Text changes only once per page, never during the visibility loop. + for (i = 0; i < 3; i++) + { + row = elems[i + 1]; + if (offset + i < snapshot.lines.size) + row SetText("^3Held by: ^7" + snapshot.lines[offset + i]); + else + row SetText(""); + } + + remaining = 12; + while (remaining > 0) + { + visible = !self lb_hud_help_visible(); + for (i = 0; i < elems.size; i++) + elems[i].alpha = visible; + wait 0.1; + // Pause the page while .help occupies the same area. + if (visible) + remaining -= 0.1; + } + offset += 3; + if (offset >= snapshot.lines.size) + break; + } + lb_hud_destroy(elems); + self notify("tws_lb_hud_done"); +} + +lb_hud_help_visible() +{ + return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 && + isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; +} + +lb_hud_end_game(elems, event) +{ + self endon("disconnect"); + self endon("tws_lb_hud_done"); + level waittill(event); + lb_hud_destroy(elems); + self notify("tws_lb_hud_done"); +} + +lb_hud_destroy(elems) +{ + for (i = 0; i < elems.size; i++) + { + if (isDefined(elems[i])) + elems[i] Destroy(); + } +} + +lb_refresh() +{ + selection = lb_selection(); + if (!isDefined(selection)) + { + lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key"); + return undefined; + } + + // Map keys are discovered and checked against the API's available maps. + discovery = lb_get("&limit=1&offset=0"); + if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1)) + return undefined; + if (!lb_resolve_map(selection, discovery["maps"])) + return undefined; + + snapshot = spawnStruct(); + snapshot.name = lb_display_text(selection.name, 64); + snapshot.round = 0; + snapshot.names = []; + snapshot.guids = []; + snapshot.lines = []; + snapshot.total = -1; + snapshot.last_round = 2147483647; + + // No map option means no completed match has been recorded for this map. + if (!selection.found) + return snapshot; + + offset = 0; + for (;;) + { + page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset); + if (!isDefined(page) || !lb_page_valid(page, offset, 3)) + return undefined; + result = lb_take_page(snapshot, page, selection.key, offset); + if (result < 0) + { + lb_log("Invalid or changing leaderboard; discarding incomplete refresh"); + return undefined; + } + if (result == 1) + { + snapshot.lines = lb_holder_lines(snapshot.names); + return snapshot; + } + offset += page["entries"].size; + if (offset > 1000000) + { + lb_log("Leaderboard exceeds the API pagination limit"); + return undefined; + } + wait 0.05; + } +} + +lb_selection() +{ + selection = spawnStruct(); + selection.map = toLower(GetDvar("mapname")); + selection.name = selection.map; + selection.key = level.tws_lb_override; + selection.explicit = selection.key != ""; + return selection; +} + +// All HTTP and parsing work belongs to the level worker, never the join thread. +lb_get(query) +{ + options = []; + options["method"] = "GET"; + headers = []; + headers["Accept"] = "application/json"; + options["headers"] = headers; + url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query; + request = http::request(url, options); + if (!isDefined(request)) + { + lb_log("HTTP request could not be created"); + return undefined; + } + pending = spawnStruct(); + pending.done = false; + level.tws_lb_pending = pending; + level thread lb_collect(request, pending); + started = getTime(); + while (!pending.done && getTime() - started < 15000) + wait 0.1; + if (!pending.done) + { + lb_log("HTTP timed out; waiting for native completion before requesting again"); + return undefined; + } + if (!isDefined(pending.success) || !pending.success) + { + lb_log("HTTP transport failed"); + return undefined; + } + // Plugins expose neither HTTP status nor response headers. Reject error + // bodies by grammar and success-schema checks, not the transport flag. + if (!lb_json_valid(pending.body)) + { + lb_log("Rejected malformed, oversized or non-JSON HTTP response"); + return undefined; + } + return jsonParse(pending.body); +} + +lb_collect(request, pending) +{ + // Keep draining after game over; ending a GSC thread cannot cancel HTTP. + request waittill("done", body, success, error); + pending.body = body; + pending.success = success; + pending.done = true; + level.tws_lb_pending = undefined; +} + +lb_log(message) +{ + print("[tws_leaderboard] " + message); +} + +// Pure data helpers below are exercised by tools/check_leaderboard.mjs. +lb_resolve_map(selection, maps) +{ + selection.found = false; + matches = 0; + // T5 requires initialization before the loop, even with matches == 1 below. + chosen = undefined; + for (i = 0; i < maps.size; i++) + { + item = maps[i]; + if (typeof(item) != "array" || typeof(item["game"]) != "string" || + typeof(item["map"]) != "string" || typeof(item["key"]) != "string" || + typeof(item["name"]) != "string") + return false; + if (item["game"] != level.tws_lb_game || item["map"] != selection.map) + continue; + if (selection.key != "" && item["key"] != selection.key) + continue; + matches++; + chosen = item; + } + if (matches > 1 || (matches == 0 && selection.explicit)) + { + lb_log("Map key is absent, ambiguous or belongs to another map/game"); + return false; + } + if (matches == 1) + { + selection.key = chosen["key"]; + selection.name = chosen["name"]; + selection.found = true; + } + return true; +} + +lb_page_valid(page, offset, limit) +{ + if (typeof(page) != "array" || typeof(page["entries"]) != "array" || + typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array") + return false; + if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"])) + return false; + if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit) + return false; + return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) && + page["entries"].size <= limit; +} + +// -1 rejects the whole refresh, 0 requests another page, 1 is complete. +lb_take_page(snapshot, page, key, offset) +{ + entries = page["entries"]; + if (offset == 0) + { + snapshot.round = page["summary"]["highestRound"]; + snapshot.total = page["total"]; + } + if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"]) + return -1; + if (entries.size == 0) + { + if (offset == 0 && snapshot.total == 0 && snapshot.round == 0) + return 1; + return -1; + } + if (snapshot.round < 2 || offset + entries.size > snapshot.total) + return -1; + for (i = 0; i < entries.size; i++) + { + entry = entries[i]; + if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"])) + return -1; + value = entry["highestRound"]; + if (value > snapshot.last_round || value > snapshot.round) + return -1; + snapshot.last_round = value; + if (value < snapshot.round) + { + if (snapshot.names.size == 0) + return -1; + return 1; + } + if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" || + typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array") + return -1; + evidence = entry["records"]["highestRound"]; + if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" || + typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"])) + return -1; + if (evidence["game"] != level.tws_lb_game || + evidence["mapKey"] != key || evidence["value"] != snapshot.round) + return -1; + // GUIDs remain strings; equal names are distinct players. + guid = entry["guid"]; + if (!isDefined(snapshot.guids[guid])) + { + snapshot.guids[guid] = true; + name = lb_display_text(entry["name"], 64); + if (name == "") + name = lb_display_text(guid, 64); + snapshot.names[snapshot.names.size] = name; + } + } + if (offset + entries.size >= snapshot.total) + return 1; + return 0; +} + +lb_uint(value) +{ + return typeof(value) == "int" && value >= 0; +} + +lb_holder_lines(names) +{ + lines = []; + line = ""; + for (i = 0; i < names.size; i++) + { + if (line != "" && line.size + names[i].size + 2 > 100) + { + lines[lines.size] = line; + line = ""; + } + if (line != "") + line += ", "; + line += names[i]; + } + if (line != "") + lines[lines.size] = line; + return lines; +} + +lb_display_text(value, limit) +{ + output = ""; + for (i = 0; i < value.size; i++) + { + ch = getSubStr(value, i, i + 1); + code = level.tws_lb_bytes[ch]; + if (!isDefined(code) || code < 32 || code == 127) + continue; + if (ch == "^") + { + // Strip game colour escapes; never let a name inject formatting. + if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2))) + i++; + continue; + } + width = 1; + if (code >= 240) + width = 4; + else if (code >= 224) + width = 3; + else if (code >= 192) + width = 2; + if (output.size + width > limit) + return output + "..."; + output += getSubStr(value, i, i + width); + i += width - 1; + } + return output; +} + +lb_url_encode(value) +{ + hex = "0123456789ABCDEF"; + encoded = ""; + for (i = 0; i < value.size; i++) + { + ch = getSubStr(value, i, i + 1); + if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch)) + encoded += ch; + else + { + code = level.tws_lb_bytes[ch]; + high = int(code / 16); + low = code % 16; + encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1); + } + } + return encoded; +} + +lb_contains(chars, ch) +{ + if (ch == "") + return false; + for (j = 0; j < chars.size; j++) + { + if (getSubStr(chars, j, j + 1) == ch) + return true; + } + return false; +} + +// jsonParse raises a script error on malformed input. Validate the complete +// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies. +// Yield during scanning so network data cannot monopolize a server frame. +lb_json_valid(body) +{ + if (typeof(body) != "string" || body.size == 0 || body.size >= 20480) + return false; + if (!lb_utf8_valid(body)) + return false; + state = spawnStruct(); + state.body = body; + state.pos = 0; + state.yield_at = 256; + if (!lb_json_value(state, 0)) + return false; + lb_json_space(state); + return state.pos == body.size; +} + +lb_utf8_valid(body) +{ + for (i = 0; i < body.size; i++) + { + if (i % 256 == 0) + wait 0.05; + code = level.tws_lb_bytes[getSubStr(body, i, i + 1)]; + if (!isDefined(code)) + return false; + if (code < 128) + continue; + count = 0; + minimum = 128; + maximum = 191; + if (code >= 194 && code <= 223) + count = 1; + else if (code >= 224 && code <= 239) + { + count = 2; + if (code == 224) + minimum = 160; + if (code == 237) + maximum = 159; + } + else if (code >= 240 && code <= 244) + { + count = 3; + if (code == 240) + minimum = 144; + if (code == 244) + maximum = 143; + } + if (count == 0 || i + count >= body.size) + return false; + for (j = 1; j <= count; j++) + { + next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)]; + if (!isDefined(next) || next < minimum || next > maximum) + return false; + minimum = 128; + maximum = 191; + } + i += count; + } + return true; +} + +lb_json_char(state) +{ + if (state.pos >= state.yield_at) + { + wait 0.05; + state.yield_at = state.pos + 256; + } + if (state.pos >= state.body.size) + return ""; + return getSubStr(state.body, state.pos, state.pos + 1); +} + +lb_json_space(state) +{ + while (lb_contains(" \t\r\n", lb_json_char(state))) + state.pos++; +} + +lb_json_value(state, depth) +{ + if (depth > 16) + return false; + lb_json_space(state); + ch = lb_json_char(state); + if (ch == "\"") + return lb_json_string(state); + if (ch == "{" || ch == "[") + { + object = ch == "{"; + close = "]"; + if (object) + close = "}"; + state.pos++; + lb_json_space(state); + if (lb_json_char(state) == close) + { + state.pos++; + return true; + } + for (;;) + { + if (object) + { + if (!lb_json_string(state)) + return false; + lb_json_space(state); + if (lb_json_char(state) != ":") + return false; + state.pos++; + } + if (!lb_json_value(state, depth + 1)) + return false; + lb_json_space(state); + ch = lb_json_char(state); + state.pos++; + if (ch == close) + return true; + if (ch != ",") + return false; + lb_json_space(state); + } + } + literals = strTok("true,false,null", ","); + for (i = 0; i < literals.size; i++) + { + literal = literals[i]; + if (state.pos + literal.size <= state.body.size && + getSubStr(state.body, state.pos, state.pos + literal.size) == literal) + { + state.pos += literal.size; + return true; + } + } + return lb_json_number(state); +} + +lb_json_string(state) +{ + if (lb_json_char(state) != "\"") + return false; + state.pos++; + while (state.pos < state.body.size) + { + ch = lb_json_char(state); + state.pos++; + if (ch == "\"") + return true; + if (level.tws_lb_bytes[ch] < 32) + return false; + if (ch != "\\") + continue; + ch = lb_json_char(state); + state.pos++; + if (lb_contains("\"\\/bfnrt", ch)) + continue; + if (ch != "u") + return false; + code = lb_json_hex4(state); + // Embedded NUL does not round-trip through engine strings. + if (code <= 0 || (code >= 56320 && code <= 57343)) + return false; + if (code >= 55296 && code <= 56319) + { + if (state.pos + 2 > state.body.size || + getSubStr(state.body, state.pos, state.pos + 2) != "\\u") + return false; + state.pos += 2; + code = lb_json_hex4(state); + if (code < 56320 || code > 57343) + return false; + } + } + return false; +} + +lb_json_hex4(state) +{ + hex = "0123456789abcdef"; + result = 0; + for (i = 0; i < 4; i++) + { + ch = toLower(lb_json_char(state)); + if (!lb_contains(hex, ch)) + return -1; + for (j = 0; j < 16; j++) + { + if (getSubStr(hex, j, j + 1) == ch) + break; + } + result = result * 16 + j; + state.pos++; + } + return result; +} + +lb_json_number(state) +{ + start = state.pos; + if (lb_json_char(state) == "-") + state.pos++; + ch = lb_json_char(state); + if (ch == "0") + state.pos++; + else + { + if (!lb_contains("123456789", ch)) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + } + if (lb_json_char(state) == ".") + { + state.pos++; + if (!lb_contains("0123456789", lb_json_char(state))) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + } + if (lb_contains("eE", lb_json_char(state))) + { + state.pos++; + if (lb_contains("+-", lb_json_char(state))) + state.pos++; + exponent = state.pos; + if (!lb_contains("0123456789", lb_json_char(state))) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + // API counters/ratios never need huge exponents; prevent native overflow. + if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38) + return false; + } + return state.pos - start <= 32; +} diff --git a/t6/NOTES.md b/t6/NOTES.md index 3d1b75e..4cc28aa 100644 --- a/t6/NOTES.md +++ b/t6/NOTES.md @@ -39,10 +39,36 @@ releases them before the protection grace period. Restoration is deferred during death/laststand/intermission until active play. Other mods controlling the same invulnerability or weapon flags can still conflict and need live testing. -The four optional T5 dvars remain; no new configuration switches are introduced. +The existing optional T5 dvars remain. Leaderboard adds the three settings +documented in the [shared README](../README.md#map-record-announcements). `common_config()` and `ensure_config()` are original T5 in-memory HUD helpers, not an external configuration system. +## Leaderboard join announcements + +The module is `tws_t6_leaderboard.gsc` in T6 and `tws_leaderboard.gsc` in T5. +Common's existing +connection listener now also dispatches optional connection handlers after a +one-frame yield. Leaderboard waits for player readiness and uses a level-owned +HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep +`hasSpawned` false, so T6 readiness uses session state and the intro-screen flag; +T5 sets `hasSpawned` for both playable and spectator spawns. + +T6's active `scr_zm_ui_gametype` / `scr_zm_map_start_location` fields come from +[stock `_zm.gsc`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/zombies/_zm.gsc). +Mode/location combinations follow the +[T6 server configurations](https://github.com/xerxes-at/T6ServerConfigs/tree/master/localappdata/Plutonium/storage/t6/gamesettings) +and Electric Cherry's map catalogue. Unknown selections require an explicit API +key instead of falling back to another variant sharing the engine map. + +Validation for this change: 135 fixture/static checks pass using the actual +data-handling functions from both scripts with JavaScript engine-value shims. +All seven T6 scripts compile and assemble using a local Intel macOS driver built +from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was +not executed here. T5 compilation and live plugin/gameplay checks were not run; +gsc-tool has no T5 backend. The production API could not be reached from this +environment. See the shared README for the live validation cases. + ## Verified primary sources - [Plutonium loading guide](https://plutonium.pw/docs/modding/loading-mods/): T6 @@ -92,7 +118,7 @@ at link time. The other four TWS modules can run without telemetry. ## Checks -Local: `tools/check.ps1` compiles all six files for T6 PC and checks shared +Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared references and entry points. It requires PowerShell and gsc-tool. The `.help` change passed the static entry-point/reference checks, but compilation and live gameplay were not run. Compiler success does not certify runtime linking. diff --git a/t6/README.md b/t6/README.md index ed3b641..4a12d0c 100644 --- a/t6/README.md +++ b/t6/README.md @@ -5,21 +5,27 @@ just like T5. There is no separate TWS config file or database setup. | File | Role | Dependencies | | --- | --- | --- | -| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help` and first-spawn dispatcher | Stock T6 | +| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | Stock T6 | | `tws_t6_hud.gsc` | Match/round timers, name, zombie count and health bar | Common | | `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common | | `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) | 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, t6-gsc-utils | ## Install -1. Copy these six `tws_t6_*.gsc` files into `Plutonium/storage/t6/scripts/zm/`. -2. For telemetry, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1) - in `Plutonium/plugins/` and restart the server process. The other four scripts +1. Copy all seven `tws_t6_*.gsc` files into + `Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard. +2. For telemetry or leaderboard, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1) + in `Plutonium/plugins/` and restart the server process. The other five scripts do not require that plugin. 3. Restart the map to load script updates. +If upgrading from the earlier leaderboard filename, **remove +`tws_leaderboard.gsc` from the T6 server script directory** before installing +`tws_t6_leaderboard.gsc`, so both copies do not load. + If updating an existing deployment, **remove `tws_t6_db.gsc` and `tws_t6_bank.gsc` from the server script directory**. Copying the remaining files will not remove those old scripts. Remove any `exec server.example.cfg` added for @@ -29,7 +35,25 @@ Source `.gsc` files load directly under Plutonium's [documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/). Keep the filenames and `scripts/zm` location so shared references resolve. To omit telemetry, remove its script; disabling it at runtime does not remove -its plugin dependency during linking. +its plugin dependency during linking. The same rule applies to leaderboard. + +## Map record announcements + +`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials. +It works independently of telemetry and HUD, and privately announces records +once per connection, including newcomers waiting in spectator mode. + +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, +`zstandard` + `town` selects `t6:zm_transit:town`, while `zclassic` + `transit` +selects TranZit. Survival, Grief and Turned variants follow Electric Cherry's +catalogue. An unknown mode/location is logged instead of guessing from the engine +map. Use `tws_leaderboard_map_key` for a custom registered selection if needed. + +The plugin must expose `httpPost` (with the GET method override), `jsonParse`, +`typeof` and `formatString`. [Shared configuration, caching and validation](../README.md#map-record-announcements) +apply to both versions. All T6 script filenames use the `tws_t6_` prefix. ## Chat commands @@ -115,7 +139,7 @@ another mod installed one first. ## Validation -To compile all six scripts for T6 PC with gsc-tool 1.4.10: +To compile all seven scripts for T6 PC with gsc-tool: ```powershell .\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe diff --git a/t6/tools/check.ps1 b/t6/tools/check.ps1 index 67dd161..36eaecb 100644 --- a/t6/tools/check.ps1 +++ b/t6/tools/check.ps1 @@ -5,8 +5,9 @@ param( $ErrorActionPreference = 'Stop' $compiler = (Resolve-Path -LiteralPath $CompilerPath).Path $repo = Split-Path -Parent $PSScriptRoot -$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_t6_*.gsc' | Sort-Object Name) -if ($files.Count -ne 6) { throw 'Expected the six TWS modules: common, HUD, AFK, lock, telemetry, join.' } +$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name) +$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc') +if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' } $common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw $functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value }) foreach ($file in $files) { @@ -31,4 +32,4 @@ foreach ($file in $files) { } Write-Output "PASS $($file.Name)" } -Write-Output 'All six T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.' +Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.' diff --git a/t6/tws_t6_common.gsc b/t6/tws_t6_common.gsc index 162e58e..aa01812 100644 --- a/t6/tws_t6_common.gsc +++ b/t6/tws_t6_common.gsc @@ -1,4 +1,4 @@ -/* tws_t6_common.gsc — shared config, HUD helpers, help and spawn-handler registries. */ +/* tws_t6_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */ #include maps\mp\_utility; #include common_scripts\utility; @@ -76,6 +76,15 @@ register_spawn_handler(fn) level.tws_spawn_handlers[level.tws_spawn_handlers.size] = fn; } +// Connection handlers also serve newcomers still waiting in spectator mode. +register_connect_handler(fn) +{ + if (!isDefined(level.tws_connect_handlers)) + level.tws_connect_handlers = []; + + level.tws_connect_handlers[level.tws_connect_handlers.size] = fn; +} + // Modules register their own commands, regardless of init order. register_help_command(command, description) { @@ -160,9 +169,23 @@ on_player_connect_dispatch() { level waittill("connected", player); player thread on_player_spawned_dispatch(); + player thread on_player_connected_dispatch(); } } +on_player_connected_dispatch() +{ + self endon("disconnect"); + // Leave the connection handshake before invoking optional modules. + wait 0.05; + + if (!isDefined(level.tws_connect_handlers)) + return; + + for (i = 0; i < level.tws_connect_handlers.size; i++) + self thread [[ level.tws_connect_handlers[i] ]](); +} + // handlers own their own self endon("disconnect") on_player_spawned_dispatch() { diff --git a/t6/tws_t6_leaderboard.gsc b/t6/tws_t6_leaderboard.gsc new file mode 100644 index 0000000..488bf2a --- /dev/null +++ b/t6/tws_t6_leaderboard.gsc @@ -0,0 +1,793 @@ +/* tws_t6_leaderboard.gsc - private map record announcement, once per connection. */ + +#include maps\mp\_utility; +#include common_scripts\utility; + +main() +{ +} + +init() +{ + if (GetDvar("tws_leaderboard_enabled") == "0") + return; + + level.tws_lb_game = "t6"; + level.tws_lb_url = GetDvar("tws_leaderboard_url"); + if (level.tws_lb_url == "") + level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard"; + if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://") + { + lb_log("Disabled: tws_leaderboard_url must use HTTPS"); + return; + } + + level.tws_lb_override = GetDvar("tws_leaderboard_map_key"); + level.tws_lb_pending = undefined; + level.tws_lb_cache = undefined; + level.tws_lb_attempt = 0; + scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome); + level thread lb_worker(); +} + +lb_worker() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + // The same byte table supports UTF-8 validation and URL percent encoding. + level.tws_lb_bytes = []; + for (i = 1; i < 256; i++) + level.tws_lb_bytes[formatString("%c", i)] = i; + wait 1; + + for (;;) + { + // A timed-out native request cannot be cancelled. Never overlap it. + while (isDefined(level.tws_lb_pending)) + wait 1; + + snapshot = lb_refresh(); + level.tws_lb_attempt++; + if (isDefined(snapshot)) + { + snapshot.saved_at = getTime(); + level.tws_lb_cache = snapshot; + wait 300; + } + else + { + lb_log("Refresh failed; retaining the last complete record, if available"); + wait 30; + } + } +} + +lb_welcome() +{ + self endon("disconnect"); + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + + if (isDefined(self.tws_lb_welcomed)) + return; + self.tws_lb_welcomed = true; + + // Spectator readiness matters too: do not wait for a playable spawn. + while (!isDefined(self.sessionstate) || + (self.sessionstate != "playing" && self.sessionstate != "spectator")) + wait 0.25; + // T6 keeps hasSpawned false for newcomers in spectator mode. + while (isDefined(level.passed_introscreen) && !level.passed_introscreen) + wait 0.25; + wait 5; + + while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0) + wait 0.25; + + if (!isDefined(level.tws_lb_cache)) + return; + snapshot = level.tws_lb_cache; + // Do not advertise an indefinitely stale record during an outage. + if (getTime() - snapshot.saved_at > 900000) + return; + + self lb_show_hud(snapshot); +} + +lb_show_hud(snapshot) +{ + self endon("disconnect"); + self endon("tws_lb_hud_done"); + + scripts\zm\tws_t6_common::ensure_config(); + // Match .help's position, typography and row spacing below the health bar. + y = level.hud_row_bar + level.hud_bar_height + 20; + elems = []; + title = scripts\zm\tws_t6_common::make_text_elem( + self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk); + title.alpha = 0; + if (snapshot.round == 0) + title SetText("No recorded high round yet"); + else + title SetText("Record: Round " + snapshot.round); + elems[elems.size] = title; + + for (i = 0; i < 3; i++) + { + row = scripts\zm\tws_t6_common::make_text_elem( + self, level.hud_x, y + 22 + i * 18, + "left", level.hud_font_small, level.hud_c_count); + row.alpha = 0; + elems[elems.size] = row; + } + + // Cleanup runs independently of the welcome thread's endon handlers. + self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); + self thread lb_hud_end_game(elems, "end_game"); + self thread lb_hud_end_game(elems, "game_ended"); + self thread lb_hud_end_game(elems, "intermission"); + + offset = 0; + for (;;) + { + // Text changes only once per page, never during the visibility loop. + for (i = 0; i < 3; i++) + { + row = elems[i + 1]; + if (offset + i < snapshot.lines.size) + row SetText("^3Held by: ^7" + snapshot.lines[offset + i]); + else + row SetText(""); + } + + remaining = 12; + while (remaining > 0) + { + visible = !self lb_hud_help_visible(); + for (i = 0; i < elems.size; i++) + elems[i].alpha = visible; + wait 0.1; + // Pause the page while .help occupies the same area. + if (visible) + remaining -= 0.1; + } + offset += 3; + if (offset >= snapshot.lines.size) + break; + } + lb_hud_destroy(elems); + self notify("tws_lb_hud_done"); +} + +lb_hud_help_visible() +{ + return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 && + isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; +} + +lb_hud_end_game(elems, event) +{ + self endon("disconnect"); + self endon("tws_lb_hud_done"); + level waittill(event); + lb_hud_destroy(elems); + self notify("tws_lb_hud_done"); +} + +lb_hud_destroy(elems) +{ + for (i = 0; i < elems.size; i++) + { + if (isDefined(elems[i])) + elems[i] Destroy(); + } +} + +lb_refresh() +{ + selection = lb_selection(); + if (!isDefined(selection)) + { + lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key"); + return undefined; + } + + // Map keys are discovered and checked against the API's available maps. + discovery = lb_get("&limit=1&offset=0"); + if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1)) + return undefined; + if (!lb_resolve_map(selection, discovery["maps"])) + return undefined; + + snapshot = spawnStruct(); + snapshot.name = lb_display_text(selection.name, 64); + snapshot.round = 0; + snapshot.names = []; + snapshot.guids = []; + snapshot.lines = []; + snapshot.total = -1; + snapshot.last_round = 2147483647; + + // No map option means no completed match has been recorded for this map. + if (!selection.found) + return snapshot; + + offset = 0; + for (;;) + { + page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset); + if (!isDefined(page) || !lb_page_valid(page, offset, 3)) + return undefined; + result = lb_take_page(snapshot, page, selection.key, offset); + if (result < 0) + { + lb_log("Invalid or changing leaderboard; discarding incomplete refresh"); + return undefined; + } + if (result == 1) + { + snapshot.lines = lb_holder_lines(snapshot.names); + return snapshot; + } + offset += page["entries"].size; + if (offset > 1000000) + { + lb_log("Leaderboard exceeds the API pagination limit"); + return undefined; + } + wait 0.05; + } +} + +lb_selection() +{ + selection = spawnStruct(); + selection.map = toLower(GetDvar("mapname")); + selection.name = selection.map; + selection.key = level.tws_lb_override; + selection.explicit = selection.key != ""; + if (selection.explicit) + return selection; + + mode = toLower(GetDvar("ui_gametype")); + location = toLower(GetDvar("ui_zm_mapstartlocation")); + if (isDefined(level.scr_zm_ui_gametype)) + mode = toLower(level.scr_zm_ui_gametype); + if (isDefined(level.scr_zm_map_start_location)) + location = toLower(level.scr_zm_map_start_location); + if ((location == "" || location == "default") && isDefined(level.default_start_location)) + location = toLower(level.default_start_location); + return lb_t6_selection(selection, mode, location); +} + +// Runtime mode/location -> Electric Cherry's registered variant catalogue. +// Never select the sole available zm_transit option by engine map alone. +lb_t6_selection(selection, mode, location) +{ + rows = []; + rows[0] = "zm_transit|zclassic|transit|transit|TranZit"; + rows[1] = "zm_transit|zstandard|town|town|Town Survival"; + rows[2] = "zm_transit|zstandard|farm|farm|Farm Survival"; + rows[3] = "zm_transit|zstandard|transit|busdepot|Bus Depot Survival"; + rows[4] = "zm_nuked|zstandard|nuked|nuked|Nuketown"; + rows[5] = "zm_highrise|zclassic|rooftop|highrise|Die Rise"; + rows[6] = "zm_prison|zclassic|prison|prison|Mob of the Dead"; + rows[7] = "zm_buried|zclassic|processing|buried|Buried"; + rows[8] = "zm_tomb|zclassic|tomb|tomb|Origins"; + rows[9] = "zm_transit|zgrief|town|towngrief|Town Grief"; + rows[10] = "zm_transit|zgrief|farm|farmgrief|Farm Grief"; + rows[11] = "zm_transit|zgrief|transit|busdepotgrief|Bus Depot Grief"; + rows[12] = "zm_prison|zgrief|cellblock|cellblock|Cell Block Grief"; + rows[13] = "zm_buried|zgrief|street|buriedgrief|Buried Grief"; + rows[14] = "zm_transit_dr|zcleansed|diner|diner|Diner Turned"; + rows[15] = "zm_buried|zcleansed|street|buriedturned|Buried Turned"; + for (i = 0; i < rows.size; i++) + { + parts = strTok(rows[i], "|"); + if (selection.map == parts[0] && mode == parts[1] && location == parts[2]) + { + selection.key = "t6:" + selection.map + ":" + parts[3]; + selection.name = parts[4]; + return selection; + } + } + return undefined; +} + +// All HTTP and parsing work belongs to the level worker, never the join thread. +lb_get(query) +{ + options = []; + options["method"] = "GET"; + headers = []; + headers["Accept"] = "application/json"; + options["headers"] = headers; + url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query; + // v3.1.1 accepts the GET method override despite the alias name. + request = httpPost(url, options); + if (!isDefined(request)) + { + lb_log("HTTP request could not be created"); + return undefined; + } + pending = spawnStruct(); + pending.done = false; + level.tws_lb_pending = pending; + level thread lb_collect(request, pending); + started = getTime(); + while (!pending.done && getTime() - started < 15000) + wait 0.1; + if (!pending.done) + { + lb_log("HTTP timed out; waiting for native completion before requesting again"); + return undefined; + } + if (!isDefined(pending.success) || !pending.success) + { + lb_log("HTTP transport failed"); + return undefined; + } + // Plugins expose neither HTTP status nor response headers. Reject error + // bodies by grammar and success-schema checks, not the transport flag. + if (!lb_json_valid(pending.body)) + { + lb_log("Rejected malformed, oversized or non-JSON HTTP response"); + return undefined; + } + return jsonParse(pending.body); +} + +lb_collect(request, pending) +{ + // Keep draining after game over; ending a GSC thread cannot cancel HTTP. + request waittill("done", body, success, error); + pending.body = body; + pending.success = success; + pending.done = true; + level.tws_lb_pending = undefined; +} + +lb_log(message) +{ + println("[tws_t6_leaderboard] " + message); +} + +// Pure data helpers below are exercised by tools/check_leaderboard.mjs. +lb_resolve_map(selection, maps) +{ + selection.found = false; + matches = 0; + // T5 requires initialization before the loop, even with matches == 1 below. + chosen = undefined; + for (i = 0; i < maps.size; i++) + { + item = maps[i]; + if (typeof(item) != "array" || typeof(item["game"]) != "string" || + typeof(item["map"]) != "string" || typeof(item["key"]) != "string" || + typeof(item["name"]) != "string") + return false; + if (item["game"] != level.tws_lb_game || item["map"] != selection.map) + continue; + if (selection.key != "" && item["key"] != selection.key) + continue; + matches++; + chosen = item; + } + if (matches > 1 || (matches == 0 && selection.explicit)) + { + lb_log("Map key is absent, ambiguous or belongs to another map/game"); + return false; + } + if (matches == 1) + { + selection.key = chosen["key"]; + selection.name = chosen["name"]; + selection.found = true; + } + return true; +} + +lb_page_valid(page, offset, limit) +{ + if (typeof(page) != "array" || typeof(page["entries"]) != "array" || + typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array") + return false; + if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"])) + return false; + if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit) + return false; + return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) && + page["entries"].size <= limit; +} + +// -1 rejects the whole refresh, 0 requests another page, 1 is complete. +lb_take_page(snapshot, page, key, offset) +{ + entries = page["entries"]; + if (offset == 0) + { + snapshot.round = page["summary"]["highestRound"]; + snapshot.total = page["total"]; + } + if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"]) + return -1; + if (entries.size == 0) + { + if (offset == 0 && snapshot.total == 0 && snapshot.round == 0) + return 1; + return -1; + } + if (snapshot.round < 2 || offset + entries.size > snapshot.total) + return -1; + for (i = 0; i < entries.size; i++) + { + entry = entries[i]; + if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"])) + return -1; + value = entry["highestRound"]; + if (value > snapshot.last_round || value > snapshot.round) + return -1; + snapshot.last_round = value; + if (value < snapshot.round) + { + if (snapshot.names.size == 0) + return -1; + return 1; + } + if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" || + typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array") + return -1; + evidence = entry["records"]["highestRound"]; + if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" || + typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"])) + return -1; + if (evidence["game"] != level.tws_lb_game || + evidence["mapKey"] != key || evidence["value"] != snapshot.round) + return -1; + // GUIDs remain strings; equal names are distinct players. + guid = entry["guid"]; + if (!isDefined(snapshot.guids[guid])) + { + snapshot.guids[guid] = true; + name = lb_display_text(entry["name"], 64); + if (name == "") + name = lb_display_text(guid, 64); + snapshot.names[snapshot.names.size] = name; + } + } + if (offset + entries.size >= snapshot.total) + return 1; + return 0; +} + +lb_uint(value) +{ + return typeof(value) == "int" && value >= 0; +} + +lb_holder_lines(names) +{ + lines = []; + line = ""; + for (i = 0; i < names.size; i++) + { + if (line != "" && line.size + names[i].size + 2 > 100) + { + lines[lines.size] = line; + line = ""; + } + if (line != "") + line += ", "; + line += names[i]; + } + if (line != "") + lines[lines.size] = line; + return lines; +} + +lb_display_text(value, limit) +{ + output = ""; + for (i = 0; i < value.size; i++) + { + ch = getSubStr(value, i, i + 1); + code = level.tws_lb_bytes[ch]; + if (!isDefined(code) || code < 32 || code == 127) + continue; + if (ch == "^") + { + // Strip game colour escapes; never let a name inject formatting. + if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2))) + i++; + continue; + } + width = 1; + if (code >= 240) + width = 4; + else if (code >= 224) + width = 3; + else if (code >= 192) + width = 2; + if (output.size + width > limit) + return output + "..."; + output += getSubStr(value, i, i + width); + i += width - 1; + } + return output; +} + +lb_url_encode(value) +{ + hex = "0123456789ABCDEF"; + encoded = ""; + for (i = 0; i < value.size; i++) + { + ch = getSubStr(value, i, i + 1); + if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch)) + encoded += ch; + else + { + code = level.tws_lb_bytes[ch]; + high = int(code / 16); + low = code % 16; + encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1); + } + } + return encoded; +} + +lb_contains(chars, ch) +{ + if (ch == "") + return false; + for (j = 0; j < chars.size; j++) + { + if (getSubStr(chars, j, j + 1) == ch) + return true; + } + return false; +} + +// jsonParse raises a script error on malformed input. Validate the complete +// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies. +// Yield during scanning so network data cannot monopolize a server frame. +lb_json_valid(body) +{ + if (typeof(body) != "string" || body.size == 0 || body.size >= 20480) + return false; + if (!lb_utf8_valid(body)) + return false; + state = spawnStruct(); + state.body = body; + state.pos = 0; + state.yield_at = 256; + if (!lb_json_value(state, 0)) + return false; + lb_json_space(state); + return state.pos == body.size; +} + +lb_utf8_valid(body) +{ + for (i = 0; i < body.size; i++) + { + if (i % 256 == 0) + wait 0.05; + code = level.tws_lb_bytes[getSubStr(body, i, i + 1)]; + if (!isDefined(code)) + return false; + if (code < 128) + continue; + count = 0; + minimum = 128; + maximum = 191; + if (code >= 194 && code <= 223) + count = 1; + else if (code >= 224 && code <= 239) + { + count = 2; + if (code == 224) + minimum = 160; + if (code == 237) + maximum = 159; + } + else if (code >= 240 && code <= 244) + { + count = 3; + if (code == 240) + minimum = 144; + if (code == 244) + maximum = 143; + } + if (count == 0 || i + count >= body.size) + return false; + for (j = 1; j <= count; j++) + { + next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)]; + if (!isDefined(next) || next < minimum || next > maximum) + return false; + minimum = 128; + maximum = 191; + } + i += count; + } + return true; +} + +lb_json_char(state) +{ + if (state.pos >= state.yield_at) + { + wait 0.05; + state.yield_at = state.pos + 256; + } + if (state.pos >= state.body.size) + return ""; + return getSubStr(state.body, state.pos, state.pos + 1); +} + +lb_json_space(state) +{ + while (lb_contains(" \t\r\n", lb_json_char(state))) + state.pos++; +} + +lb_json_value(state, depth) +{ + if (depth > 16) + return false; + lb_json_space(state); + ch = lb_json_char(state); + if (ch == "\"") + return lb_json_string(state); + if (ch == "{" || ch == "[") + { + object = ch == "{"; + close = "]"; + if (object) + close = "}"; + state.pos++; + lb_json_space(state); + if (lb_json_char(state) == close) + { + state.pos++; + return true; + } + for (;;) + { + if (object) + { + if (!lb_json_string(state)) + return false; + lb_json_space(state); + if (lb_json_char(state) != ":") + return false; + state.pos++; + } + if (!lb_json_value(state, depth + 1)) + return false; + lb_json_space(state); + ch = lb_json_char(state); + state.pos++; + if (ch == close) + return true; + if (ch != ",") + return false; + lb_json_space(state); + } + } + literals = strTok("true,false,null", ","); + for (i = 0; i < literals.size; i++) + { + literal = literals[i]; + if (state.pos + literal.size <= state.body.size && + getSubStr(state.body, state.pos, state.pos + literal.size) == literal) + { + state.pos += literal.size; + return true; + } + } + return lb_json_number(state); +} + +lb_json_string(state) +{ + if (lb_json_char(state) != "\"") + return false; + state.pos++; + while (state.pos < state.body.size) + { + ch = lb_json_char(state); + state.pos++; + if (ch == "\"") + return true; + if (level.tws_lb_bytes[ch] < 32) + return false; + if (ch != "\\") + continue; + ch = lb_json_char(state); + state.pos++; + if (lb_contains("\"\\/bfnrt", ch)) + continue; + if (ch != "u") + return false; + code = lb_json_hex4(state); + // Embedded NUL does not round-trip through engine strings. + if (code <= 0 || (code >= 56320 && code <= 57343)) + return false; + if (code >= 55296 && code <= 56319) + { + if (state.pos + 2 > state.body.size || + getSubStr(state.body, state.pos, state.pos + 2) != "\\u") + return false; + state.pos += 2; + code = lb_json_hex4(state); + if (code < 56320 || code > 57343) + return false; + } + } + return false; +} + +lb_json_hex4(state) +{ + hex = "0123456789abcdef"; + result = 0; + for (i = 0; i < 4; i++) + { + ch = toLower(lb_json_char(state)); + if (!lb_contains(hex, ch)) + return -1; + for (j = 0; j < 16; j++) + { + if (getSubStr(hex, j, j + 1) == ch) + break; + } + result = result * 16 + j; + state.pos++; + } + return result; +} + +lb_json_number(state) +{ + start = state.pos; + if (lb_json_char(state) == "-") + state.pos++; + ch = lb_json_char(state); + if (ch == "0") + state.pos++; + else + { + if (!lb_contains("123456789", ch)) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + } + if (lb_json_char(state) == ".") + { + state.pos++; + if (!lb_contains("0123456789", lb_json_char(state))) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + } + if (lb_contains("eE", lb_json_char(state))) + { + state.pos++; + if (lb_contains("+-", lb_json_char(state))) + state.pos++; + exponent = state.pos; + if (!lb_contains("0123456789", lb_json_char(state))) + return false; + while (lb_contains("0123456789", lb_json_char(state))) + state.pos++; + // API counters/ratios never need huge exponents; prevent native overflow. + if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38) + return false; + } + return state.pos - start <= 32; +} diff --git a/tools/check_leaderboard.mjs b/tools/check_leaderboard.mjs new file mode 100644 index 0000000..168210d --- /dev/null +++ b/tools/check_leaderboard.mjs @@ -0,0 +1,224 @@ +// Execute the actual data-only GSC functions with small engine-value 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'; +import vm from 'node:vm'; + +const root = new URL('../', import.meta.url); +const bytes = (text) => Buffer.from(text, 'utf8').toString('latin1'); +const text = (value) => Buffer.from(value, 'latin1').toString('utf8'); +let checks = 0; +function check(name, run) { + try { run(); checks++; } + catch (error) { throw new Error(name, { cause: error }); } +} + +function functions(source) { + // Protect literals/comments before rewriting the small shared language subset. + const literals = []; + const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (token) => { + if (token.startsWith('/')) return ''; + literals.push(token); + return `__literal_${literals.length - 1}__`; + }); + const found = new Map(); + const headers = /^(lb_\w+)\(([^\n]*)\)\s*\n\{/gm; + for (let match; (match = headers.exec(protectedSource));) { + let end = headers.lastIndex, depth = 1; + while (depth && end < protectedSource.length) { + const ch = protectedSource[end++]; + if (ch === '{') depth++; + if (ch === '}') depth--; + } + assert.equal(depth, 0); + 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, ';'); + body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]); + found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); + } + return found; +} + +function engine(game) { + const source = readFileSync(new URL(`${game}/${game === 't6' ? 'tws_t6_leaderboard' : 'tws_leaderboard'}.gsc`, root), 'utf8'); + const all = functions(source); + const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy']); + const 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 = []; + const context = vm.createContext({ + level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table }, + GetDvar: (key) => dvars[key] ?? '', + spawnStruct: () => ({}), + isDefined: (value) => value !== undefined && value !== null, + gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array' + : typeof value === 'number' ? (Number.isInteger(value) ? 'int' : 'float') : typeof value === 'boolean' ? 'int' : typeof value, + toLower: (value) => value.toLowerCase(), + getSubStr: (value, start, end) => { + assert.equal(typeof value, 'string'); + assert(start >= 0 && start <= value.length, `substring start ${start} exceeds ${value.length}`); + return value.slice(start, end); + }, + strTok: (value, delimiter) => value.split(delimiter), + int: (value) => Math.trunc(Number(value)), + lb_log: (message) => logs.push(message), + 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 }; +} + +function mapOption(game) { + return game === 't5' + ? { game, map: 'zombie_theater', key: 't5:zombie_theater', name: 'Kino Der Toten' } + : { game, map: 'zm_transit', key: 't6:zm_transit:town', name: 'Town Survival' }; +} +function entry(option, guid, name = `Player ${guid}`, round = 52) { + return { guid, name: bytes(name), rank: round === 52 ? 1 : 10, highestRound: round, + records: { highestRound: { game: option.game, mapKey: option.key, value: round } } }; +} +function page(option, entries, total, offset = 0, limit = 3, round = 52) { + return { entries, total, offset, limit, metric: 'highestRound', maps: [option], summary: { highestRound: round } }; +} +function discovery(option) { return page(option, [], 0, 0, 1, 0); } + +for (const game of ['t5', 't6']) { + const { context: c, source, all, dvars, responses, queries } = engine(game); + const option = mapOption(game); + check(`${game} exactly one connect listener across modules`, () => { + const common = readFileSync(new URL(game === 't5' ? 't5/tws_common.gsc' : 't6/tws_t6_common.gsc', root), 'utf8'); + assert.equal((common.match(/waittill\("connected"/g) ?? []).length, 1); + assert(!source.includes('waittill("connected"')); + assert(!all.get('lb_welcome').includes('spawned_player')); + if (game === 't6') assert(!all.get('lb_welcome').includes('self.hasSpawned')); + assert(source.includes('register_connect_handler(::lb_welcome)')); + }); + 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é 🧟'); + assert.equal(text(c.lb_display_text(bytes('abc🧟'), 6)), 'abc...'); + assert.equal(text(c.lb_display_text(bytes('abc🧟'), 7)), 'abc🧟'); + }); + const valid = [discovery(option), { text: 'René 🧟 " \\ /\n\r\t\b\f', n: -1.23e-4, yes: true, no: false, empty: null }, + [], {}, 0, -0.2, 'hello', true, null]; + for (const value of valid) check(`${game} valid JSON ${JSON.stringify(value).slice(0, 60)}`, () => { + assert.equal(c.lb_json_valid(bytes(JSON.stringify(value))), true); + }); + check(`${game} escaped unicode pair and whitespace`, () => { + assert.equal(c.lb_json_valid(' \n{"s":"\\u00e9 \\uD83E\\uDDDF"}\t'), true); + }); + const invalid = ['', ' ', 'Error', '{', '{"a":}', '{"a":1,}', '[1,]', '[,1]', '{a:1}', 'true false', + '{"a":"\\q"}', '{"a":"raw\nline"}', '"\\ud800"', '"\\udc00"', '"\\ud800\\u0041"', '"\\u0000"', + '"\\uX001"', '"unterminated', '01', '-01', '+1', '.5', '1.', '1e', '1e+', '1e999', '1'.repeat(33), + 'NaN', 'Infinity', '['.repeat(18) + '0' + ']'.repeat(18), '"' + 'a'.repeat(20480) + '"', + '"\xc0\xaf"', '"\xed\xa0\x80"', '"\xf4\x90\x80\x80"', '"\xff"', '"\xc2"']; + for (const value of invalid) check(`${game} malformed JSON ${JSON.stringify(value).slice(0, 50)}`, () => { + assert.equal(c.lb_json_valid(value), false); + }); + check(`${game} every truncated prefix of a valid API response`, () => { + const body = JSON.stringify(page(option, [entry(option, '000123')], 1)); + for (let i = 0; i < body.length; i++) assert.equal(c.lb_json_valid(body.slice(0, i)), false, `accepted prefix ${i}`); + }); + check(`${game} success schema rejects API errors and bad field types`, () => { + for (const value of [{ ok: false, error: 'bad request' }, [], null, 'oops', + { ...discovery(option), entries: 'no' }, { ...discovery(option), metric: undefined }, + { ...discovery(option), limit: "1" }, { ...discovery(option), total: -1 }]) { + assert.equal(c.lb_page_valid(value, 0, 1), false); + } + }); + check(`${game} one holder`, () => { + responses.push(discovery(option), page(option, [entry(option, '000123', 'Alice')], 1)); + const result = c.lb_refresh(); + assert.equal(result.round, 52); + assert.deepEqual(Array.from(result.names), ['Alice']); + assert(queries.at(-1).includes(`map=${encodeURIComponent(option.key)}`)); + }); + check(`${game} every tie across pages, with repeated GUID and identical names`, () => { + const tied = Array.from({ length: 8 }, (_, i) => entry(option, `${i}`, i < 2 ? 'Same name' : `Holder ${i}`)); + tied.splice(3, 0, tied[2]); + responses.push(discovery(option), page(option, tied.slice(0, 3), 10), page(option, tied.slice(3, 6), 10, 3), + page(option, tied.slice(6, 9), 10, 6), page(option, [entry(option, 'lower', 'Lower', 51)], 10, 9)); + const result = c.lb_refresh(); + assert.equal(result.names.length, 8); + assert.equal(result.names.filter((x) => x === 'Same name').length, 2); + assert(!result.names.includes('Lower')); + assert.equal(queries.at(-1).includes('offset=9'), true); + }); + check(`${game} no records versus missing response`, () => { + responses.push(discovery(option), page(option, [], 0, 0, 3, 0)); + assert.equal(c.lb_refresh().round, 0); + responses.push({ ...discovery(option), maps: [] }); + assert.equal(c.lb_refresh().round, 0); + responses.push(undefined); + assert.equal(c.lb_refresh(), undefined); + }); + check(`${game} failed later page never returns partial holders`, () => { + responses.push(discovery(option), page(option, [entry(option, '1'), entry(option, '2'), entry(option, '3')], 4), undefined); + assert.equal(c.lb_refresh(), undefined); + }); + check(`${game} changing top record / total rejects mixed pages`, () => { + for (const [total, round] of [[4, 53], [5, 52]]) { + responses.push(discovery(option), page(option, [entry(option, '1'), entry(option, '2'), entry(option, '3')], 4), + page(option, [entry(option, '4')], total, 3, 3, round)); + assert.equal(c.lb_refresh(), undefined); + } + }); + check(`${game} wrong map evidence rejects response`, () => { + const wrong = entry({ ...option, key: 'wrong' }, '1'); + responses.push(discovery(option), page(option, [wrong], 1)); + assert.equal(c.lb_refresh(), undefined); + }); + check(`${game} explicit key must match current game and map`, () => { + c.level.tws_lb_override = 't6:zm_transit:town'; + const wrong = { ...option, game: 'wrong', key: c.level.tws_lb_override }; + responses.push(discovery(wrong)); + assert.equal(c.lb_refresh(), undefined); + c.level.tws_lb_override = ''; + }); + check(`${game} all holders fit bounded messages`, () => { + const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`); + const lines = Array.from(c.lb_holder_lines(names)); + assert(lines.every((line) => line.length <= 100)); + assert.deepEqual(lines.join(', ').split(', '), names); + }); + if (game === 't6') { + const variants = [ + ['zm_transit', 'zclassic', 'transit', 'transit'], ['zm_transit', 'zstandard', 'town', 'town'], + ['zm_transit', 'zstandard', 'farm', 'farm'], ['zm_transit', 'zstandard', 'transit', 'busdepot'], + ['zm_nuked', 'zstandard', 'nuked', 'nuked'], ['zm_highrise', 'zclassic', 'rooftop', 'highrise'], + ['zm_prison', 'zclassic', 'prison', 'prison'], ['zm_buried', 'zclassic', 'processing', 'buried'], + ['zm_tomb', 'zclassic', 'tomb', 'tomb'], ['zm_transit', 'zgrief', 'town', 'towngrief'], + ['zm_transit', 'zgrief', 'farm', 'farmgrief'], ['zm_transit', 'zgrief', 'transit', 'busdepotgrief'], + ['zm_prison', 'zgrief', 'cellblock', 'cellblock'], ['zm_buried', 'zgrief', 'street', 'buriedgrief'], + ['zm_transit_dr', 'zcleansed', 'diner', 'diner'], ['zm_buried', 'zcleansed', 'street', 'buriedturned'], + ]; + for (const [map, mode, location, slug] of variants) check(`T6 ${slug}`, () => { + assert.equal(c.lb_t6_selection({ map }, mode, location).key, `t6:${map}:${slug}`); + }); + check('T6 unknown variant does not fall back to available Town', () => { + dvars.ui_gametype = 'unknown'; + assert.equal(c.lb_selection(), undefined); + dvars.ui_gametype = 'zstandard'; + }); + check('T6 live stock settings take precedence over changed dvars', () => { + c.level.scr_zm_ui_gametype = 'zclassic'; + c.level.scr_zm_map_start_location = 'transit'; + assert.equal(c.lb_selection().key, 't6:zm_transit:transit'); + }); + } + assert.equal(responses.length, 0, `${game} unused responses`); +} +check('paired data helpers stay identical', () => { + const t5 = functions(readFileSync(new URL('t5/tws_leaderboard.gsc', root), 'utf8')); + const t6 = functions(readFileSync(new URL('t6/tws_t6_leaderboard.gsc', root), 'utf8')); + for (const [name, fn] of t5) { + if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) { + const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_common::'); + assert.equal(normalized, fn, name); + } + } +}); +console.log(`PASS ${checks} leaderboard fixture/static checks (both games). Engine linking and gameplay still require Plutonium.`);