Add persistent cross-game player bank (Refs #3)
This commit is contained in:
@@ -6,6 +6,47 @@ T5/T6 script and each native plugin. Current versions are recorded in
|
|||||||
|
|
||||||
## 2026-10-12
|
## 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 <amount|all>` and `.w <amount|all>` 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/<game>/` 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`
|
### 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
|
- Fix `t6/scripts/tws_t6_join.gsc` treating the initial T6 setup spawn as
|
||||||
|
|||||||
@@ -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
|
changes, the minor for compatible features, and the major for breaking changes
|
||||||
to commands, configuration, native APIs, or data contracts. Reset lower fields
|
to commands, configuration, native APIs, or data contracts. Reset lower fields
|
||||||
when increasing the minor or major. Bump once per logical change; documentation-only
|
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
|
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`
|
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` |
|
| 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 |
|
| 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` |
|
| 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` |
|
| 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 <amount|all>` to deposit spendable points, and `.w <amount|all>` 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
|
## Map record announcements
|
||||||
|
|
||||||
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
|
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
|
||||||
|
|||||||
@@ -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.
|
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
|
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.
|
handling. Those checks require the Windows build and a running Plutonium server.
|
||||||
|
|
||||||
|
## Bank support (TWS 1.1.0)
|
||||||
|
|
||||||
|
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
|
||||||
|
decisions and strict response validation for the new bank script. Build and
|
||||||
|
install the DLL, then restart the full process. No upstream pins or external
|
||||||
|
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
|
||||||
|
Portable checks: `python3 tools/test_plugin_bank.py`.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include <stdinc.hpp>
|
||||||
|
#include "loader/component_loader.hpp"
|
||||||
|
#include "gsc.hpp"
|
||||||
|
#include "game/scripting/array.hpp"
|
||||||
|
#include <utils/bank.hpp>
|
||||||
|
|
||||||
|
namespace bank
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
|
||||||
|
{
|
||||||
|
return {"tws-bank/t5", server, endpoint};
|
||||||
|
}
|
||||||
|
|
||||||
|
void storage_error()
|
||||||
|
{
|
||||||
|
// Do not echo parse exceptions: their text can contain player data.
|
||||||
|
printf("^1[tws_bank] Outbox operation failed; check tws-bank/t5 files, disk space and permissions. Existing reports retained.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class component final : public component_interface
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||||
|
{
|
||||||
|
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scripting::array entries;
|
||||||
|
for (const auto& value : spool(server, endpoint).load())
|
||||||
|
{
|
||||||
|
scripting::array entry;
|
||||||
|
entry["id"] = value.id;
|
||||||
|
entry["decision"] = value.decision;
|
||||||
|
entry["body"] = value.body;
|
||||||
|
entries.emplace_back(entry);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
catch (...) { storage_error(); return {}; }
|
||||||
|
});
|
||||||
|
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||||
|
{
|
||||||
|
try { spool(server, endpoint).store(body); return true; }
|
||||||
|
catch (...) { storage_error(); return false; }
|
||||||
|
});
|
||||||
|
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||||
|
{
|
||||||
|
try { spool(server, endpoint).erase(id); return true; }
|
||||||
|
catch (...) { storage_error(); return false; }
|
||||||
|
});
|
||||||
|
gsc::function::add("bankresponse", [](const std::string& response)
|
||||||
|
{
|
||||||
|
const auto ack = utils::bank::parse_acknowledgement(response);
|
||||||
|
scripting::array result;
|
||||||
|
result["valid"] = ack.valid;
|
||||||
|
result["state"] = ack.state;
|
||||||
|
result["code"] = ack.code;
|
||||||
|
result["request_id"] = ack.id;
|
||||||
|
result["amount"] = ack.amount;
|
||||||
|
result["balance"] = ack.balance;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
REGISTER_COMPONENT(bank::component)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#include <stdinc.hpp>
|
||||||
|
#include "bank.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
namespace utils::bank
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr size_t max_body_size = 0x5000;
|
||||||
|
constexpr size_t max_file_size = 16 * 1024 * 1024;
|
||||||
|
constexpr size_t max_reports = 4096;
|
||||||
|
|
||||||
|
// Stable, compact filename for arbitrary server names and endpoints.
|
||||||
|
// The envelope also checks both strings, so a collision fails closed.
|
||||||
|
std::string scope_name(const std::string& scope)
|
||||||
|
{
|
||||||
|
uint64_t hash = 14695981039346656037ull;
|
||||||
|
for (const unsigned char byte : scope)
|
||||||
|
hash = (hash ^ byte) * 1099511628211ull;
|
||||||
|
std::ostringstream result;
|
||||||
|
result << std::hex << std::setfill('0') << std::setw(16) << hash;
|
||||||
|
return result.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledgement parse_acknowledgement(const std::string& response)
|
||||||
|
{
|
||||||
|
acknowledgement result;
|
||||||
|
if (response.empty() || response.size() >= max_body_size) return result;
|
||||||
|
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||||
|
if (!data.is_object()) return result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!data.at("ok").is_boolean()) return result;
|
||||||
|
result.state = data.at("state").get<std::string>();
|
||||||
|
result.code = data.at("code").get<std::string>();
|
||||||
|
result.id = data.at("request_id").get<std::string>();
|
||||||
|
if (result.id.size() > 64 || result.code.size() > 64 ||
|
||||||
|
(result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
|
||||||
|
result.state != "cancelled" && result.state != "rejected")) return {};
|
||||||
|
for (const auto* key : {"amount", "balance"})
|
||||||
|
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return {};
|
||||||
|
result.amount = data.at("amount").get<int>();
|
||||||
|
result.balance = data.at("balance").get<int>();
|
||||||
|
if (data.at("ok").get<bool>() != (result.state != "rejected") ||
|
||||||
|
(result.state != "balance" && result.id.empty()) ||
|
||||||
|
((result.state == "prepared" || result.state == "committed") && result.amount == 0)) return {};
|
||||||
|
result.valid = true;
|
||||||
|
}
|
||||||
|
catch (...) { return {}; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
|
||||||
|
: server_(std::move(server)), endpoint_(std::move(endpoint))
|
||||||
|
{
|
||||||
|
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
report outbox::decode(const std::string& body) const
|
||||||
|
{
|
||||||
|
if (body.empty() || body.size() >= max_body_size)
|
||||||
|
throw std::runtime_error("invalid bank request size");
|
||||||
|
const auto data = nlohmann::json::parse(body);
|
||||||
|
if (!data.is_object() || data.at("server") != server_)
|
||||||
|
throw std::runtime_error("invalid bank scope");
|
||||||
|
report value;
|
||||||
|
value.id = data.at("request_id").get<std::string>();
|
||||||
|
value.decision = data.at("decision").get<std::string>();
|
||||||
|
if (value.id.empty() || value.id.size() > 64 ||
|
||||||
|
(value.decision != "cancel" && value.decision != "commit"))
|
||||||
|
throw std::runtime_error("invalid bank decision");
|
||||||
|
value.body = body;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<report> outbox::load() const
|
||||||
|
{
|
||||||
|
if (!std::filesystem::exists(file_))
|
||||||
|
return {};
|
||||||
|
if (std::filesystem::file_size(file_) > max_file_size)
|
||||||
|
throw std::runtime_error("bank outbox exceeds size limit");
|
||||||
|
std::ifstream stream(file_, std::ios::binary);
|
||||||
|
if (!stream)
|
||||||
|
throw std::runtime_error("cannot open bank outbox");
|
||||||
|
const auto data = nlohmann::json::parse(stream);
|
||||||
|
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
|
||||||
|
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||||
|
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
|
||||||
|
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
|
||||||
|
std::vector<report> reports;
|
||||||
|
std::set<std::string> identities;
|
||||||
|
for (const auto& body : data.at("reports"))
|
||||||
|
{
|
||||||
|
auto value = decode(body.get<std::string>());
|
||||||
|
if (!identities.emplace(value.id).second)
|
||||||
|
throw std::runtime_error("duplicate identity in bank outbox");
|
||||||
|
reports.push_back(std::move(value));
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
void outbox::save(const std::vector<report>& reports) const
|
||||||
|
{
|
||||||
|
if (reports.size() > max_reports)
|
||||||
|
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||||
|
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
|
||||||
|
{"reports", nlohmann::json::array()}};
|
||||||
|
for (const auto& value : reports)
|
||||||
|
data["reports"].push_back(value.body);
|
||||||
|
const auto encoded = data.dump();
|
||||||
|
if (encoded.size() > max_file_size)
|
||||||
|
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||||
|
std::filesystem::create_directories(file_.parent_path());
|
||||||
|
auto temporary = file_;
|
||||||
|
temporary += ".tmp";
|
||||||
|
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||||
|
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
|
||||||
|
stream.flush();
|
||||||
|
if (!stream)
|
||||||
|
throw std::runtime_error("cannot write bank outbox; old file retained");
|
||||||
|
stream.close();
|
||||||
|
if (stream.fail())
|
||||||
|
throw std::runtime_error("cannot close bank outbox; old file retained");
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||||
|
throw std::runtime_error("cannot replace bank outbox; old file retained");
|
||||||
|
#else
|
||||||
|
// Used by the portable tests; the production plugin targets Windows.
|
||||||
|
std::filesystem::rename(temporary, file_);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void outbox::store(const std::string& body) const
|
||||||
|
{
|
||||||
|
const auto incoming = decode(body);
|
||||||
|
auto reports = load();
|
||||||
|
for (auto& value : reports)
|
||||||
|
{
|
||||||
|
if (value.id != incoming.id)
|
||||||
|
continue;
|
||||||
|
auto old_data = nlohmann::json::parse(value.body);
|
||||||
|
auto new_data = nlohmann::json::parse(body);
|
||||||
|
old_data.erase("decision");
|
||||||
|
new_data.erase("decision");
|
||||||
|
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
|
||||||
|
throw std::runtime_error("bank identity/decision conflict");
|
||||||
|
if (value.body != body)
|
||||||
|
{
|
||||||
|
value = incoming;
|
||||||
|
save(reports);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reports.push_back(incoming);
|
||||||
|
save(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
void outbox::erase(const std::string& id) const
|
||||||
|
{
|
||||||
|
auto reports = load();
|
||||||
|
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
|
||||||
|
{
|
||||||
|
return value.id == id;
|
||||||
|
});
|
||||||
|
if (end == reports.end())
|
||||||
|
return;
|
||||||
|
reports.erase(end, reports.end());
|
||||||
|
save(reports);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace utils::bank
|
||||||
|
{
|
||||||
|
struct report { std::string id, decision, body; };
|
||||||
|
struct acknowledgement
|
||||||
|
{
|
||||||
|
bool valid = false;
|
||||||
|
std::string state, code, id;
|
||||||
|
int amount = 0, balance = 0;
|
||||||
|
};
|
||||||
|
acknowledgement parse_acknowledgement(const std::string& response);
|
||||||
|
// One game-thread writer; cancel intent is replaced by commit before points move.
|
||||||
|
class outbox
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
outbox(std::filesystem::path root, std::string server, std::string endpoint);
|
||||||
|
std::vector<report> load() const;
|
||||||
|
void store(const std::string& body) const;
|
||||||
|
void erase(const std::string& id) const;
|
||||||
|
private:
|
||||||
|
std::filesystem::path file_;
|
||||||
|
std::string server_, endpoint_;
|
||||||
|
report decode(const std::string& body) const;
|
||||||
|
void save(const std::vector<report>& reports) const;
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -1,7 +1,7 @@
|
|||||||
# TWS T5 Zombies scripts
|
# TWS T5 Zombies scripts
|
||||||
|
|
||||||
Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
|
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.
|
with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module.
|
||||||
|
|
||||||
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
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_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_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_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
|
## Install / upgrade
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ that directory:
|
|||||||
Do not leave both common scripts installed: duplicate connection listeners
|
Do not leave both common scripts installed: duplicate connection listeners
|
||||||
break T5's connect handshake. Existing dvar names are unchanged.
|
break T5's connect handshake. Existing dvar names are unchanged.
|
||||||
|
|
||||||
|
## Player bank
|
||||||
|
|
||||||
|
The optional `tws_t5_bank.gsc` adds `.b`, `.d <amount|all>` and
|
||||||
|
`.w <amount|all>` and registers them in help. It requires common and telemetry
|
||||||
|
`1.0.0` or newer, the TWS t5 plugin `1.1.0`, Electric Cherry's bank API and
|
||||||
|
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
|
||||||
|
including a full process restart for the plugin and a map restart for scripts.
|
||||||
|
|
||||||
## Persistent Discord text
|
## Persistent Discord text
|
||||||
|
|
||||||
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
|
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/* tws_t5_bank.gsc - shared GUID bank, with durable transfer decisions. */
|
||||||
|
#include maps\_utility;
|
||||||
|
#include common_scripts\utility;
|
||||||
|
|
||||||
|
init()
|
||||||
|
{
|
||||||
|
if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0")
|
||||||
|
return;
|
||||||
|
level.bank_url = GetDvar("tws_bank_url");
|
||||||
|
if (level.bank_url == "")
|
||||||
|
level.bank_url = "https://ec.thewrightserver.net/api/bank";
|
||||||
|
level.bank_server = GetDvar("tws_server_id");
|
||||||
|
level.bank_token = GetDvar("tws_bank_token");
|
||||||
|
// A private per-server cfg supplies this; never embed bank credentials.
|
||||||
|
if (level.bank_server == "" || level.bank_token.size < 40 ||
|
||||||
|
getSubStr(level.bank_token, 0, 8) != "ec_bank_" ||
|
||||||
|
getSubStr(level.bank_url, 0, 8) != "https://")
|
||||||
|
{
|
||||||
|
print("^1[tws_bank] Disabled: configure HTTPS, tws_server_id and a private tws_bank_token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
|
||||||
|
if (!isDefined(level.bank_queue))
|
||||||
|
return;
|
||||||
|
level.bank_read = 0;
|
||||||
|
level.bank_write = level.bank_queue.size;
|
||||||
|
level.bank_busy = false;
|
||||||
|
level.bank_ended = false;
|
||||||
|
scripts\sp\tws_t5_common::register_help_command(".b", "Show bank balance");
|
||||||
|
scripts\sp\tws_t5_common::register_help_command(".d <amount|all>", "Deposit points");
|
||||||
|
scripts\sp\tws_t5_common::register_help_command(".w <amount|all>", "Withdraw points");
|
||||||
|
level thread bank_end_watcher();
|
||||||
|
level thread bank_sender();
|
||||||
|
level thread bank_chat_listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_end_watcher()
|
||||||
|
{
|
||||||
|
level waittill_any("end_game", "game_ended", "intermission");
|
||||||
|
level.bank_ended = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_chat_listener()
|
||||||
|
{
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
level waittill("say", message, player);
|
||||||
|
if (!isDefined(player) || !isDefined(message))
|
||||||
|
continue;
|
||||||
|
args = strTok(toLower(message), " ");
|
||||||
|
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
|
||||||
|
continue;
|
||||||
|
if (level.bank_busy || level.bank_read < level.bank_write)
|
||||||
|
{
|
||||||
|
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
level.bank_busy = true;
|
||||||
|
level thread bank_request(player, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_amount(text)
|
||||||
|
{
|
||||||
|
if (text == "all")
|
||||||
|
return -1;
|
||||||
|
if (text.size == 0 || text.size > 7)
|
||||||
|
return 0;
|
||||||
|
digits = "0123456789";
|
||||||
|
for (i = 0; i < text.size; i++)
|
||||||
|
{
|
||||||
|
found = false;
|
||||||
|
for (j = 0; j < digits.size; j++)
|
||||||
|
if (text[i] == digits[j])
|
||||||
|
found = true;
|
||||||
|
if (!found)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
amount = int(text);
|
||||||
|
if (amount < 1 || amount > 1000000)
|
||||||
|
return 0;
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_playing(player)
|
||||||
|
{
|
||||||
|
return !level.bank_ended && !is_true(level.intermission) &&
|
||||||
|
isDefined(player) && isDefined(player.sessionstate) &&
|
||||||
|
player.sessionstate == "playing" && isAlive(player) &&
|
||||||
|
!(player maps\_laststand::player_is_in_laststand()) &&
|
||||||
|
isDefined(player.score) && player.score >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
|
||||||
|
bank_guard(entry)
|
||||||
|
{
|
||||||
|
self endon("tws_bank_finished");
|
||||||
|
self waittill_any("disconnect", "killed_player", "spawned_player");
|
||||||
|
entry.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_say(entry, text)
|
||||||
|
{
|
||||||
|
if (entry.active && isDefined(entry.player))
|
||||||
|
entry.player iPrintLnBold(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_request(player, args)
|
||||||
|
{
|
||||||
|
entry = spawnStruct();
|
||||||
|
entry.player = player;
|
||||||
|
entry.active = true;
|
||||||
|
player thread bank_guard(entry);
|
||||||
|
bank_transfer(entry, args);
|
||||||
|
if (entry.active && isDefined(player))
|
||||||
|
player notify("tws_bank_finished");
|
||||||
|
level.bank_busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_transfer(entry, args)
|
||||||
|
{
|
||||||
|
player = entry.player;
|
||||||
|
data = [];
|
||||||
|
data["server"] = level.bank_server;
|
||||||
|
data["guid"] = player getGuid();
|
||||||
|
if (args[0] == ".b")
|
||||||
|
{
|
||||||
|
if (args.size != 1)
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Usage: .b");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply = bank_http("balance", jsonSerialize(data), "");
|
||||||
|
if (!isDefined(reply) || reply["state"] != "balance")
|
||||||
|
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
|
||||||
|
else
|
||||||
|
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points; available: ^7" + reply["amount"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.size != 2)
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amount = bank_amount(args[1]);
|
||||||
|
if (amount == 0)
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
|
||||||
|
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operation = "withdraw";
|
||||||
|
if (args[0] == ".d")
|
||||||
|
{
|
||||||
|
operation = "deposit";
|
||||||
|
if (amount == -1)
|
||||||
|
amount = int(player.score);
|
||||||
|
if (amount <= 0 || amount > 1000000 || player.score < amount)
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capacity = 1000000 - int(player.score);
|
||||||
|
if (capacity < 0)
|
||||||
|
capacity = 0;
|
||||||
|
if (operation == "withdraw" && amount != -1 && amount > capacity)
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id = telemetryNewMatch();
|
||||||
|
if (!isDefined(id))
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data["request_id"] = id;
|
||||||
|
data["match"] = level.telemetry_match;
|
||||||
|
data["operation"] = operation;
|
||||||
|
data["amount"] = amount;
|
||||||
|
if (amount == -1)
|
||||||
|
data["amount"] = "all";
|
||||||
|
data["capacity"] = capacity;
|
||||||
|
data["decision"] = "cancel";
|
||||||
|
body = jsonSerialize(data);
|
||||||
|
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
|
||||||
|
if (!bankStore(level.bank_server, level.bank_url, body))
|
||||||
|
{
|
||||||
|
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply = bank_http(operation, body, id);
|
||||||
|
if (isDefined(reply) && reply["state"] == "prepared")
|
||||||
|
{
|
||||||
|
transfer = reply["amount"];
|
||||||
|
if (entry.active && bank_playing(player) &&
|
||||||
|
(amount == -1 || amount == transfer) &&
|
||||||
|
((operation == "deposit" && player.score >= transfer) ||
|
||||||
|
(operation == "withdraw" && player.score <= 1000000 - transfer)))
|
||||||
|
{
|
||||||
|
data["decision"] = "commit";
|
||||||
|
commit_body = jsonSerialize(data);
|
||||||
|
// No yield between recording the decision and updating the balance.
|
||||||
|
// Do not change score_total: bank funds are not earned match points.
|
||||||
|
if (bankStore(level.bank_server, level.bank_url, commit_body))
|
||||||
|
{
|
||||||
|
if (operation == "deposit")
|
||||||
|
player.score -= transfer;
|
||||||
|
else
|
||||||
|
player.score += transfer;
|
||||||
|
player.pers["score"] = player.score;
|
||||||
|
body = commit_body;
|
||||||
|
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data["decision"] = "cancel";
|
||||||
|
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
|
||||||
|
}
|
||||||
|
else if (isDefined(reply) && reply["state"] == "rejected")
|
||||||
|
bank_say(entry, bank_error(reply["code"]));
|
||||||
|
else
|
||||||
|
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
|
||||||
|
pending = [];
|
||||||
|
pending["id"] = id;
|
||||||
|
pending["body"] = body;
|
||||||
|
pending["decision"] = data["decision"];
|
||||||
|
level.bank_queue[level.bank_write] = pending;
|
||||||
|
level.bank_write++;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_error(code)
|
||||||
|
{
|
||||||
|
if (code == "insufficient_balance" || code == "empty")
|
||||||
|
return "^3Not enough bank balance. Your points are unchanged.";
|
||||||
|
if (code == "bank_limit")
|
||||||
|
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
|
||||||
|
if (code == "points_limit")
|
||||||
|
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
|
||||||
|
if (code == "not_in_match")
|
||||||
|
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
|
||||||
|
if (code == "pending")
|
||||||
|
return "^3A bank transfer is pending on another server. Try again shortly.";
|
||||||
|
return "^3Bank rejected the transfer. Your points are unchanged.";
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_http(action, body, id)
|
||||||
|
{
|
||||||
|
headers = [];
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
headers["Authorization"] = "Bearer " + level.bank_token;
|
||||||
|
options = [];
|
||||||
|
options["method"] = "POST";
|
||||||
|
options["headers"] = headers;
|
||||||
|
options["body"] = body;
|
||||||
|
options["connect_timeout_ms"] = 5000;
|
||||||
|
options["timeout_ms"] = 15000;
|
||||||
|
request = http::request(level.bank_url + "/" + action, options);
|
||||||
|
if (!isDefined(request))
|
||||||
|
return undefined;
|
||||||
|
request waittill("done", response, success, error, diagnostics);
|
||||||
|
if (!isDefined(success) || !success || !isDefined(response) ||
|
||||||
|
!isDefined(diagnostics) || !isDefined(diagnostics["http_status"]) ||
|
||||||
|
diagnostics["http_status"] != 200)
|
||||||
|
return undefined;
|
||||||
|
reply = bankResponse(response);
|
||||||
|
if (!reply["valid"] || reply["request_id"] != id)
|
||||||
|
return undefined;
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_resolved(decision, state)
|
||||||
|
{
|
||||||
|
if (decision == "commit")
|
||||||
|
return state == "committed";
|
||||||
|
return state == "cancelled" || state == "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
bank_sender()
|
||||||
|
{
|
||||||
|
delay = 1;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
if (level.bank_busy || level.bank_read >= level.bank_write)
|
||||||
|
{
|
||||||
|
wait 0.1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry = level.bank_queue[level.bank_read];
|
||||||
|
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
|
||||||
|
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
|
||||||
|
{
|
||||||
|
// Cleanup failures never reapply points; settlement is idempotent.
|
||||||
|
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
|
||||||
|
{
|
||||||
|
level.bank_queue[level.bank_read] = undefined;
|
||||||
|
level.bank_read++;
|
||||||
|
if (level.bank_read == level.bank_write)
|
||||||
|
{
|
||||||
|
level.bank_queue = [];
|
||||||
|
level.bank_read = 0;
|
||||||
|
level.bank_write = 0;
|
||||||
|
}
|
||||||
|
delay = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying");
|
||||||
|
wait delay;
|
||||||
|
delay *= 2;
|
||||||
|
if (delay > 30)
|
||||||
|
delay = 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
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.
|
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
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
|
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
|
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 |
|
| 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_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_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_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
|
## 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.
|
`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
|
in `Plutonium/plugins/` and restart the server process. The other five scripts
|
||||||
do not require that plugin.
|
do not require that plugin.
|
||||||
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
|
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_leaderboard.gsc` from the T6 server script directory** before installing
|
||||||
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
||||||
|
|
||||||
If updating an existing deployment, **remove `tws_t6_db.gsc` and
|
If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
|
||||||
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
|
replace the old `tws_t6_bank.gsc` with this release's file. Remove any
|
||||||
will not remove those old scripts. Remove any `exec server.example.cfg` added for
|
`exec server.example.cfg` added for that legacy version; configure the new
|
||||||
the earlier version. Neither a database nor that config file is used anymore.
|
Electric Cherry bank using the private per-server token described below.
|
||||||
|
|
||||||
Source `.gsc` files load directly under Plutonium's
|
Source `.gsc` files load directly under Plutonium's
|
||||||
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
|
[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
|
To omit telemetry, remove its script; disabling it at runtime does not remove
|
||||||
its plugin dependency during linking. The same rule applies to leaderboard.
|
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
|
## Persistent Discord text
|
||||||
|
|
||||||
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
|
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
|
||||||
@@ -184,7 +194,7 @@ another mod installed one first.
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
To compile all seven scripts for T6 PC with gsc-tool:
|
To compile all eight scripts for T6 PC with gsc-tool:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
||||||
$repo = Split-Path -Parent $PSScriptRoot
|
$repo = Split-Path -Parent $PSScriptRoot
|
||||||
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
|
$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')
|
$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 seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
|
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
|
$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 })
|
$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) {
|
foreach ($file in $files) {
|
||||||
@@ -32,4 +32,4 @@ foreach ($file in $files) {
|
|||||||
}
|
}
|
||||||
Write-Output "PASS $($file.Name)"
|
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.'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => 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.`);
|
||||||
@@ -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 <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\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<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> 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)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#include "bank.hpp"
|
||||||
|
#include <json.hpp>
|
||||||
|
#include <cassert>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
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 <typename F> 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 : {"", "<html>bad gateway</html>", "{", "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";
|
||||||
|
}
|
||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tws_t5_afk.gsc": "1.0.0",
|
"tws_t5_afk.gsc": "1.0.0",
|
||||||
|
"tws_t5_bank.gsc": "1.0.0",
|
||||||
"tws_t5_common.gsc": "1.1.0",
|
"tws_t5_common.gsc": "1.1.0",
|
||||||
"tws_t5_hud.gsc": "1.1.0",
|
"tws_t5_hud.gsc": "1.1.0",
|
||||||
"tws_t5_join.gsc": "1.0.0",
|
"tws_t5_join.gsc": "1.0.0",
|
||||||
@@ -7,12 +8,13 @@
|
|||||||
"tws_t5_lock.gsc": "1.0.0",
|
"tws_t5_lock.gsc": "1.0.0",
|
||||||
"tws_t5_telemetry.gsc": "1.0.0",
|
"tws_t5_telemetry.gsc": "1.0.0",
|
||||||
"tws_t6_afk.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_common.gsc": "1.1.0",
|
||||||
"tws_t6_hud.gsc": "1.1.0",
|
"tws_t6_hud.gsc": "1.1.0",
|
||||||
"tws_t6_join.gsc": "1.0.1",
|
"tws_t6_join.gsc": "1.0.1",
|
||||||
"tws_t6_leaderboard.gsc": "1.0.0",
|
"tws_t6_leaderboard.gsc": "1.0.0",
|
||||||
"tws_t6_lock.gsc": "1.0.0",
|
"tws_t6_lock.gsc": "1.0.0",
|
||||||
"tws_t6_telemetry.gsc": "1.0.0",
|
"tws_t6_telemetry.gsc": "1.0.0",
|
||||||
"t5-plugin": "1.0.0",
|
"t5-plugin": "1.1.0",
|
||||||
"t6-plugin": "1.0.0"
|
"t6-plugin": "1.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user