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/t6", 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/t6 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;
};
}
+18 -8
View File
@@ -2,7 +2,8 @@
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
just like T5. The telemetry token is embedded in the reporter script; no
separate token config or database setup is required.
separate telemetry token config is required. The optional bank needs its own
private server token.
| File | Role | Dependencies |
| --- | --- | --- |
@@ -13,12 +14,13 @@ separate token config or database setup is required.
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.1.0 |
## Install
1. Copy all seven `tws_t6_*.gsc` files into
1. Copy all eight `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. The other five scripts
do not require that plugin.
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
@@ -29,10 +31,10 @@ If upgrading from the earlier leaderboard filename, **remove
`tws_leaderboard.gsc` from the T6 server script directory** before installing
`tws_t6_leaderboard.gsc`, so both copies do not load.
If updating an existing deployment, **remove `tws_t6_db.gsc` and
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
will not remove those old scripts. Remove any `exec server.example.cfg` added for
the earlier version. Neither a database nor that config file is used anymore.
If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
replace the old `tws_t6_bank.gsc` with this release's file. Remove any
`exec server.example.cfg` added for that legacy version; configure the new
Electric Cherry bank using the private per-server token described below.
Source `.gsc` files load directly under Plutonium's
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
@@ -40,6 +42,14 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
To omit telemetry, remove its script; disabling it at runtime does not remove
its plugin dependency during linking. The same rule applies to leaderboard.
## Player bank
The optional `tws_t6_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 t6 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
@@ -184,7 +194,7 @@ another mod installed one first.
## Validation
To compile all seven scripts for T6 PC with gsc-tool:
To compile all eight scripts for T6 PC with gsc-tool:
```powershell
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
+3 -3
View File
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' }
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
foreach ($file in $files) {
@@ -32,4 +32,4 @@ foreach ($file in $files) {
}
Write-Output "PASS $($file.Name)"
}
Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
Write-Output 'All eight T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
+328
View File
@@ -0,0 +1,328 @@
/* 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;
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://")
{
println("^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\zm\tws_t6_common::register_help_command(".b", "Show bank balance");
scripts\zm\tws_t6_common::register_help_command(".d <amount|all>", "Deposit points");
scripts\zm\tws_t6_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\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 <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;
}
}
println("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying");
wait delay;
delay *= 2;
if (delay > 30)
delay = 30;
}
}