Add persistent cross-game player bank (Refs #3)

This commit is contained in:
2026-10-12 20:45:41 -04:00
parent eda2c7e76d
commit 9cce0be438
19 changed files with 1646 additions and 15 deletions
+8
View File
@@ -101,3 +101,11 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
The localhost tests compile the actual HTTP utility against the host libcurl.
They do not validate Windows linking, its Schannel backend or game VM notification
handling. Those checks require the Windows build and a running Plutonium server.
## Bank support (TWS 1.1.0)
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
decisions and strict response validation for the new bank script. Build and
install the DLL, then restart the full process. No upstream pins or external
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
Portable checks: `python3 tools/test_plugin_bank.py`.
@@ -0,0 +1,71 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/bank.hpp>
namespace bank
{
namespace
{
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
{
return {"tws-bank/t5", server, endpoint};
}
void storage_error()
{
// Do not echo parse exceptions: their text can contain player data.
printf("^1[tws_bank] Outbox operation failed; check tws-bank/t5 files, disk space and permissions. Existing reports retained.\n");
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server, endpoint).load())
{
scripting::array entry;
entry["id"] = value.id;
entry["decision"] = value.decision;
entry["body"] = value.body;
entries.emplace_back(entry);
}
return entries;
}
catch (...) { storage_error(); return {}; }
});
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{
try { spool(server, endpoint).store(body); return true; }
catch (...) { storage_error(); return false; }
});
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { spool(server, endpoint).erase(id); return true; }
catch (...) { storage_error(); return false; }
});
gsc::function::add("bankresponse", [](const std::string& response)
{
const auto ack = utils::bank::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid;
result["state"] = ack.state;
result["code"] = ack.code;
result["request_id"] = ack.id;
result["amount"] = ack.amount;
result["balance"] = ack.balance;
return result;
});
}
};
}
REGISTER_COMPONENT(bank::component)
@@ -0,0 +1,175 @@
#include <stdinc.hpp>
#include "bank.hpp"
#include <cstdint>
#include <iomanip>
#include <set>
namespace utils::bank
{
namespace
{
constexpr size_t max_body_size = 0x5000;
constexpr size_t max_file_size = 16 * 1024 * 1024;
constexpr size_t max_reports = 4096;
// Stable, compact filename for arbitrary server names and endpoints.
// The envelope also checks both strings, so a collision fails closed.
std::string scope_name(const std::string& scope)
{
uint64_t hash = 14695981039346656037ull;
for (const unsigned char byte : scope)
hash = (hash ^ byte) * 1099511628211ull;
std::ostringstream result;
result << std::hex << std::setfill('0') << std::setw(16) << hash;
return result.str();
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
acknowledgement result;
if (response.empty() || response.size() >= max_body_size) return result;
const auto data = nlohmann::json::parse(response, nullptr, false);
if (!data.is_object()) return result;
try
{
if (!data.at("ok").is_boolean()) return result;
result.state = data.at("state").get<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
if (result.id.size() > 64 || result.code.size() > 64 ||
(result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected")) return {};
for (const auto* key : {"amount", "balance"})
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return {};
result.amount = data.at("amount").get<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (result.state != "rejected") ||
(result.state != "balance" && result.id.empty()) ||
((result.state == "prepared" || result.state == "committed") && result.amount == 0)) return {};
result.valid = true;
}
catch (...) { return {}; }
return result;
}
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint))
{
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
}
report outbox::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size)
throw std::runtime_error("invalid bank request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_)
throw std::runtime_error("invalid bank scope");
report value;
value.id = data.at("request_id").get<std::string>();
value.decision = data.at("decision").get<std::string>();
if (value.id.empty() || value.id.size() > 64 ||
(value.decision != "cancel" && value.decision != "commit"))
throw std::runtime_error("invalid bank decision");
value.body = body;
return value;
}
std::vector<report> outbox::load() const
{
if (!std::filesystem::exists(file_))
return {};
if (std::filesystem::file_size(file_) > max_file_size)
throw std::runtime_error("bank outbox exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream)
throw std::runtime_error("cannot open bank outbox");
const auto data = nlohmann::json::parse(stream);
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
std::vector<report> reports;
std::set<std::string> identities;
for (const auto& body : data.at("reports"))
{
auto value = decode(body.get<std::string>());
if (!identities.emplace(value.id).second)
throw std::runtime_error("duplicate identity in bank outbox");
reports.push_back(std::move(value));
}
return reports;
}
void outbox::save(const std::vector<report>& reports) const
{
if (reports.size() > max_reports)
throw std::runtime_error("bank outbox is full; delivery blocked");
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
{"reports", nlohmann::json::array()}};
for (const auto& value : reports)
data["reports"].push_back(value.body);
const auto encoded = data.dump();
if (encoded.size() > max_file_size)
throw std::runtime_error("bank outbox is full; delivery blocked");
std::filesystem::create_directories(file_.parent_path());
auto temporary = file_;
temporary += ".tmp";
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
stream.flush();
if (!stream)
throw std::runtime_error("cannot write bank outbox; old file retained");
stream.close();
if (stream.fail())
throw std::runtime_error("cannot close bank outbox; old file retained");
#ifdef _WIN32
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("cannot replace bank outbox; old file retained");
#else
// Used by the portable tests; the production plugin targets Windows.
std::filesystem::rename(temporary, file_);
#endif
}
void outbox::store(const std::string& body) const
{
const auto incoming = decode(body);
auto reports = load();
for (auto& value : reports)
{
if (value.id != incoming.id)
continue;
auto old_data = nlohmann::json::parse(value.body);
auto new_data = nlohmann::json::parse(body);
old_data.erase("decision");
new_data.erase("decision");
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
throw std::runtime_error("bank identity/decision conflict");
if (value.body != body)
{
value = incoming;
save(reports);
}
return;
}
reports.push_back(incoming);
save(reports);
}
void outbox::erase(const std::string& id) const
{
auto reports = load();
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
{
return value.id == id;
});
if (end == reports.end())
return;
reports.erase(end, reports.end());
save(reports);
}
}
@@ -0,0 +1,30 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace utils::bank
{
struct report { std::string id, decision, body; };
struct acknowledgement
{
bool valid = false;
std::string state, code, id;
int amount = 0, balance = 0;
};
acknowledgement parse_acknowledgement(const std::string& response);
// One game-thread writer; cancel intent is replaced by commit before points move.
class outbox
{
public:
outbox(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<report> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
private:
std::filesystem::path file_;
std::string server_, endpoint_;
report decode(const std::string& body) const;
void save(const std::vector<report>& reports) const;
};
}
+10 -1
View File
@@ -1,7 +1,7 @@
# TWS T5 Zombies scripts
Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
(Black Ops 1) zombies dedicated servers. Split across seven `.gsc` files,
(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files,
with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module.
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
@@ -17,6 +17,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
## Install / upgrade
@@ -31,6 +32,14 @@ that directory:
Do not leave both common scripts installed: duplicate connection listeners
break T5's connect handshake. Existing dvar names are unchanged.
## Player bank
The optional `tws_t5_bank.gsc` adds `.b`, `.d <amount|all>` and
`.w <amount|all>` and registers them in help. It requires common and telemetry
`1.0.0` or newer, the TWS t5 plugin `1.1.0`, Electric Cherry's bank API and
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
including a full process restart for the plugin and a map restart for scripts.
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
+324
View File
@@ -0,0 +1,324 @@
/* 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;
level.bank_url = GetDvar("tws_bank_url");
if (level.bank_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_" ||
getSubStr(level.bank_url, 0, 8) != "https://")
{
print("^1[tws_bank] Disabled: configure HTTPS, 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\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_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 <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);
// 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;
}
}
print("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying");
wait delay;
delay *= 2;
if (delay > 30)
delay = 30;
}
}