/* 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"); // 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"); return; } level.bank_queue = bankLoad(level.bank_server, level.bank_url); if (!isDefined(level.bank_queue)) return; 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_transfer(entry, args) { player = entry.player; data = []; data["server"] = level.bank_server; data["guid"] = player getGuid(); 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); // Persist cancellation BEFORE prepare can reach EC, including lost replies. if (!bankStore(level.bank_server, level.bank_url, body)) { 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 (bankStore(level.bank_server, level.bank_url, commit_body)) { 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"]; 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."; } bank_http(action, body, id) { 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)) return undefined; request waittill("done", response, success, error, diagnostics); if (!isDefined(success) || !success || !isDefined(response) || !isDefined(diagnostics) || !isDefined(diagnostics["http_status"]) || diagnostics["http_status"] != 200) return undefined; reply = bankResponse(response); if (!reply["valid"] || reply["request_id"] != id) return undefined; 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]; reply = bank_http(entry["decision"], entry["body"], entry["id"]); 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"])) { 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; } } println("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying"); wait delay; delay *= 2; if (delay > 30) delay = 30; } }