Add map record HUD announcements for T5 and T6

This commit is contained in:
2026-10-05 22:14:27 -04:00
parent bbafebe208
commit 05a906c3e2
11 changed files with 1967 additions and 20 deletions
+744
View File
@@ -0,0 +1,744 @@
/* tws_leaderboard.gsc - private map record announcement, once per connection. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1")
return;
if (GetDvar("tws_leaderboard_enabled") == "0")
return;
level.tws_lb_game = "t5";
level.tws_lb_url = GetDvar("tws_leaderboard_url");
if (level.tws_lb_url == "")
level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard";
if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://")
{
lb_log("Disabled: tws_leaderboard_url must use HTTPS");
return;
}
level.tws_lb_override = GetDvar("tws_leaderboard_map_key");
level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
scripts\sp\tws_common::register_connect_handler(::lb_welcome);
level thread lb_worker();
}
lb_worker()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
// The same byte table supports UTF-8 validation and URL percent encoding.
level.tws_lb_bytes = [];
for (i = 1; i < 256; i++)
level.tws_lb_bytes[formatString("%c", i)] = i;
wait 1;
for (;;)
{
// A timed-out native request cannot be cancelled. Never overlap it.
while (isDefined(level.tws_lb_pending))
wait 1;
snapshot = lb_refresh();
level.tws_lb_attempt++;
if (isDefined(snapshot))
{
snapshot.saved_at = getTime();
level.tws_lb_cache = snapshot;
wait 300;
}
else
{
lb_log("Refresh failed; retaining the last complete record, if available");
wait 30;
}
}
}
lb_welcome()
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
if (isDefined(self.tws_lb_welcomed))
return;
self.tws_lb_welcomed = true;
// Spectator readiness matters too: do not wait for a playable spawn.
while (!isDefined(self.sessionstate) ||
(self.sessionstate != "playing" && self.sessionstate != "spectator"))
wait 0.25;
while (isDefined(self.hasSpawned) && !self.hasSpawned)
wait 0.25;
wait 5;
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
wait 0.25;
if (!isDefined(level.tws_lb_cache))
return;
snapshot = level.tws_lb_cache;
// Do not advertise an indefinitely stale record during an outage.
if (getTime() - snapshot.saved_at > 900000)
return;
self lb_show_hud(snapshot);
}
lb_show_hud(snapshot)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
scripts\sp\tws_common::ensure_config();
// Match .help's position, typography and row spacing below the health bar.
y = level.hud_row_bar + level.hud_bar_height + 20;
elems = [];
title = scripts\sp\tws_common::make_text_elem(
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
title.alpha = 0;
if (snapshot.round == 0)
title SetText("No recorded high round yet");
else
title SetText("Record: Round " + snapshot.round);
elems[elems.size] = title;
for (i = 0; i < 3; i++)
{
row = scripts\sp\tws_common::make_text_elem(
self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
row.alpha = 0;
elems[elems.size] = row;
}
// Cleanup runs independently of the welcome thread's endon handlers.
self thread scripts\sp\tws_common::destroy_on_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission");
offset = 0;
for (;;)
{
// Text changes only once per page, never during the visibility loop.
for (i = 0; i < 3; i++)
{
row = elems[i + 1];
if (offset + i < snapshot.lines.size)
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
else
row SetText("");
}
remaining = 12;
while (remaining > 0)
{
visible = !self lb_hud_help_visible();
for (i = 0; i < elems.size; i++)
elems[i].alpha = visible;
wait 0.1;
// Pause the page while .help occupies the same area.
if (visible)
remaining -= 0.1;
}
offset += 3;
if (offset >= snapshot.lines.size)
break;
}
lb_hud_destroy(elems);
self notify("tws_lb_hud_done");
}
lb_hud_help_visible()
{
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
}
lb_hud_end_game(elems, event)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
level waittill(event);
lb_hud_destroy(elems);
self notify("tws_lb_hud_done");
}
lb_hud_destroy(elems)
{
for (i = 0; i < elems.size; i++)
{
if (isDefined(elems[i]))
elems[i] Destroy();
}
}
lb_refresh()
{
selection = lb_selection();
if (!isDefined(selection))
{
lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key");
return undefined;
}
// Map keys are discovered and checked against the API's available maps.
discovery = lb_get("&limit=1&offset=0");
if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1))
return undefined;
if (!lb_resolve_map(selection, discovery["maps"]))
return undefined;
snapshot = spawnStruct();
snapshot.name = lb_display_text(selection.name, 64);
snapshot.round = 0;
snapshot.names = [];
snapshot.guids = [];
snapshot.lines = [];
snapshot.total = -1;
snapshot.last_round = 2147483647;
// No map option means no completed match has been recorded for this map.
if (!selection.found)
return snapshot;
offset = 0;
for (;;)
{
page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset);
if (!isDefined(page) || !lb_page_valid(page, offset, 3))
return undefined;
result = lb_take_page(snapshot, page, selection.key, offset);
if (result < 0)
{
lb_log("Invalid or changing leaderboard; discarding incomplete refresh");
return undefined;
}
if (result == 1)
{
snapshot.lines = lb_holder_lines(snapshot.names);
return snapshot;
}
offset += page["entries"].size;
if (offset > 1000000)
{
lb_log("Leaderboard exceeds the API pagination limit");
return undefined;
}
wait 0.05;
}
}
lb_selection()
{
selection = spawnStruct();
selection.map = toLower(GetDvar("mapname"));
selection.name = selection.map;
selection.key = level.tws_lb_override;
selection.explicit = selection.key != "";
return selection;
}
// All HTTP and parsing work belongs to the level worker, never the join thread.
lb_get(query)
{
options = [];
options["method"] = "GET";
headers = [];
headers["Accept"] = "application/json";
options["headers"] = headers;
url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query;
request = http::request(url, options);
if (!isDefined(request))
{
lb_log("HTTP request could not be created");
return undefined;
}
pending = spawnStruct();
pending.done = false;
level.tws_lb_pending = pending;
level thread lb_collect(request, pending);
started = getTime();
while (!pending.done && getTime() - started < 15000)
wait 0.1;
if (!pending.done)
{
lb_log("HTTP timed out; waiting for native completion before requesting again");
return undefined;
}
if (!isDefined(pending.success) || !pending.success)
{
lb_log("HTTP transport failed");
return undefined;
}
// Plugins expose neither HTTP status nor response headers. Reject error
// bodies by grammar and success-schema checks, not the transport flag.
if (!lb_json_valid(pending.body))
{
lb_log("Rejected malformed, oversized or non-JSON HTTP response");
return undefined;
}
return jsonParse(pending.body);
}
lb_collect(request, pending)
{
// Keep draining after game over; ending a GSC thread cannot cancel HTTP.
request waittill("done", body, success, error);
pending.body = body;
pending.success = success;
pending.done = true;
level.tws_lb_pending = undefined;
}
lb_log(message)
{
print("[tws_leaderboard] " + message);
}
// Pure data helpers below are exercised by tools/check_leaderboard.mjs.
lb_resolve_map(selection, maps)
{
selection.found = false;
matches = 0;
// T5 requires initialization before the loop, even with matches == 1 below.
chosen = undefined;
for (i = 0; i < maps.size; i++)
{
item = maps[i];
if (typeof(item) != "array" || typeof(item["game"]) != "string" ||
typeof(item["map"]) != "string" || typeof(item["key"]) != "string" ||
typeof(item["name"]) != "string")
return false;
if (item["game"] != level.tws_lb_game || item["map"] != selection.map)
continue;
if (selection.key != "" && item["key"] != selection.key)
continue;
matches++;
chosen = item;
}
if (matches > 1 || (matches == 0 && selection.explicit))
{
lb_log("Map key is absent, ambiguous or belongs to another map/game");
return false;
}
if (matches == 1)
{
selection.key = chosen["key"];
selection.name = chosen["name"];
selection.found = true;
}
return true;
}
lb_page_valid(page, offset, limit)
{
if (typeof(page) != "array" || typeof(page["entries"]) != "array" ||
typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array")
return false;
if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"]))
return false;
if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit)
return false;
return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) &&
page["entries"].size <= limit;
}
// -1 rejects the whole refresh, 0 requests another page, 1 is complete.
lb_take_page(snapshot, page, key, offset)
{
entries = page["entries"];
if (offset == 0)
{
snapshot.round = page["summary"]["highestRound"];
snapshot.total = page["total"];
}
if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"])
return -1;
if (entries.size == 0)
{
if (offset == 0 && snapshot.total == 0 && snapshot.round == 0)
return 1;
return -1;
}
if (snapshot.round < 2 || offset + entries.size > snapshot.total)
return -1;
for (i = 0; i < entries.size; i++)
{
entry = entries[i];
if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"]))
return -1;
value = entry["highestRound"];
if (value > snapshot.last_round || value > snapshot.round)
return -1;
snapshot.last_round = value;
if (value < snapshot.round)
{
if (snapshot.names.size == 0)
return -1;
return 1;
}
if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" ||
typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array")
return -1;
evidence = entry["records"]["highestRound"];
if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" ||
typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"]))
return -1;
if (evidence["game"] != level.tws_lb_game ||
evidence["mapKey"] != key || evidence["value"] != snapshot.round)
return -1;
// GUIDs remain strings; equal names are distinct players.
guid = entry["guid"];
if (!isDefined(snapshot.guids[guid]))
{
snapshot.guids[guid] = true;
name = lb_display_text(entry["name"], 64);
if (name == "")
name = lb_display_text(guid, 64);
snapshot.names[snapshot.names.size] = name;
}
}
if (offset + entries.size >= snapshot.total)
return 1;
return 0;
}
lb_uint(value)
{
return typeof(value) == "int" && value >= 0;
}
lb_holder_lines(names)
{
lines = [];
line = "";
for (i = 0; i < names.size; i++)
{
if (line != "" && line.size + names[i].size + 2 > 100)
{
lines[lines.size] = line;
line = "";
}
if (line != "")
line += ", ";
line += names[i];
}
if (line != "")
lines[lines.size] = line;
return lines;
}
lb_display_text(value, limit)
{
output = "";
for (i = 0; i < value.size; i++)
{
ch = getSubStr(value, i, i + 1);
code = level.tws_lb_bytes[ch];
if (!isDefined(code) || code < 32 || code == 127)
continue;
if (ch == "^")
{
// Strip game colour escapes; never let a name inject formatting.
if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2)))
i++;
continue;
}
width = 1;
if (code >= 240)
width = 4;
else if (code >= 224)
width = 3;
else if (code >= 192)
width = 2;
if (output.size + width > limit)
return output + "...";
output += getSubStr(value, i, i + width);
i += width - 1;
}
return output;
}
lb_url_encode(value)
{
hex = "0123456789ABCDEF";
encoded = "";
for (i = 0; i < value.size; i++)
{
ch = getSubStr(value, i, i + 1);
if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch))
encoded += ch;
else
{
code = level.tws_lb_bytes[ch];
high = int(code / 16);
low = code % 16;
encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1);
}
}
return encoded;
}
lb_contains(chars, ch)
{
if (ch == "")
return false;
for (j = 0; j < chars.size; j++)
{
if (getSubStr(chars, j, j + 1) == ch)
return true;
}
return false;
}
// jsonParse raises a script error on malformed input. Validate the complete
// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies.
// Yield during scanning so network data cannot monopolize a server frame.
lb_json_valid(body)
{
if (typeof(body) != "string" || body.size == 0 || body.size >= 20480)
return false;
if (!lb_utf8_valid(body))
return false;
state = spawnStruct();
state.body = body;
state.pos = 0;
state.yield_at = 256;
if (!lb_json_value(state, 0))
return false;
lb_json_space(state);
return state.pos == body.size;
}
lb_utf8_valid(body)
{
for (i = 0; i < body.size; i++)
{
if (i % 256 == 0)
wait 0.05;
code = level.tws_lb_bytes[getSubStr(body, i, i + 1)];
if (!isDefined(code))
return false;
if (code < 128)
continue;
count = 0;
minimum = 128;
maximum = 191;
if (code >= 194 && code <= 223)
count = 1;
else if (code >= 224 && code <= 239)
{
count = 2;
if (code == 224)
minimum = 160;
if (code == 237)
maximum = 159;
}
else if (code >= 240 && code <= 244)
{
count = 3;
if (code == 240)
minimum = 144;
if (code == 244)
maximum = 143;
}
if (count == 0 || i + count >= body.size)
return false;
for (j = 1; j <= count; j++)
{
next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)];
if (!isDefined(next) || next < minimum || next > maximum)
return false;
minimum = 128;
maximum = 191;
}
i += count;
}
return true;
}
lb_json_char(state)
{
if (state.pos >= state.yield_at)
{
wait 0.05;
state.yield_at = state.pos + 256;
}
if (state.pos >= state.body.size)
return "";
return getSubStr(state.body, state.pos, state.pos + 1);
}
lb_json_space(state)
{
while (lb_contains(" \t\r\n", lb_json_char(state)))
state.pos++;
}
lb_json_value(state, depth)
{
if (depth > 16)
return false;
lb_json_space(state);
ch = lb_json_char(state);
if (ch == "\"")
return lb_json_string(state);
if (ch == "{" || ch == "[")
{
object = ch == "{";
close = "]";
if (object)
close = "}";
state.pos++;
lb_json_space(state);
if (lb_json_char(state) == close)
{
state.pos++;
return true;
}
for (;;)
{
if (object)
{
if (!lb_json_string(state))
return false;
lb_json_space(state);
if (lb_json_char(state) != ":")
return false;
state.pos++;
}
if (!lb_json_value(state, depth + 1))
return false;
lb_json_space(state);
ch = lb_json_char(state);
state.pos++;
if (ch == close)
return true;
if (ch != ",")
return false;
lb_json_space(state);
}
}
literals = strTok("true,false,null", ",");
for (i = 0; i < literals.size; i++)
{
literal = literals[i];
if (state.pos + literal.size <= state.body.size &&
getSubStr(state.body, state.pos, state.pos + literal.size) == literal)
{
state.pos += literal.size;
return true;
}
}
return lb_json_number(state);
}
lb_json_string(state)
{
if (lb_json_char(state) != "\"")
return false;
state.pos++;
while (state.pos < state.body.size)
{
ch = lb_json_char(state);
state.pos++;
if (ch == "\"")
return true;
if (level.tws_lb_bytes[ch] < 32)
return false;
if (ch != "\\")
continue;
ch = lb_json_char(state);
state.pos++;
if (lb_contains("\"\\/bfnrt", ch))
continue;
if (ch != "u")
return false;
code = lb_json_hex4(state);
// Embedded NUL does not round-trip through engine strings.
if (code <= 0 || (code >= 56320 && code <= 57343))
return false;
if (code >= 55296 && code <= 56319)
{
if (state.pos + 2 > state.body.size ||
getSubStr(state.body, state.pos, state.pos + 2) != "\\u")
return false;
state.pos += 2;
code = lb_json_hex4(state);
if (code < 56320 || code > 57343)
return false;
}
}
return false;
}
lb_json_hex4(state)
{
hex = "0123456789abcdef";
result = 0;
for (i = 0; i < 4; i++)
{
ch = toLower(lb_json_char(state));
if (!lb_contains(hex, ch))
return -1;
for (j = 0; j < 16; j++)
{
if (getSubStr(hex, j, j + 1) == ch)
break;
}
result = result * 16 + j;
state.pos++;
}
return result;
}
lb_json_number(state)
{
start = state.pos;
if (lb_json_char(state) == "-")
state.pos++;
ch = lb_json_char(state);
if (ch == "0")
state.pos++;
else
{
if (!lb_contains("123456789", ch))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
}
if (lb_json_char(state) == ".")
{
state.pos++;
if (!lb_contains("0123456789", lb_json_char(state)))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
}
if (lb_contains("eE", lb_json_char(state)))
{
state.pos++;
if (lb_contains("+-", lb_json_char(state)))
state.pos++;
exponent = state.pos;
if (!lb_contains("0123456789", lb_json_char(state)))
return false;
while (lb_contains("0123456789", lb_json_char(state)))
state.pos++;
// API counters/ratios never need huge exponents; prevent native overflow.
if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38)
return false;
}
return state.pos - start <= 32;
}