Add persistent cross-game player bank (Refs #3)
This commit is contained in:
@@ -101,3 +101,11 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
|
||||
The localhost tests compile the actual HTTP utility against the host libcurl.
|
||||
They do not validate Windows linking, its Schannel backend or game VM notification
|
||||
handling. Those checks require the Windows build and a running Plutonium server.
|
||||
|
||||
## Bank support (TWS 1.1.0)
|
||||
|
||||
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
|
||||
decisions and strict response validation for the new bank script. Build and
|
||||
install the DLL, then restart the full process. No upstream pins or external
|
||||
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py`.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/bank.hpp>
|
||||
|
||||
namespace bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
|
||||
{
|
||||
return {"tws-bank/t6", server, endpoint};
|
||||
}
|
||||
|
||||
void storage_error()
|
||||
{
|
||||
// Do not echo parse exceptions: their text can contain player data.
|
||||
printf("^1[tws_bank] Outbox operation failed; check tws-bank/t6 files, disk space and permissions. Existing reports retained.\n");
|
||||
}
|
||||
}
|
||||
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server, endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id;
|
||||
entry["decision"] = value.decision;
|
||||
entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
catch (...) { storage_error(); return {}; }
|
||||
});
|
||||
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{
|
||||
try { spool(server, endpoint).store(body); return true; }
|
||||
catch (...) { storage_error(); return false; }
|
||||
});
|
||||
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { spool(server, endpoint).erase(id); return true; }
|
||||
catch (...) { storage_error(); return false; }
|
||||
});
|
||||
gsc::function::add("bankresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::bank::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid;
|
||||
result["state"] = ack.state;
|
||||
result["code"] = ack.code;
|
||||
result["request_id"] = ack.id;
|
||||
result["amount"] = ack.amount;
|
||||
result["balance"] = ack.balance;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
REGISTER_COMPONENT(bank::component)
|
||||
@@ -0,0 +1,175 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "bank.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <set>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000;
|
||||
constexpr size_t max_file_size = 16 * 1024 * 1024;
|
||||
constexpr size_t max_reports = 4096;
|
||||
|
||||
// Stable, compact filename for arbitrary server names and endpoints.
|
||||
// The envelope also checks both strings, so a collision fails closed.
|
||||
std::string scope_name(const std::string& scope)
|
||||
{
|
||||
uint64_t hash = 14695981039346656037ull;
|
||||
for (const unsigned char byte : scope)
|
||||
hash = (hash ^ byte) * 1099511628211ull;
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0') << std::setw(16) << hash;
|
||||
return result.str();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
acknowledgement result;
|
||||
if (response.empty() || response.size() >= max_body_size) return result;
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (!data.is_object()) return result;
|
||||
try
|
||||
{
|
||||
if (!data.at("ok").is_boolean()) return result;
|
||||
result.state = data.at("state").get<std::string>();
|
||||
result.code = data.at("code").get<std::string>();
|
||||
result.id = data.at("request_id").get<std::string>();
|
||||
if (result.id.size() > 64 || result.code.size() > 64 ||
|
||||
(result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
|
||||
result.state != "cancelled" && result.state != "rejected")) return {};
|
||||
for (const auto* key : {"amount", "balance"})
|
||||
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return {};
|
||||
result.amount = data.at("amount").get<int>();
|
||||
result.balance = data.at("balance").get<int>();
|
||||
if (data.at("ok").get<bool>() != (result.state != "rejected") ||
|
||||
(result.state != "balance" && result.id.empty()) ||
|
||||
((result.state == "prepared" || result.state == "committed") && result.amount == 0)) return {};
|
||||
result.valid = true;
|
||||
}
|
||||
catch (...) { return {}; }
|
||||
return result;
|
||||
}
|
||||
|
||||
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint))
|
||||
{
|
||||
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
|
||||
}
|
||||
|
||||
report outbox::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size)
|
||||
throw std::runtime_error("invalid bank request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_)
|
||||
throw std::runtime_error("invalid bank scope");
|
||||
report value;
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
value.decision = data.at("decision").get<std::string>();
|
||||
if (value.id.empty() || value.id.size() > 64 ||
|
||||
(value.decision != "cancel" && value.decision != "commit"))
|
||||
throw std::runtime_error("invalid bank decision");
|
||||
value.body = body;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::vector<report> outbox::load() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_))
|
||||
return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size)
|
||||
throw std::runtime_error("bank outbox exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot open bank outbox");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
|
||||
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
|
||||
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
|
||||
std::vector<report> reports;
|
||||
std::set<std::string> identities;
|
||||
for (const auto& body : data.at("reports"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!identities.emplace(value.id).second)
|
||||
throw std::runtime_error("duplicate identity in bank outbox");
|
||||
reports.push_back(std::move(value));
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
void outbox::save(const std::vector<report>& reports) const
|
||||
{
|
||||
if (reports.size() > max_reports)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
|
||||
{"reports", nlohmann::json::array()}};
|
||||
for (const auto& value : reports)
|
||||
data["reports"].push_back(value.body);
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_;
|
||||
temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot write bank outbox; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail())
|
||||
throw std::runtime_error("cannot close bank outbox; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace bank outbox; old file retained");
|
||||
#else
|
||||
// Used by the portable tests; the production plugin targets Windows.
|
||||
std::filesystem::rename(temporary, file_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void outbox::store(const std::string& body) const
|
||||
{
|
||||
const auto incoming = decode(body);
|
||||
auto reports = load();
|
||||
for (auto& value : reports)
|
||||
{
|
||||
if (value.id != incoming.id)
|
||||
continue;
|
||||
auto old_data = nlohmann::json::parse(value.body);
|
||||
auto new_data = nlohmann::json::parse(body);
|
||||
old_data.erase("decision");
|
||||
new_data.erase("decision");
|
||||
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
|
||||
throw std::runtime_error("bank identity/decision conflict");
|
||||
if (value.body != body)
|
||||
{
|
||||
value = incoming;
|
||||
save(reports);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reports.push_back(incoming);
|
||||
save(reports);
|
||||
}
|
||||
|
||||
void outbox::erase(const std::string& id) const
|
||||
{
|
||||
auto reports = load();
|
||||
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
|
||||
{
|
||||
return value.id == id;
|
||||
});
|
||||
if (end == reports.end())
|
||||
return;
|
||||
reports.erase(end, reports.end());
|
||||
save(reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
struct report { std::string id, decision, body; };
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string state, code, id;
|
||||
int amount = 0, balance = 0;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// One game-thread writer; cancel intent is replaced by commit before points move.
|
||||
class outbox
|
||||
{
|
||||
public:
|
||||
outbox(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<report> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_;
|
||||
report decode(const std::string& body) const;
|
||||
void save(const std::vector<report>& reports) const;
|
||||
};
|
||||
}
|
||||
+18
-8
@@ -2,7 +2,8 @@
|
||||
|
||||
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
|
||||
just like T5. The telemetry token is embedded in the reporter script; no
|
||||
separate token config or database setup is required.
|
||||
separate telemetry token config is required. The optional bank needs its own
|
||||
private server token.
|
||||
|
||||
| File | Role | Dependencies |
|
||||
| --- | --- | --- |
|
||||
@@ -13,12 +14,13 @@ separate token config or database setup is required.
|
||||
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
|
||||
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.1.0 |
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy all seven `tws_t6_*.gsc` files into
|
||||
1. Copy all eight `tws_t6_*.gsc` files into
|
||||
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
|
||||
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
|
||||
2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
|
||||
in `Plutonium/plugins/` and restart the server process. The other five scripts
|
||||
do not require that plugin.
|
||||
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
|
||||
@@ -29,10 +31,10 @@ If upgrading from the earlier leaderboard filename, **remove
|
||||
`tws_leaderboard.gsc` from the T6 server script directory** before installing
|
||||
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
||||
|
||||
If updating an existing deployment, **remove `tws_t6_db.gsc` and
|
||||
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
|
||||
will not remove those old scripts. Remove any `exec server.example.cfg` added for
|
||||
the earlier version. Neither a database nor that config file is used anymore.
|
||||
If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
|
||||
replace the old `tws_t6_bank.gsc` with this release's file. Remove any
|
||||
`exec server.example.cfg` added for that legacy version; configure the new
|
||||
Electric Cherry bank using the private per-server token described below.
|
||||
|
||||
Source `.gsc` files load directly under Plutonium's
|
||||
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
|
||||
@@ -40,6 +42,14 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
|
||||
To omit telemetry, remove its script; disabling it at runtime does not remove
|
||||
its plugin dependency during linking. The same rule applies to leaderboard.
|
||||
|
||||
## Player bank
|
||||
|
||||
The optional `tws_t6_bank.gsc` adds `.b`, `.d <amount|all>` and
|
||||
`.w <amount|all>` and registers them in help. It requires common and telemetry
|
||||
`1.0.0` or newer, the TWS t6 plugin `1.1.0`, Electric Cherry's bank API and
|
||||
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
|
||||
including a full process restart for the plugin and a map restart for scripts.
|
||||
|
||||
## Persistent Discord text
|
||||
|
||||
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
|
||||
@@ -184,7 +194,7 @@ another mod installed one first.
|
||||
|
||||
## Validation
|
||||
|
||||
To compile all seven scripts for T6 PC with gsc-tool:
|
||||
To compile all eight scripts for T6 PC with gsc-tool:
|
||||
|
||||
```powershell
|
||||
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user