Add map record HUD announcements for T5 and T6

This commit is contained in:
2026-10-05 22:14:27 -04:00
parent bbafebe208
commit 05a906c3e2
11 changed files with 1967 additions and 20 deletions
+63 -1
View File
@@ -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
+9 -2
View File
@@ -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` |
+24 -4
View File
@@ -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<br/>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 |
+24 -1
View File
@@ -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()
{
+744
View File
@@ -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;
}
+28 -2
View File
@@ -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.
+30 -6
View File
@@ -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
+4 -3
View File
@@ -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.'
+24 -1
View File
@@ -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()
{
+793
View File
@@ -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;
}
+224
View File
@@ -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(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => 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 = ['', ' ', '<html>Error</html>', '{', '{"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.`);