524 lines
18 KiB
Plaintext
524 lines
18 KiB
Plaintext
/* tws_t5_bank.gsc - shared GUID bank, with durable transfer decisions. */
|
|
#include maps\_utility;
|
|
#include common_scripts\utility;
|
|
|
|
init()
|
|
{
|
|
if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0")
|
|
return;
|
|
// Game containers use the public API, independent of the host controller URL.
|
|
level.bank_url = "https://ec.thewrightserver.net/api/bank";
|
|
level.bank_server = GetDvar("tws_server_id");
|
|
level.bank_token = GetDvar("tws_bank_token");
|
|
level.bank_logs = [];
|
|
level.bank_diagnostic_serial = 0;
|
|
credentials = "valid";
|
|
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
|
credentials = "invalid";
|
|
print("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
|
|
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
|
|
" credentials=" + credentials);
|
|
// A private per-server cfg supplies this; never embed bank credentials.
|
|
if (level.bank_server == "" || level.bank_token.size < 40 ||
|
|
getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
|
{
|
|
print("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token; pending=not_loaded");
|
|
return;
|
|
}
|
|
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
|
|
if (!isDefined(level.bank_queue))
|
|
{
|
|
print("[tws_bank] startup journal_load=failure pending=unknown bank=disabled");
|
|
return;
|
|
}
|
|
print("[tws_bank] startup journal_load=success pending=" + level.bank_queue.size);
|
|
for (i = 0; i < level.bank_queue.size; i++)
|
|
{
|
|
level.bank_queue[i]["since"] = getTime();
|
|
level.bank_queue[i]["attempts"] = 0;
|
|
}
|
|
level.bank_read = 0;
|
|
level.bank_write = level.bank_queue.size;
|
|
level.bank_busy = false;
|
|
level.bank_ended = false;
|
|
scripts\sp\tws_t5_common::register_help_command(".b", "Show bank balance");
|
|
scripts\sp\tws_t5_common::register_help_command(".d <amount|all>", "Deposit points");
|
|
scripts\sp\tws_t5_common::register_help_command(".w <amount|all>", "Withdraw points");
|
|
level thread bank_end_watcher();
|
|
level thread bank_sender();
|
|
level thread bank_chat_listener();
|
|
}
|
|
|
|
bank_end_watcher()
|
|
{
|
|
level waittill_any("end_game", "game_ended", "intermission");
|
|
level.bank_ended = true;
|
|
}
|
|
|
|
bank_chat_listener()
|
|
{
|
|
for (;;)
|
|
{
|
|
level waittill("say", message, player);
|
|
if (!isDefined(player) || !isDefined(message))
|
|
continue;
|
|
args = strTok(toLower(message), " ");
|
|
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
|
|
continue;
|
|
if (level.bank_busy || level.bank_read < level.bank_write)
|
|
{
|
|
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
|
|
continue;
|
|
}
|
|
level.bank_busy = true;
|
|
level thread bank_request(player, args);
|
|
}
|
|
}
|
|
|
|
bank_amount(text)
|
|
{
|
|
if (text == "all")
|
|
return -1;
|
|
if (text.size == 0 || text.size > 7)
|
|
return 0;
|
|
digits = "0123456789";
|
|
for (i = 0; i < text.size; i++)
|
|
{
|
|
found = false;
|
|
for (j = 0; j < digits.size; j++)
|
|
if (text[i] == digits[j])
|
|
found = true;
|
|
if (!found)
|
|
return 0;
|
|
}
|
|
amount = int(text);
|
|
if (amount < 1 || amount > 1000000)
|
|
return 0;
|
|
return amount;
|
|
}
|
|
|
|
bank_playing(player)
|
|
{
|
|
return !level.bank_ended && !is_true(level.intermission) &&
|
|
isDefined(player) && isDefined(player.sessionstate) &&
|
|
player.sessionstate == "playing" && isAlive(player) &&
|
|
!(player maps\_laststand::player_is_in_laststand()) &&
|
|
isDefined(player.score) && player.score >= 0;
|
|
}
|
|
|
|
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
|
|
bank_guard(entry)
|
|
{
|
|
self endon("tws_bank_finished");
|
|
self waittill_any("disconnect", "killed_player", "spawned_player");
|
|
entry.active = false;
|
|
}
|
|
|
|
bank_say(entry, text)
|
|
{
|
|
if (entry.active && isDefined(entry.player))
|
|
entry.player iPrintLnBold(text);
|
|
}
|
|
|
|
bank_request(player, args)
|
|
{
|
|
entry = spawnStruct();
|
|
entry.player = player;
|
|
entry.active = true;
|
|
player thread bank_guard(entry);
|
|
bank_transfer(entry, args);
|
|
if (entry.active && isDefined(player))
|
|
player notify("tws_bank_finished");
|
|
level.bank_busy = false;
|
|
}
|
|
|
|
bank_guid(player)
|
|
{
|
|
if (!isDefined(player))
|
|
return undefined;
|
|
guid = player getGuid();
|
|
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
|
return undefined;
|
|
// T5 can return a number. Preserve string identities without numeric casts.
|
|
guid = "" + guid;
|
|
if (guid.size == 0 || guid.size > 64)
|
|
return undefined;
|
|
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
|
for (i = 0; i < guid.size; i++)
|
|
{
|
|
found = false;
|
|
for (j = 0; j < allowed.size; j++)
|
|
if (guid[i] == allowed[j])
|
|
found = true;
|
|
if (!found)
|
|
return undefined;
|
|
}
|
|
return guid;
|
|
}
|
|
|
|
bank_transfer(entry, args)
|
|
{
|
|
player = entry.player;
|
|
guid = bank_guid(player);
|
|
if (!isDefined(guid))
|
|
{
|
|
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
|
|
return;
|
|
}
|
|
data = [];
|
|
data["server"] = level.bank_server;
|
|
data["guid"] = guid;
|
|
if (args[0] == ".b")
|
|
{
|
|
if (args.size != 1)
|
|
{
|
|
bank_say(entry, "^3Usage: .b");
|
|
return;
|
|
}
|
|
reply = bank_http("balance", jsonSerialize(data), "");
|
|
if (!isDefined(reply) || reply["state"] != "balance")
|
|
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
|
|
else
|
|
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
|
|
return;
|
|
}
|
|
if (args.size != 2)
|
|
{
|
|
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
|
|
return;
|
|
}
|
|
amount = bank_amount(args[1]);
|
|
if (amount == 0)
|
|
{
|
|
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
|
|
return;
|
|
}
|
|
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
|
|
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
|
|
{
|
|
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
|
|
return;
|
|
}
|
|
operation = "withdraw";
|
|
if (args[0] == ".d")
|
|
{
|
|
operation = "deposit";
|
|
if (amount == -1)
|
|
amount = int(player.score);
|
|
if (amount <= 0 || amount > 1000000 || player.score < amount)
|
|
{
|
|
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
|
|
return;
|
|
}
|
|
}
|
|
capacity = 1000000 - int(player.score);
|
|
if (capacity < 0)
|
|
capacity = 0;
|
|
if (operation == "withdraw" && amount != -1 && amount > capacity)
|
|
{
|
|
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
|
|
return;
|
|
}
|
|
id = telemetryNewMatch();
|
|
if (!isDefined(id))
|
|
{
|
|
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
|
|
return;
|
|
}
|
|
data["request_id"] = id;
|
|
data["match"] = level.telemetry_match;
|
|
data["operation"] = operation;
|
|
data["amount"] = amount;
|
|
if (amount == -1)
|
|
data["amount"] = "all";
|
|
data["capacity"] = capacity;
|
|
data["decision"] = "cancel";
|
|
body = jsonSerialize(data);
|
|
pending_since = getTime();
|
|
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
|
|
if (!bank_store(body, id, "cancel"))
|
|
{
|
|
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
|
return;
|
|
}
|
|
reply = bank_http(operation, body, id);
|
|
if (isDefined(reply) && reply["state"] == "prepared")
|
|
{
|
|
transfer = reply["amount"];
|
|
if (entry.active && bank_playing(player) &&
|
|
(amount == -1 || amount == transfer) &&
|
|
((operation == "deposit" && player.score >= transfer) ||
|
|
(operation == "withdraw" && player.score <= 1000000 - transfer)))
|
|
{
|
|
data["decision"] = "commit";
|
|
commit_body = jsonSerialize(data);
|
|
// No yield between recording the decision and updating the balance.
|
|
// Do not change score_total: bank funds are not earned match points.
|
|
if (bank_store(commit_body, id, "commit"))
|
|
{
|
|
if (operation == "deposit")
|
|
player.score -= transfer;
|
|
else
|
|
player.score += transfer;
|
|
player.pers["score"] = player.score;
|
|
body = commit_body;
|
|
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
|
|
}
|
|
else
|
|
{
|
|
data["decision"] = "cancel";
|
|
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
|
}
|
|
}
|
|
else
|
|
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
|
|
}
|
|
else if (isDefined(reply) && reply["state"] == "rejected")
|
|
bank_say(entry, bank_error(reply["code"]));
|
|
else
|
|
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
|
|
pending = [];
|
|
pending["id"] = id;
|
|
pending["body"] = body;
|
|
pending["decision"] = data["decision"];
|
|
pending["since"] = pending_since;
|
|
pending["attempts"] = 0;
|
|
level.bank_queue[level.bank_write] = pending;
|
|
level.bank_write++;
|
|
}
|
|
|
|
bank_error(code)
|
|
{
|
|
if (code == "insufficient_balance" || code == "empty")
|
|
return "^3Not enough bank balance. Your points are unchanged.";
|
|
if (code == "bank_limit")
|
|
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
|
|
if (code == "points_limit")
|
|
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
|
|
if (code == "not_in_match")
|
|
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
|
|
if (code == "pending")
|
|
return "^3A bank transfer is pending on another server. Try again shortly.";
|
|
return "^3Bank rejected the transfer. Your points are unchanged.";
|
|
}
|
|
|
|
// Only locally generated identities/configured server names pass through here.
|
|
// Never use this to print response fields: those use fixed vocabularies below.
|
|
bank_log_token(value)
|
|
{
|
|
if (!isDefined(value) || typeof(value) != "string" || value.size == 0 || value.size > 128)
|
|
return "unavailable";
|
|
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
|
for (i = 0; i < value.size; i++)
|
|
{
|
|
found = false;
|
|
for (j = 0; j < allowed.size; j++)
|
|
if (value[i] == allowed[j])
|
|
found = true;
|
|
if (!found)
|
|
return "invalid";
|
|
}
|
|
return value;
|
|
}
|
|
|
|
bank_response_code(code)
|
|
{
|
|
// Unknown values may contain secrets or console controls, even if short.
|
|
codes = strTok("ok insufficient_balance empty bank_limit points_limit not_in_match pending invalid_request unauthorized forbidden not_found conflict disabled", " ");
|
|
for (i = 0; i < codes.size; i++)
|
|
if (isDefined(code) && code == codes[i])
|
|
return codes[i];
|
|
return "unknown";
|
|
}
|
|
|
|
bank_response_reason(reason)
|
|
{
|
|
reasons = strTok("empty_body body_limit invalid_json schema field_limit state amount_range ok_state missing_id zero_transfer", " ");
|
|
for (i = 0; i < reasons.size; i++)
|
|
if (isDefined(reason) && reason == reasons[i])
|
|
return reasons[i];
|
|
return "unknown";
|
|
}
|
|
|
|
bank_diagnostic_number(diagnostics, key)
|
|
{
|
|
if (!isDefined(diagnostics) || typeof(diagnostics) != "array" || !isDefined(diagnostics[key]))
|
|
return "unavailable";
|
|
value = diagnostics[key];
|
|
if ((typeof(value) != "int" && typeof(value) != "float") || value < 0 || value > 2147483647)
|
|
return "unavailable";
|
|
return "" + int(value);
|
|
}
|
|
|
|
bank_http_details(diagnostics)
|
|
{
|
|
detail = "";
|
|
keys = strTok("http_status curl_code dns_ms connect_ms tls_ms total_ms", " ");
|
|
for (i = 0; i < keys.size; i++)
|
|
detail += " " + keys[i] + "=" + bank_diagnostic_number(diagnostics, keys[i]);
|
|
return detail;
|
|
}
|
|
|
|
// Fixed keys bound memory. Correlation IDs/timings never form the signature.
|
|
// Report first failure, reason/status/code changes, 30s reminders and recovery.
|
|
bank_log(key, signature, detail, failed)
|
|
{
|
|
now = getTime();
|
|
previous = level.bank_logs[key];
|
|
suppressed = 0;
|
|
recovery = "";
|
|
if (isDefined(previous))
|
|
{
|
|
suppressed = previous["suppressed"];
|
|
if (failed && previous["signature"] == signature && now >= previous["time"] && now - previous["time"] < 30000)
|
|
{
|
|
previous["suppressed"]++;
|
|
level.bank_logs[key] = previous;
|
|
return;
|
|
}
|
|
if (previous["failed"] && !failed)
|
|
recovery = " recovery=true";
|
|
}
|
|
print("[tws_bank] " + detail + recovery + " suppressed=" + suppressed);
|
|
current = [];
|
|
current["signature"] = signature;
|
|
current["time"] = now;
|
|
current["failed"] = failed;
|
|
current["suppressed"] = 0;
|
|
level.bank_logs[key] = current;
|
|
}
|
|
|
|
bank_store(body, id, decision)
|
|
{
|
|
saved = bankStore(level.bank_server, level.bank_url, body);
|
|
outcome = "success";
|
|
if (!saved)
|
|
outcome = "storage_failure";
|
|
bank_log("write_" + decision, outcome, "journal_write decision=" + decision +
|
|
" request_id=" + bank_log_token(id) + " outcome=" + outcome, !saved);
|
|
return saved;
|
|
}
|
|
|
|
bank_http_outcome(action, id, correlation, reason, code, diagnostics, failed)
|
|
{
|
|
level.bank_http_signature = reason + ":" + code + ":" +
|
|
bank_diagnostic_number(diagnostics, "http_status") + ":" + bank_diagnostic_number(diagnostics, "curl_code");
|
|
level.bank_http_detail = "correlation=" + correlation + " action=" + action +
|
|
" request_id=" + bank_log_token(id) + " outcome=" + reason + " code=" + code + bank_http_details(diagnostics);
|
|
// Settlement adds progress and cleanup outcome before logging this attempt.
|
|
if (action != "commit" && action != "cancel")
|
|
bank_log(action, level.bank_http_signature, level.bank_http_detail, failed);
|
|
}
|
|
|
|
bank_http(action, body, id)
|
|
{
|
|
level.bank_diagnostic_serial++;
|
|
correlation = "bank-" + getTime() + "-" + level.bank_diagnostic_serial;
|
|
headers = [];
|
|
headers["Content-Type"] = "application/json";
|
|
headers["Authorization"] = "Bearer " + level.bank_token;
|
|
options = [];
|
|
options["method"] = "POST";
|
|
options["headers"] = headers;
|
|
options["body"] = body;
|
|
options["connect_timeout_ms"] = 5000;
|
|
options["timeout_ms"] = 15000;
|
|
request = http::request(level.bank_url + "/" + action, options);
|
|
if (!isDefined(request))
|
|
{
|
|
bank_http_outcome(action, id, correlation, "request_creation", "none", undefined, true);
|
|
return undefined;
|
|
}
|
|
request waittill("done", response, success, error, diagnostics);
|
|
reason = "ok";
|
|
if (!isDefined(success) || !success)
|
|
reason = "transport_failure";
|
|
else if (bank_diagnostic_number(diagnostics, "http_status") == "unavailable")
|
|
reason = "missing_diagnostics";
|
|
else if (diagnostics["http_status"] != 200)
|
|
reason = "http_rejection";
|
|
else if (!isDefined(response))
|
|
reason = "missing_response";
|
|
if (reason != "ok")
|
|
{
|
|
bank_http_outcome(action, id, correlation, reason, "none", diagnostics, true);
|
|
return undefined;
|
|
}
|
|
reply = bankResponse(response);
|
|
if (!isDefined(reply) || !isDefined(reply["valid"]) || !reply["valid"])
|
|
{
|
|
reason = "unknown";
|
|
if (isDefined(reply))
|
|
reason = bank_response_reason(reply["reason"]);
|
|
bank_http_outcome(action, id, correlation, "invalid_ack_" + reason, "none", diagnostics, true);
|
|
return undefined;
|
|
}
|
|
if (reply["request_id"] != id)
|
|
{
|
|
bank_http_outcome(action, id, correlation, "request_id_mismatch", "none", diagnostics, true);
|
|
return undefined;
|
|
}
|
|
if (reply["state"] == "rejected")
|
|
reason = "application_rejection";
|
|
else if ((action == "balance" && reply["state"] != "balance") ||
|
|
((action == "deposit" || action == "withdraw") && reply["state"] != "prepared") ||
|
|
((action == "commit" || action == "cancel") && !bank_resolved(action, reply["state"])))
|
|
reason = "unexpected_state";
|
|
bank_http_outcome(action, id, correlation, reason, bank_response_code(reply["code"]), diagnostics, reason != "ok");
|
|
return reply;
|
|
}
|
|
|
|
bank_resolved(decision, state)
|
|
{
|
|
if (decision == "commit")
|
|
return state == "committed";
|
|
return state == "cancelled" || state == "rejected";
|
|
}
|
|
|
|
bank_sender()
|
|
{
|
|
delay = 1;
|
|
for (;;)
|
|
{
|
|
if (level.bank_busy || level.bank_read >= level.bank_write)
|
|
{
|
|
wait 0.1;
|
|
continue;
|
|
}
|
|
entry = level.bank_queue[level.bank_read];
|
|
entry["attempts"]++;
|
|
level.bank_queue[level.bank_read] = entry;
|
|
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
|
|
duration = int((getTime() - entry["since"]) / 1000);
|
|
progress = " decision=" + entry["decision"] + " attempt=" + entry["attempts"] +
|
|
" pending_seconds=" + duration;
|
|
outcome = "pending";
|
|
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
|
|
{
|
|
// Cleanup failures never reapply points; settlement is idempotent.
|
|
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
|
|
{
|
|
bank_log("settlement", "resolved", level.bank_http_detail + progress +
|
|
" journal_remove=success settlement=resolved next_retry_seconds=0", false);
|
|
level.bank_queue[level.bank_read] = undefined;
|
|
level.bank_read++;
|
|
if (level.bank_read == level.bank_write)
|
|
{
|
|
level.bank_queue = [];
|
|
level.bank_read = 0;
|
|
level.bank_write = 0;
|
|
}
|
|
delay = 1;
|
|
continue;
|
|
}
|
|
outcome = "journal_remove_failure";
|
|
}
|
|
bank_log("settlement", level.bank_http_signature + ":" + outcome, level.bank_http_detail + progress +
|
|
" settlement=" + outcome + " next_retry_seconds=" + delay, true);
|
|
wait delay;
|
|
delay *= 2;
|
|
if (delay > 30)
|
|
delay = 30;
|
|
}
|
|
}
|