Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
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.2 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; available: ^7" + reply["amount"]);
|
|
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;
|
|
}
|
|
}
|