Add map record HUD announcements for T5 and T6
This commit is contained in:
@@ -5,7 +5,7 @@ Combined working repo for the TWS Plutonium Zombies GSC scripts across two games
|
|||||||
## Layout
|
## 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).
|
- [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
|
## Parity
|
||||||
|
|
||||||
@@ -21,11 +21,73 @@ Modules currently in each tree:
|
|||||||
| lock | `t5/tws_lock.gsc` | `t6/tws_t6_lock.gsc` |
|
| 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 |
|
| 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` |
|
| 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)* |
|
| db | `t5/tws_db.gsc` | *(not ported)* |
|
||||||
| bank | `t5/tws_bank.gsc` | *(not ported)* |
|
| bank | `t5/tws_bank.gsc` | *(not ported)* |
|
||||||
|
|
||||||
`db` and `bank` are T5-only for now.
|
`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
|
## Validating T6
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
+9
-2
@@ -13,10 +13,16 @@ Copy all `tws_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server
|
|||||||
|
|
||||||
## Platform quirks (T5 SP engine, Plutonium)
|
## 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
|
- **One `level waittill("connected")` only.** `tws_common` owns it. A
|
||||||
second listener breaks the T5 SP connect handshake. Per-player work goes
|
second listener breaks the T5 SP connect handshake. Per-player work goes
|
||||||
through `tws_common::register_spawn_handler` and gets dispatched after
|
through `tws_common::register_spawn_handler` after `spawned_player`, or
|
||||||
`spawned_player`.
|
`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
|
- **Chat uses `level waittill("say", ...)`**, not `callbackPlayerSay`. T5 SP
|
||||||
dedicated disables the sound subsystem, cascading through the say-callback
|
dedicated disables the sound subsystem, cascading through the say-callback
|
||||||
dispatch — `callbackPlayerSay` never fires.
|
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_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_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_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
@@ -1,7 +1,7 @@
|
|||||||
# tws-t5-aio
|
# tws-t5-aio
|
||||||
|
|
||||||
Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry for Plutonium T5
|
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 eight `.gsc` files,
|
(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files,
|
||||||
each installable / removable in isolation (with `tws_common` as the shared
|
each installable / removable in isolation (with `tws_common` as the shared
|
||||||
hub).
|
hub).
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
|||||||
|
|
||||||
| File | Role |
|
| 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_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_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 |
|
| [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_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_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_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
|
## Player commands
|
||||||
|
|
||||||
@@ -58,12 +74,14 @@ graph BT
|
|||||||
bank[tws_bank]
|
bank[tws_bank]
|
||||||
lock[tws_lock]
|
lock[tws_lock]
|
||||||
telemetry[tws_telemetry]
|
telemetry[tws_telemetry]
|
||||||
|
leaderboard[tws_leaderboard]
|
||||||
|
|
||||||
db --> common
|
db --> common
|
||||||
hud --> common
|
hud --> common
|
||||||
afk --> common
|
afk --> common
|
||||||
join --> common
|
join --> common
|
||||||
telemetry --> common
|
telemetry --> common
|
||||||
|
leaderboard --> common
|
||||||
|
|
||||||
bank -. db_probe_done .-> db
|
bank -. db_probe_done .-> db
|
||||||
lock -. hud_round_changed<br/>hud_game_over .-> hud
|
lock -. hud_round_changed<br/>hud_game_over .-> hud
|
||||||
@@ -73,7 +91,7 @@ graph BT
|
|||||||
- All other cross-module coupling flows through level notifies
|
- All other cross-module coupling flows through level notifies
|
||||||
(`db_probe_done`, `hud_round_changed`, `hud_match_started`,
|
(`db_probe_done`, `hud_round_changed`, `hud_match_started`,
|
||||||
`hud_game_over`) or through spawn handlers registered with
|
`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.
|
- Any file except `tws_common` can be removed; the rest still load.
|
||||||
`tws_afk` has no downstream consumers — pull it independently.
|
`tws_afk` has no downstream consumers — pull it independently.
|
||||||
`tws_bank` needs `tws_db`; `tws_lock` needs `tws_hud`.
|
`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_*`,
|
- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`,
|
||||||
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold`
|
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold`
|
||||||
- Spawn-handler registry
|
- Spawn-handler registry
|
||||||
|
- Connection-handler registry for optional modules that serve spectator newcomers
|
||||||
- `.help` listener and help registry populated by installed command modules
|
- `.help` listener and help registry populated by installed command modules
|
||||||
- Connect listener + per-player dispatch: iterates
|
- Connect listener + per-player dispatch: iterates
|
||||||
`level.tws_spawn_handlers` after `spawned_player` and threads each on
|
`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) |
|
| `common_config()` | Populate every `level.hud_*` (idempotent) |
|
||||||
| `ensure_config()` | No-op if config already set; called by every module's `init` |
|
| `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_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()` |
|
| `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_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline |
|
||||||
| `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element |
|
| `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element |
|
||||||
|
|||||||
+24
-1
@@ -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 maps\_utility;
|
||||||
#include common_scripts\utility;
|
#include common_scripts\utility;
|
||||||
@@ -74,6 +74,15 @@ register_spawn_handler(fn)
|
|||||||
level.tws_spawn_handlers[level.tws_spawn_handlers.size] = 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.
|
// Modules register their own entries so help only lists installed commands.
|
||||||
register_help_command(command, description)
|
register_help_command(command, description)
|
||||||
{
|
{
|
||||||
@@ -159,9 +168,23 @@ on_player_connect_dispatch()
|
|||||||
{
|
{
|
||||||
level waittill("connected", player);
|
level waittill("connected", player);
|
||||||
player thread on_player_spawned_dispatch();
|
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")
|
// handlers own their own self endon("disconnect")
|
||||||
on_player_spawned_dispatch()
|
on_player_spawned_dispatch()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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
|
death/laststand/intermission until active play. Other mods controlling the same
|
||||||
invulnerability or weapon flags can still conflict and need live testing.
|
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,
|
`common_config()` and `ensure_config()` are original T5 in-memory HUD helpers,
|
||||||
not an external configuration system.
|
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
|
## Verified primary sources
|
||||||
|
|
||||||
- [Plutonium loading guide](https://plutonium.pw/docs/modding/loading-mods/): T6
|
- [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
|
## 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`
|
references and entry points. It requires PowerShell and gsc-tool. The `.help`
|
||||||
change passed the static entry-point/reference checks, but compilation and live
|
change passed the static entry-point/reference checks, but compilation and live
|
||||||
gameplay were not run. Compiler success does not certify runtime linking.
|
gameplay were not run. Compiler success does not certify runtime linking.
|
||||||
|
|||||||
+30
-6
@@ -5,21 +5,27 @@ just like T5. There is no separate TWS config file or database setup.
|
|||||||
|
|
||||||
| File | Role | Dependencies |
|
| 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_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_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_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_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_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
|
## Install
|
||||||
|
|
||||||
1. Copy these six `tws_t6_*.gsc` files into `Plutonium/storage/t6/scripts/zm/`.
|
1. Copy all seven `tws_t6_*.gsc` files into
|
||||||
2. For telemetry, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1)
|
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
|
||||||
in `Plutonium/plugins/` and restart the server process. The other four scripts
|
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.
|
do not require that plugin.
|
||||||
3. Restart the map to load script updates.
|
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
|
If updating an existing deployment, **remove `tws_t6_db.gsc` and
|
||||||
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
|
`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
|
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/).
|
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
|
||||||
Keep the filenames and `scripts/zm` location so shared references resolve.
|
Keep the filenames and `scripts/zm` location so shared references resolve.
|
||||||
To omit telemetry, remove its script; disabling it at runtime does not remove
|
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
|
## Chat commands
|
||||||
|
|
||||||
@@ -115,7 +139,7 @@ another mod installed one first.
|
|||||||
|
|
||||||
## Validation
|
## 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
|
```powershell
|
||||||
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
||||||
|
|||||||
+4
-3
@@ -5,8 +5,9 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
||||||
$repo = Split-Path -Parent $PSScriptRoot
|
$repo = Split-Path -Parent $PSScriptRoot
|
||||||
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_t6_*.gsc' | Sort-Object Name)
|
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
|
||||||
if ($files.Count -ne 6) { throw 'Expected the six TWS modules: common, HUD, AFK, lock, telemetry, join.' }
|
$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
|
$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 })
|
$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) {
|
foreach ($file in $files) {
|
||||||
@@ -31,4 +32,4 @@ foreach ($file in $files) {
|
|||||||
}
|
}
|
||||||
Write-Output "PASS $($file.Name)"
|
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
@@ -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 maps\mp\_utility;
|
||||||
#include common_scripts\utility;
|
#include common_scripts\utility;
|
||||||
@@ -76,6 +76,15 @@ register_spawn_handler(fn)
|
|||||||
level.tws_spawn_handlers[level.tws_spawn_handlers.size] = 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.
|
// Modules register their own commands, regardless of init order.
|
||||||
register_help_command(command, description)
|
register_help_command(command, description)
|
||||||
{
|
{
|
||||||
@@ -160,9 +169,23 @@ on_player_connect_dispatch()
|
|||||||
{
|
{
|
||||||
level waittill("connected", player);
|
level waittill("connected", player);
|
||||||
player thread on_player_spawned_dispatch();
|
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")
|
// handlers own their own self endon("disconnect")
|
||||||
on_player_spawned_dispatch()
|
on_player_spawned_dispatch()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.`);
|
||||||
Reference in New Issue
Block a user