851 lines
24 KiB
Plaintext
851 lines
24 KiB
Plaintext
/* tws_t6_leaderboard.gsc - private map record announcements 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;
|
|
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();
|
|
}
|
|
|
|
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");
|
|
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");
|
|
|
|
snapshot = lb_cached_record();
|
|
if (!isDefined(snapshot))
|
|
{
|
|
if (manual)
|
|
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
|
|
return;
|
|
}
|
|
// A manual replay replaces any welcome announcement still waiting to run.
|
|
if (manual)
|
|
self notify("tws_lb_requested");
|
|
self lb_show_hud(snapshot);
|
|
}
|
|
|
|
lb_show_hud(snapshot)
|
|
{
|
|
// Stop the previous display and its cleanup threads before replacing it.
|
|
self notify("tws_lb_hud_done");
|
|
if (isDefined(self.tws_lb_elems))
|
|
lb_hud_destroy(self.tws_lb_elems);
|
|
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 display and ends when it is replaced.
|
|
self.tws_lb_elems = elems;
|
|
self thread lb_hud_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.tws_lb_elems = undefined;
|
|
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_disconnect(elems)
|
|
{
|
|
self endon("tws_lb_hud_done");
|
|
self waittill("disconnect");
|
|
lb_hud_destroy(elems);
|
|
}
|
|
|
|
lb_hud_end_game(elems, event)
|
|
{
|
|
self endon("disconnect");
|
|
self endon("tws_lb_hud_done");
|
|
level waittill(event);
|
|
lb_hud_destroy(elems);
|
|
self.tws_lb_elems = undefined;
|
|
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;
|
|
}
|