diff --git a/CHANGELOG.md b/CHANGELOG.md index 4463b53..b3009a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ T5/T6 script and each native plugin. Current versions are recorded in ## 2026-10-12 +### Persistent cross-game player bank — TWS #3 / Electric Cherry #146 + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_bank.gsc` | `1.0.0` | New `t5/scripts/tws_t5_bank.gsc` | +| `tws_t6_bank.gsc` | `1.0.0` | New `t6/scripts/tws_t6_bank.gsc` | +| `t5-plugin` | `1.1.0` | `t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` | +| `t6-plugin` | `1.1.0` | `t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` | + +- Add `.b`, `.d ` and `.w ` plus help registration on + both engines. Store shared GUID balances and ledger history through Electric + Cherry's authenticated bank API. EC generates and injects a private token per + immutable server instance, preserving it on rebuild/move and revoking it on + retirement. No manual token map is required; + the shared telemetry token does not authorize bank operations. +- Add native `bankLoad`, `bankStore`, `bankRemove` and `bankResponse` helpers. + Save cancellation before preparing a transfer, replace it with a commit + decision before applying points without yielding, and replay settlement + after map/process restarts. Invalid replies, insufficient funds, changed + player state, and failed journal writes leave spendable points unchanged. + Preserve earned `score_total`; applied withdrawals belong to the current match. +- T5 and T6 receive equivalent behavior with their own namespaces, last-stand + APIs, entry points and journal directories. Other scripts are unchanged; + existing common help registration and telemetry match keys already suffice. +- Compatibility: deploy Electric Cherry #146 first, then each game's plugin + `1.1.0` and bank script `1.0.0`, with common and telemetry `1.0.0` or newer. + EC host agents `1.9.0+` settle pending journals before destructive teardown. + No new third-party dependency, upstream pin, or loader version requirement. + Keep `tws-bank//` on persistent writable storage; preserve pending + journals when rotating credentials or restarting servers. +- Deployment: build/install both Windows x86 DLLs and **restart the full server + processes**, then install the new bank scripts and create/rebuild through EC + to inject private credentials. Restart maps after script changes. See + [bank setup and recovery](README.md#player-bank-t5-and-t6). + No deployment was performed. Windows DLL linking, GSC compilation and live + Plutonium checks remain required. +- Checks: `node tools/check_bank.mjs` exercises the actual transfer functions; + `python3 tools/test_plugin_bank.py` checks native journals, response validation, + failed writes and process recovery with sanitizers. The T6 compiler check + now includes all eight modules. + ### T6 newcomer join eligibility — `tws_t6_join.gsc` `1.0.1` - Fix `t6/scripts/tws_t6_join.gsc` treating the initial T6 setup spawn as diff --git a/README.md b/README.md index 11fdf0c..1a1ba4a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal changes, the minor for compatible features, and the major for breaking changes to commands, configuration, native APIs, or data contracts. Reset lower fields when increasing the minor or major. Bump once per logical change; documentation-only -and test-only changes are exempt. All 14 scripts and both plugins advance independently. +and test-only changes are exempt. All 16 scripts and both plugins advance independently. The initial `1.0.0` versions document the existing source baseline. These TWS versions are separate from upstream revisions in each plugin's `upstream.json` @@ -170,8 +170,64 @@ Modules currently in each tree: | lock | `t5/scripts/tws_t5_lock.gsc` | `t6/scripts/tws_t6_lock.gsc` | | telemetry | `t5/scripts/tws_t5_telemetry.gsc` | `t6/scripts/tws_t6_telemetry.gsc` — queued `game_start` / `heartbeat` / `round_change` / `game_end` matching T5 | | join | `t5/scripts/tws_t5_join.gsc` | `t6/scripts/tws_t6_join.gsc` | +| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank | | leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` | +## Player bank (T5 and T6) + +Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and +telemetry scripts (`1.0.0` or later). Players can use `.b` to read their balance, +`.d ` to deposit spendable points, and `.w ` to withdraw. +Configured bank modules register all three commands in `.help`. Balances are +shared by GUID across games and survive map/server restarts. Transfers do not +change `score_total`; bank-assisted matches still count on existing leaderboards. + +Deploy [Electric Cherry #146](https://gitea.thewrightserver.net/josh/electric-cherry/issues/146) +first, then build/install both native plugins `1.1.0` and **restart the server +processes**. The plugins provide `bankLoad`, `bankStore`, `bankRemove` and +`bankResponse`. Removing the bank script is necessary to omit these linking +requirements; disabling it by dvar does not avoid linking. + +Electric Cherry generates a unique bank token when it prepares a native server +instance and injects it through that instance's private +`electric_cherry_bank.cfg`. It sets `tws_server_id`, `tws_bank_token` and +`tws_bank_url` automatically. Rebuilds and host moves keep the instance token; +destroying the instance revokes it. Reusing a server name gets a new token. +There is no `EC_BANK_TOKENS` map or per-server secret to maintain. Keep secrets +outside shared scripts and promoted configs. The URL must use HTTPS. +Set `tws_bank_enabled 0` to disable the module; restart maps after script/config changes. +Electric Cherry's `EC_BANK_MAX_BALANCE` defaults to 250000 (range 1–1000000). +Each transfer and bank-funded spendable balance is capped at 1000000 points. + +Transfers require a living player recorded in the server's current telemetry +match. Newcomers may wait until the next 30-second heartbeat. The game validates +points and player state again after a prepare reply; failed or uncertain +prepares leave game points unchanged and cancel the reservation. After points +move, a durable commit decision retries settlement without applying them again. +A transfer in progress blocks new bank requests on that server. A GUID can +have only one prepared transfer across the fleet. + +Preserve `tws-bank/t5/` and `tws-bank/t6/`, relative to the game process working +directory, across process/container restarts, with one process per server/endpoint +directory. EC host agents **1.9.0+** settle +pending journals before deleting runtime storage on removal, rebuild, migration +or launch cancellation. Update the agents before enabling the bank. An outage +or invalid journal blocks cleanup and preserves the files. Decisions are scoped +to server name and URL and contain no token. Recoveries run before new bank requests; +failed cleanup/replies retry with the original identity. Corrupt storage +blocks the bank; failed writes prevent points changes. Restore storage or +credentials to finish pending work, rather than deleting the journal. Holds +have no automatic expiry. Already-applied withdrawals are match points and +are not refunded when a match ends or its process crashes. As with any live +score, the journal cannot make game memory and the remote database atomic +across a machine/storage failure. + +Run `node tools/check_bank.mjs` and `python3 tools/test_plugin_bank.py`. +Before rollout, build/link Windows DLLs, compile GSC, and verify both games: +help, balance and `all` commands, score HUD/persistence, max/overdraw errors, +death/respawn/disconnect during HTTP, late telemetry, lost prepare/commit +responses, disk failures and map/process restart with pending decisions. + ## Map record announcements Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6, diff --git a/t5/plugins/README.md b/t5/plugins/README.md index 600af41..d02b2e5 100644 --- a/t5/plugins/README.md +++ b/t5/plugins/README.md @@ -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`. diff --git a/t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp b/t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp new file mode 100644 index 0000000..5bfe796 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp @@ -0,0 +1,71 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "game/scripting/array.hpp" +#include + +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) diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/bank.cpp b/t5/plugins/tws-t5-gsc-utils/src/utils/bank.cpp new file mode 100644 index 0000000..d823772 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/bank.cpp @@ -0,0 +1,175 @@ +#include +#include "bank.hpp" + +#include +#include +#include + +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(); + result.code = data.at("code").get(); + result.id = data.at("request_id").get(); + 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(); + result.balance = data.at("balance").get(); + if (data.at("ok").get() != (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(); + value.decision = data.at("decision").get(); + 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 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 reports; + std::set identities; + for (const auto& body : data.at("reports")) + { + auto value = decode(body.get()); + 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& 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(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); + } +} diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/bank.hpp b/t5/plugins/tws-t5-gsc-utils/src/utils/bank.hpp new file mode 100644 index 0000000..a12602e --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/bank.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +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 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& reports) const; + }; +} diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 5d617b1..cd612b2 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -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 ` and +`.w ` 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 diff --git a/t5/scripts/tws_t5_bank.gsc b/t5/scripts/tws_t5_bank.gsc new file mode 100644 index 0000000..96c3972 --- /dev/null +++ b/t5/scripts/tws_t5_bank.gsc @@ -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 ", "Deposit points"); + scripts\sp\tws_t5_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\_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; + } + } + print("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying"); + wait delay; + delay *= 2; + if (delay > 30) + delay = 30; + } +} diff --git a/t6/plugins/README.md b/t6/plugins/README.md index 089d4cc..af8412a 100644 --- a/t6/plugins/README.md +++ b/t6/plugins/README.md @@ -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`. diff --git a/t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp b/t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp new file mode 100644 index 0000000..a7d29a3 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp @@ -0,0 +1,71 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "game/scripting/array.hpp" +#include + +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) diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/bank.cpp b/t6/plugins/tws-t6-gsc-utils/src/utils/bank.cpp new file mode 100644 index 0000000..d823772 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/bank.cpp @@ -0,0 +1,175 @@ +#include +#include "bank.hpp" + +#include +#include +#include + +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(); + result.code = data.at("code").get(); + result.id = data.at("request_id").get(); + 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(); + result.balance = data.at("balance").get(); + if (data.at("ok").get() != (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(); + value.decision = data.at("decision").get(); + 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 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 reports; + std::set identities; + for (const auto& body : data.at("reports")) + { + auto value = decode(body.get()); + 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& 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(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); + } +} diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/bank.hpp b/t6/plugins/tws-t6-gsc-utils/src/utils/bank.hpp new file mode 100644 index 0000000..a12602e --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/bank.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +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 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& reports) const; + }; +} diff --git a/t6/scripts/README.md b/t6/scripts/README.md index 7af6853..0d1928b 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -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 ` and +`.w ` 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 diff --git a/t6/scripts/tools/check.ps1 b/t6/scripts/tools/check.ps1 index 36eaecb..f094623 100644 --- a/t6/scripts/tools/check.ps1 +++ b/t6/scripts/tools/check.ps1 @@ -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.' diff --git a/t6/scripts/tws_t6_bank.gsc b/t6/scripts/tws_t6_bank.gsc new file mode 100644 index 0000000..68951c7 --- /dev/null +++ b/t6/scripts/tws_t6_bank.gsc @@ -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 ", "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; + } +} diff --git a/tools/check_bank.mjs b/tools/check_bank.mjs new file mode 100644 index 0000000..09d60b9 --- /dev/null +++ b/tools/check_bank.mjs @@ -0,0 +1,136 @@ +// Actual GSC transfer logic under portable VM/HTTP/disk shims; no game runtime. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +const root = new URL('../', import.meta.url); +function functions(source) { + const literals = []; + const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { + if (token.startsWith('/')) return ''; + literals.push(token); + return `__literal_${literals.length - 1}__`; + }); + const found = new Map(); + const headers = /^(bank_\w+)\(([^\n]*)\)\s*\n\{/gm; + for (let match; (match = headers.exec(protectedSource));) { + let end = headers.lastIndex, depth = 1; + while (depth && end < protectedSource.length) { + const ch = protectedSource[end++]; + if (ch === '{') depth++; + if (ch === '}') depth--; + } + assert.equal(depth, 0); + let body = protectedSource.slice(headers.lastIndex, end - 1); + const locals = new Set([...body.matchAll(/(? x[1])); + body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost'); + body = body.replace(/player getGuid\(\)/g, 'getGuid(player)'); + body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify('); + body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];'); + body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;'); + body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;'); + body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);'); + body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]); + body = body.replace(/player maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g, 'player.downed').replace(/wait ([^;]+);/g, 'yield $1;'); + const generator = match[1] === 'bank_sender'; + found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); + } + return found; +} + +function engine(game, options = {}) { + const source = readFileSync(new URL(`${game}/scripts/tws_${game}_bank.gsc`, root), 'utf8'); + const excluded = new Set(['bank_chat_listener','bank_end_watcher','bank_guard','bank_say','bank_request']); + const functionsByName = functions(source); + const player = { guid: '18446744073709551615', sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true }; + const entry = { player, active: true }; + const saved = [], sent = [], messages = [], order = []; + const c = vm.createContext({ + level: { bank_server: 'server', bank_url: 'https://test/api/bank', bank_token: 'ec_bank_private', bank_queue: [], bank_read: 0, bank_write: 0, + bank_busy: false, bank_ended: false, telemetry_match: 'match', telemetry_started: true }, + isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive, + int: Math.trunc, getGuid: p => p.guid, + jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))), + bank_say: (_entry, message) => messages.push(message), + telemetryNewMatch: () => 'request-1', + bankStore: (_server, _url, body) => { + const data = JSON.parse(body); order.push(data.decision); + if (options.storageFailure === data.decision) return false; + saved.splice(0, saved.length, data); return true; + }, + bankRemove: () => { if (options.removeFailure) return false; saved.length = 0; return true; }, + httpPost: (url, data) => { order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {}; }, + httpComplete: () => { + options.during?.(c, entry); + const action = sent.at(-1).url.split('/').at(-1); + const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared'; + const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : 'request-1', amount: options.amount ?? 2000, balance: 5000, code: options.code ?? 'ok' }; + return [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }]; + }, + bankResponse: data => options.malformed ? { valid: false } : JSON.parse(data), + print: () => {}, println: () => {} + }); + vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c); + return { c, player, entry, saved, sent, messages, order, run: args => c.bank_transfer(entry,args) }; +} +let checks = 0; +for (const game of ['t5','t6']) { + function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } } + test('strict positive integer syntax', () => { + const {c} = engine(game); + for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0); + assert.equal(c.bank_amount('2000'),2000); assert.equal(c.bank_amount('ALL'),0); assert.equal(c.bank_amount('all'),-1); + }); + test('deposit journals cancellation then commit before moving points', () => { + const e=engine(game); e.run(['.d','2000']); + assert.equal(e.player.score,1000); assert.equal(e.player.score_total,4000); assert.equal(e.player.pers.score,1000); + assert.deepEqual(e.order,['cancel','http','commit']); assert.equal(e.saved[0].decision,'commit'); + assert.equal(e.saved[0].guid,e.player.guid); + assert.equal(e.sent[0].headers.Authorization,'Bearer ec_bank_private'); + assert.equal(e.sent[0].timeout_ms,15000); + }); + test('all captures deposit points and resolves withdrawal amount', () => { + const e=engine(game,{amount:3000}); e.run(['.d','all']); assert.equal(e.player.score,0); + assert.equal(JSON.parse(e.sent[0].body).amount,3000); + const w=engine(game); w.run(['.w','all']); assert.equal(w.player.score,5000); + assert.equal(JSON.parse(w.sent[0].body).amount,'all'); + }); + test('failure responses never change points and retain cancellation', () => { + for (const options of [{timeout:true},{noHandle:true},{status:503},{status:401},{status:429},{missingDiagnostics:true},{malformed:true},{wrongId:true},{state:'rejected',code:'insufficient_balance'}]) { + const e=engine(game,options); e.run(['.w','2000']); assert.equal(e.player.score,3000); + assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.c.level.bank_queue[0].decision,'cancel'); + } + }); + test('storage failures never send unsaved work or move points', () => { + for (const storageFailure of ['cancel','commit']) { + const e=engine(game,{storageFailure}); e.run(['.d','2000']); assert.equal(e.player.score,3000); + if(storageFailure==='cancel') assert.equal(e.sent.length,0); else assert.equal(e.saved[0].decision,'cancel'); + } + }); + test('spending, death, disconnect, map end and point cap changes cancel prepared work', () => { + for(const during of [(_c,e)=>{e.player.score=100;},(_c,e)=>{e.player.alive=false;},(_c,e)=>{e.active=false;},(c)=>{c.level.bank_ended=true;},(_c,e)=>{e.player.downed=true;}]) { + const e=engine(game,{during}); e.run(['.d','2000']); assert.equal(e.saved[0].decision,'cancel'); + } + const e=engine(game,{during:(_c,e)=>{e.player.score=999999;}}); e.run(['.w','2000']); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.player.score,999999); + }); + test('invalid requests and unavailable match fail before HTTP', () => { + for(const args of [['.d'],['.w','1000001'],['.d','4000'],['.b','2000'],['.w','1000000']]) { + const e=engine(game); e.run(args); assert.equal(e.sent.length,0); assert.equal(e.player.score,3000); + } + const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0); + }); + test('balance reads do not journal or alter points', () => { + const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.match(e.messages[0],/5000/); + }); + test('recovered settlements cannot reapply points and retry cleanup safely', () => { + const e=engine(game); e.run(['.w','2000']); const score=e.player.score; + const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score); + const failed=engine(game,{removeFailure:true}); failed.run(['.d','2000']); const gen=failed.c.bank_sender(); gen.next(); gen.next(); + assert.equal(failed.player.score,1000); assert.equal(failed.saved[0].decision,'commit'); + }); + test('conflicting settlement states retain their journal', () => { + const {c}=engine(game); + assert(!c.bank_resolved('commit','cancelled')); assert(!c.bank_resolved('cancel','committed')); + assert(c.bank_resolved('cancel','rejected')); assert(c.bank_resolved('commit','committed')); + }); +} +console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`); diff --git a/tools/test_plugin_bank.py b/tools/test_plugin_bank.py new file mode 100644 index 0000000..ad2f78c --- /dev/null +++ b/tools/test_plugin_bank.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Test native bank storage/acknowledgments with ASan/UBSan, without the VM. + +Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header +pointing to the single header at the revision in the plugin's upstream.json. +""" +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers") +args = parser.parse_args() + +with tempfile.TemporaryDirectory(prefix="tws-bank-") as temp: + directory = Path(temp) + for game in ("t5", "t6"): + plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils" + header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp" + if not header.is_file(): + parser.error(f"Missing {header}; restore dependencies or pass --json-header") + shutil.copyfile(header, directory / "json.hpp") + (directory / "stdinc.hpp").write_text("#include \n#include \n#include \n#include \n") + executable = directory / game + subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"), + str(plugin / "src/utils/bank.cpp"), str(ROOT / "tools/tests/bank_cases.cpp"), + "-o", str(executable)], check=True) + work = directory / f"{game}-data" + subprocess.run([str(executable), str(work)], check=True) + # Separate OS processes prove snapshots/identities don't depend on VM or + # DLL memory. A newly booted producer recovers before its own start. + subprocess.run([str(executable), str(work), "produce"], check=True) + subprocess.run([str(executable), str(work), "recover"], check=True) + subprocess.run([str(executable), str(work), "empty"], check=True) + + # Compile the actual registration adapter as well, substituting only + # engine interfaces. This does not validate the Windows ABI/VM linker. + stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text() + stub = stub.replace("std::vector get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector get_keys() const") + (directory / "component_stub.hpp").write_text(stub) + component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/bank.cpp").read_text(), flags=re.MULTILINE) + source = directory / "component.cpp" + source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n' + component) + subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsyntax-only", + "-I", str(directory), "-I", str(plugin / "src/utils"), str(source)], check=True) + print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True) diff --git a/tools/tests/bank_cases.cpp b/tools/tests/bank_cases.cpp new file mode 100644 index 0000000..4edc08c --- /dev/null +++ b/tools/tests/bank_cases.cpp @@ -0,0 +1,105 @@ +#include "bank.hpp" +#include +#include +#include +#include + +namespace fs = std::filesystem; +using utils::bank::outbox; +using utils::bank::parse_acknowledgement; +const std::string server = "test-server", endpoint = "https://example.test/api/bank"; + +std::string body(const std::string& id, const std::string& decision) +{ + return nlohmann::json{{"server",server},{"guid","18446744073709551615"}, + {"match","match"},{"request_id",id},{"operation","deposit"}, + {"amount",2000},{"capacity",1000000},{"decision",decision}}.dump(); +} +template void must_fail(F action) +{ + bool failed = false; + try { action(); } catch (const std::exception&) { failed = true; } + assert(failed); +} +int main(int argc, char** argv) +{ + assert(argc >= 2); + const fs::path root(argv[1]); + const outbox queue(root,server,endpoint); + if (argc == 3) + { + const std::string mode(argv[2]); + if (mode == "produce") + { + queue.store(body("uncertain-prepare","cancel")); + queue.store(body("applied-points","cancel")); + queue.store(body("applied-points","commit")); + } + else if (mode == "recover") + { + const auto entries = queue.load(); + assert(entries.size() == 2); + assert(entries[0].decision == "cancel" && entries[1].decision == "commit"); + for (const auto& entry : entries) queue.erase(entry.id); + } + else assert(mode == "empty" && queue.load().empty()); + return 0; + } + fs::create_directories(root); + assert(queue.load().empty()); + queue.store(body("one","cancel")); + queue.store(body("one","cancel")); + assert(queue.load().size() == 1); + queue.store(body("one","commit")); + queue.store(body("one","commit")); + assert(queue.load()[0].decision == "commit"); + must_fail([&]{queue.store(body("one","cancel"));}); + auto changed = nlohmann::json::parse(body("one","commit")); + changed["amount"] = 100; + must_fail([&]{queue.store(changed.dump());}); + assert(queue.load()[0].body == body("one","commit")); + const outbox isolated(root,"another-server",endpoint); + assert(isolated.load().empty()); + const outbox another_endpoint(root,server,endpoint+"/other"); + assert(another_endpoint.load().empty()); + // An interrupted temp write cannot replace the committed decision. + const auto file = fs::directory_iterator(root)->path(); + { std::ofstream stream(file.string()+".tmp"); stream << "incomplete"; } + assert(queue.load()[0].decision == "commit"); + fs::remove(file.string()+".tmp"); + // A failed replacement preserves cancellation and must stop points moving. + queue.erase("one"); + queue.store(body("two","cancel")); + fs::create_directory(file.string()+".tmp"); + must_fail([&]{queue.store(body("two","commit"));}); + assert(queue.load()[0].decision == "cancel"); + fs::remove(file.string()+".tmp"); + queue.erase("two"); + queue.erase("two"); + assert(queue.load().empty()); + auto valid = nlohmann::json{{"ok",true},{"state","prepared"},{"request_id","one"}, + {"code","ok"},{"amount",2000},{"balance",3000}}; + const auto ack = parse_acknowledgement(valid.dump()); + assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one"); + for (const auto& field : {"amount","balance"}) + for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)}) + { + auto value = valid; value[field] = bad; + assert(!parse_acknowledgement(value.dump()).valid); + } + auto bad = valid; bad["ok"] = false; + assert(!parse_acknowledgement(bad.dump()).valid); + bad = valid; bad["request_id"] = ""; + assert(!parse_acknowledgement(bad.dump()).valid); + bad = valid; bad["state"] = "unknown"; + assert(!parse_acknowledgement(bad.dump()).valid); + for (const auto& text : {"", "bad gateway", "{", "null", "{\"ok\":true}"}) + assert(!parse_acknowledgement(text).valid); + assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid); + { std::ofstream stream(file); stream << "corrupt"; } + must_fail([&]{queue.load();}); + must_fail([&]{queue.store(body("three","cancel"));}); + must_fail([&]{queue.erase("three");}); + fs::remove(file); + std::cout << "bank decisions, corruption, cancellation, response guards passed\n"; +} diff --git a/versions.json b/versions.json index 1ddcafc..4566693 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "tws_t5_afk.gsc": "1.0.0", + "tws_t5_bank.gsc": "1.0.0", "tws_t5_common.gsc": "1.1.0", "tws_t5_hud.gsc": "1.1.0", "tws_t5_join.gsc": "1.0.0", @@ -7,12 +8,13 @@ "tws_t5_lock.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0", "tws_t6_afk.gsc": "1.0.0", + "tws_t6_bank.gsc": "1.0.0", "tws_t6_common.gsc": "1.1.0", "tws_t6_hud.gsc": "1.1.0", "tws_t6_join.gsc": "1.0.1", "tws_t6_leaderboard.gsc": "1.0.0", "tws_t6_lock.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0", - "t5-plugin": "1.0.0", - "t6-plugin": "1.0.0" + "t5-plugin": "1.1.0", + "t6-plugin": "1.1.0" }