/* tws_t6_bank.gsc - shared GUID bank, with durable transfer decisions. */ #include maps\mp\_utility; #include common_scripts\utility; main() { } init() { if (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"; println("[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_") { println("^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)) { println("[tws_bank] startup journal_load=failure pending=unknown bank=disabled"); return; } println("[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\zm\tws_t6_common::register_help_command(".b", "Show bank balance"); scripts\zm\tws_t6_common::register_help_command(".d ", "Deposit points"); scripts\zm\tws_t6_common::register_help_command(".w ", "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\mp\zombies\_zm_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; // Use the persistent field populated at connection, as telemetry does. guid = player.guid; if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) return undefined; // Preserve string identities without numeric casts, including large GUIDs. 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 or .w "); 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"; } println("[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; } }