Add map record HUD announcements for T5 and T6
This commit is contained in:
+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
|
||||
invulnerability or weapon flags can still conflict and need live testing.
|
||||
|
||||
The four optional T5 dvars remain; no new configuration switches are introduced.
|
||||
The existing optional T5 dvars remain. Leaderboard adds the three settings
|
||||
documented in the [shared README](../README.md#map-record-announcements).
|
||||
`common_config()` and `ensure_config()` are original T5 in-memory HUD helpers,
|
||||
not an external configuration system.
|
||||
|
||||
## Leaderboard join announcements
|
||||
|
||||
The module is `tws_t6_leaderboard.gsc` in T6 and `tws_leaderboard.gsc` in T5.
|
||||
Common's existing
|
||||
connection listener now also dispatches optional connection handlers after a
|
||||
one-frame yield. Leaderboard waits for player readiness and uses a level-owned
|
||||
HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep
|
||||
`hasSpawned` false, so T6 readiness uses session state and the intro-screen flag;
|
||||
T5 sets `hasSpawned` for both playable and spectator spawns.
|
||||
|
||||
T6's active `scr_zm_ui_gametype` / `scr_zm_map_start_location` fields come from
|
||||
[stock `_zm.gsc`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/zombies/_zm.gsc).
|
||||
Mode/location combinations follow the
|
||||
[T6 server configurations](https://github.com/xerxes-at/T6ServerConfigs/tree/master/localappdata/Plutonium/storage/t6/gamesettings)
|
||||
and Electric Cherry's map catalogue. Unknown selections require an explicit API
|
||||
key instead of falling back to another variant sharing the engine map.
|
||||
|
||||
Validation for this change: 135 fixture/static checks pass using the actual
|
||||
data-handling functions from both scripts with JavaScript engine-value shims.
|
||||
All seven T6 scripts compile and assemble using a local Intel macOS driver built
|
||||
from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was
|
||||
not executed here. T5 compilation and live plugin/gameplay checks were not run;
|
||||
gsc-tool has no T5 backend. The production API could not be reached from this
|
||||
environment. See the shared README for the live validation cases.
|
||||
|
||||
## Verified primary sources
|
||||
|
||||
- [Plutonium loading guide](https://plutonium.pw/docs/modding/loading-mods/): T6
|
||||
@@ -92,7 +118,7 @@ at link time. The other four TWS modules can run without telemetry.
|
||||
|
||||
## Checks
|
||||
|
||||
Local: `tools/check.ps1` compiles all six files for T6 PC and checks shared
|
||||
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared
|
||||
references and entry points. It requires PowerShell and gsc-tool. The `.help`
|
||||
change passed the static entry-point/reference checks, but compilation and live
|
||||
gameplay were not run. Compiler success does not certify runtime linking.
|
||||
|
||||
+30
-6
@@ -5,21 +5,27 @@ just like T5. There is no separate TWS config file or database setup.
|
||||
|
||||
| File | Role | Dependencies |
|
||||
| --- | --- | --- |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help` and first-spawn dispatcher | Stock T6 |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | Stock T6 |
|
||||
| `tws_t6_hud.gsc` | Match/round timers, name, zombie count and health bar | Common |
|
||||
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common |
|
||||
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
|
||||
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, t6-gsc-utils |
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy these six `tws_t6_*.gsc` files into `Plutonium/storage/t6/scripts/zm/`.
|
||||
2. For telemetry, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1)
|
||||
in `Plutonium/plugins/` and restart the server process. The other four scripts
|
||||
1. Copy all seven `tws_t6_*.gsc` files into
|
||||
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
|
||||
2. For telemetry or leaderboard, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1)
|
||||
in `Plutonium/plugins/` and restart the server process. The other five scripts
|
||||
do not require that plugin.
|
||||
3. Restart the map to load script updates.
|
||||
|
||||
If upgrading from the earlier leaderboard filename, **remove
|
||||
`tws_leaderboard.gsc` from the T6 server script directory** before installing
|
||||
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
||||
|
||||
If updating an existing deployment, **remove `tws_t6_db.gsc` and
|
||||
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
|
||||
will not remove those old scripts. Remove any `exec server.example.cfg` added for
|
||||
@@ -29,7 +35,25 @@ Source `.gsc` files load directly under Plutonium's
|
||||
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
|
||||
Keep the filenames and `scripts/zm` location so shared references resolve.
|
||||
To omit telemetry, remove its script; disabling it at runtime does not remove
|
||||
its plugin dependency during linking.
|
||||
its plugin dependency during linking. The same rule applies to leaderboard.
|
||||
|
||||
## Map record announcements
|
||||
|
||||
`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials.
|
||||
It works independently of telemetry and HUD, and privately announces records
|
||||
once per connection, including newcomers waiting in spectator mode.
|
||||
|
||||
Map selection uses stock `level.scr_zm_ui_gametype` and
|
||||
`level.scr_zm_map_start_location`, falling back to the corresponding
|
||||
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
|
||||
`zstandard` + `town` selects `t6:zm_transit:town`, while `zclassic` + `transit`
|
||||
selects TranZit. Survival, Grief and Turned variants follow Electric Cherry's
|
||||
catalogue. An unknown mode/location is logged instead of guessing from the engine
|
||||
map. Use `tws_leaderboard_map_key` for a custom registered selection if needed.
|
||||
|
||||
The plugin must expose `httpPost` (with the GET method override), `jsonParse`,
|
||||
`typeof` and `formatString`. [Shared configuration, caching and validation](../README.md#map-record-announcements)
|
||||
apply to both versions. All T6 script filenames use the `tws_t6_` prefix.
|
||||
|
||||
## Chat commands
|
||||
|
||||
@@ -115,7 +139,7 @@ another mod installed one first.
|
||||
|
||||
## Validation
|
||||
|
||||
To compile all six scripts for T6 PC with gsc-tool 1.4.10:
|
||||
To compile all seven scripts for T6 PC with gsc-tool:
|
||||
|
||||
```powershell
|
||||
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
||||
|
||||
+4
-3
@@ -5,8 +5,9 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_t6_*.gsc' | Sort-Object Name)
|
||||
if ($files.Count -ne 6) { throw 'Expected the six TWS modules: common, HUD, AFK, lock, telemetry, join.' }
|
||||
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
|
||||
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc')
|
||||
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
|
||||
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
|
||||
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
|
||||
foreach ($file in $files) {
|
||||
@@ -31,4 +32,4 @@ foreach ($file in $files) {
|
||||
}
|
||||
Write-Output "PASS $($file.Name)"
|
||||
}
|
||||
Write-Output 'All six T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
|
||||
Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
/* tws_t6_common.gsc — shared config, HUD helpers, help and spawn-handler registries. */
|
||||
/* tws_t6_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */
|
||||
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
@@ -76,6 +76,15 @@ register_spawn_handler(fn)
|
||||
level.tws_spawn_handlers[level.tws_spawn_handlers.size] = fn;
|
||||
}
|
||||
|
||||
// Connection handlers also serve newcomers still waiting in spectator mode.
|
||||
register_connect_handler(fn)
|
||||
{
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
level.tws_connect_handlers = [];
|
||||
|
||||
level.tws_connect_handlers[level.tws_connect_handlers.size] = fn;
|
||||
}
|
||||
|
||||
// Modules register their own commands, regardless of init order.
|
||||
register_help_command(command, description)
|
||||
{
|
||||
@@ -160,9 +169,23 @@ on_player_connect_dispatch()
|
||||
{
|
||||
level waittill("connected", player);
|
||||
player thread on_player_spawned_dispatch();
|
||||
player thread on_player_connected_dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
on_player_connected_dispatch()
|
||||
{
|
||||
self endon("disconnect");
|
||||
// Leave the connection handshake before invoking optional modules.
|
||||
wait 0.05;
|
||||
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
return;
|
||||
|
||||
for (i = 0; i < level.tws_connect_handlers.size; i++)
|
||||
self thread [[ level.tws_connect_handlers[i] ]]();
|
||||
}
|
||||
|
||||
// handlers own their own self endon("disconnect")
|
||||
on_player_spawned_dispatch()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user