/* 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; }