Files
tws-zombies-scripts/t6/scripts/tws_t6_leaderboard.gsc
T

896 lines
26 KiB
Plaintext

/* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#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;
level.tws_lb_chase_finished = undefined;
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker();
level thread lb_round_watcher();
}
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_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = 0;
for (;;)
{
// T6 advances its counter before the between-round pause is over.
// Both engines notify the actual start; never synthesize a late start.
level waittill("start_of_round");
if (!isDefined(level.round_number) || level.round_number <= last_round)
continue;
last_round = level.round_number;
lb_round_start(last_round);
}
}
lb_round_start(round)
{
if (isDefined(level.tws_lb_chase_finished))
return;
snapshot = lb_cached_record();
if (!isDefined(snapshot) || snapshot.round < 2)
return;
message = lb_round_message(round, snapshot.round);
if (message == "")
return;
// Celebrate at most once, even if a later refresh raises the fleet record.
// Busy clients skip the milestone too; no delayed mid-round announcements.
if (round > snapshot.round)
level.tws_lb_chase_finished = true;
players = getPlayers();
for (i = 0; i < players.size; i++)
players[i] lb_remind_player(snapshot, message);
}
lb_round_message(round, record)
{
if (round < 1 || record < 2)
return "";
gap = record - round;
if (gap < 0)
return "Recorded high round passed!";
if (gap == 0)
return "Record round reached!";
if (gap > 20)
return "";
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
if (gap > 10 && gap % 5 != 0)
return "";
if (gap > 5 && gap <= 10 && gap % 2 != 0)
return "";
if (gap == 1)
return "1 round away from matching it";
return gap + " rounds away from matching it";
}
lb_player_ready(player)
{
// A welcome attempt (even with no record) or manual request releases this
// gate. Give that full announcement at least 30 seconds before a reminder.
if (!isDefined(player.tws_lb_last_request) ||
getTime() - player.tws_lb_last_request < 30000)
return false;
if (!isDefined(player.sessionstate) ||
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
return false;
if (isDefined(level.passed_introscreen) && !level.passed_introscreen)
return false;
return true;
}
lb_remind_player(snapshot, message)
{
if (!lb_player_ready(self))
return;
self scripts\zm\tws_t6_common::show_record_reminder(snapshot, message);
}
lb_welcome()
{
self endon("disconnect");
self endon("tws_lb_requested");
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;
self lb_request(false);
}
lb_chat_listener()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".leaders")
player thread lb_request(true);
}
}
lb_cached_record()
{
if (!isDefined(level.tws_lb_cache))
return undefined;
snapshot = level.tws_lb_cache;
// Do not advertise an indefinitely stale record during an outage.
if (getTime() - snapshot.saved_at > 900000)
return undefined;
return snapshot;
}
lb_request(manual)
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
// A manual replay replaces any welcome announcement still waiting to run.
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
// Common owns pagination, help priority and the single service window.
// Undefined snapshots produce a private unavailable page for manual replay.
self scripts\zm\tws_t6_common::show_record(snapshot);
}
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 = "";
columns = 0;
for (i = 0; i < names.size; i++)
{
name = lb_holder_name(names[i]);
width = lb_holder_width(name);
if (line != "" && columns + width + 2 > 16)
{
lines[lines.size] = line;
line = "";
columns = 0;
}
if (line != "")
{
line += ", ";
columns += 2;
}
line += name;
columns += width;
}
if (line != "")
lines[lines.size] = line;
return lines;
}
lb_holder_name(value)
{
// The service has 200 virtual units at font scale 1.1. Use a conservative
// 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics.
// A long name gets one bounded entry; no tied holder is dropped.
value = lb_display_text(value, 64);
if (value == "")
value = "Unknown player";
if (lb_holder_width(value) <= 16)
return value;
output = "";
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
width = 1;
if (code >= 128)
width = 2;
if (columns + width > 13)
break;
count = lb_glyph_bytes(code);
output += getSubStr(value, i, i + count);
columns += width;
i += count - 1;
}
return output + "...";
}
lb_holder_width(value)
{
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
if (code >= 128)
columns += 2;
else
columns++;
i += lb_glyph_bytes(code) - 1;
}
return columns;
}
lb_glyph_bytes(code)
{
if (code >= 240)
return 4;
if (code >= 224)
return 3;
if (code >= 192)
return 2;
return 1;
}
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;
}