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

This commit is contained in:
2026-10-12 20:45:41 -04:00
parent eda2c7e76d
commit 9cce0be438
19 changed files with 1646 additions and 15 deletions
+8
View File
@@ -101,3 +101,11 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
The localhost tests compile the actual HTTP utility against the host libcurl.
They do not validate Windows linking, its Schannel backend or game VM notification
handling. Those checks require the Windows build and a running Plutonium server.
## Bank support (TWS 1.1.0)
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
decisions and strict response validation for the new bank script. Build and
install the DLL, then restart the full process. No upstream pins or external
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
Portable checks: `python3 tools/test_plugin_bank.py`.
@@ -0,0 +1,71 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/bank.hpp>
namespace bank
{
namespace
{
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
{
return {"tws-bank/t6", server, endpoint};
}
void storage_error()
{
// Do not echo parse exceptions: their text can contain player data.
printf("^1[tws_bank] Outbox operation failed; check tws-bank/t6 files, disk space and permissions. Existing reports retained.\n");
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server, endpoint).load())
{
scripting::array entry;
entry["id"] = value.id;
entry["decision"] = value.decision;
entry["body"] = value.body;
entries.emplace_back(entry);
}
return entries;
}
catch (...) { storage_error(); return {}; }
});
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{
try { spool(server, endpoint).store(body); return true; }
catch (...) { storage_error(); return false; }
});
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { spool(server, endpoint).erase(id); return true; }
catch (...) { storage_error(); return false; }
});
gsc::function::add("bankresponse", [](const std::string& response)
{
const auto ack = utils::bank::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid;
result["state"] = ack.state;
result["code"] = ack.code;
result["request_id"] = ack.id;
result["amount"] = ack.amount;
result["balance"] = ack.balance;
return result;
});
}
};
}
REGISTER_COMPONENT(bank::component)
@@ -0,0 +1,175 @@
#include <stdinc.hpp>
#include "bank.hpp"
#include <cstdint>
#include <iomanip>
#include <set>
namespace utils::bank
{
namespace
{
constexpr size_t max_body_size = 0x5000;
constexpr size_t max_file_size = 16 * 1024 * 1024;
constexpr size_t max_reports = 4096;
// Stable, compact filename for arbitrary server names and endpoints.
// The envelope also checks both strings, so a collision fails closed.
std::string scope_name(const std::string& scope)
{
uint64_t hash = 14695981039346656037ull;
for (const unsigned char byte : scope)
hash = (hash ^ byte) * 1099511628211ull;
std::ostringstream result;
result << std::hex << std::setfill('0') << std::setw(16) << hash;
return result.str();
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
acknowledgement result;
if (response.empty() || response.size() >= max_body_size) return result;
const auto data = nlohmann::json::parse(response, nullptr, false);
if (!data.is_object()) return result;
try
{
if (!data.at("ok").is_boolean()) return result;
result.state = data.at("state").get<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
if (result.id.size() > 64 || result.code.size() > 64 ||
(result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected")) return {};
for (const auto* key : {"amount", "balance"})
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return {};
result.amount = data.at("amount").get<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (result.state != "rejected") ||
(result.state != "balance" && result.id.empty()) ||
((result.state == "prepared" || result.state == "committed") && result.amount == 0)) return {};
result.valid = true;
}
catch (...) { return {}; }
return result;
}
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint))
{
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
}
report outbox::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size)
throw std::runtime_error("invalid bank request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_)
throw std::runtime_error("invalid bank scope");
report value;
value.id = data.at("request_id").get<std::string>();
value.decision = data.at("decision").get<std::string>();
if (value.id.empty() || value.id.size() > 64 ||
(value.decision != "cancel" && value.decision != "commit"))
throw std::runtime_error("invalid bank decision");
value.body = body;
return value;
}
std::vector<report> outbox::load() const
{
if (!std::filesystem::exists(file_))
return {};
if (std::filesystem::file_size(file_) > max_file_size)
throw std::runtime_error("bank outbox exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream)
throw std::runtime_error("cannot open bank outbox");
const auto data = nlohmann::json::parse(stream);
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
std::vector<report> reports;
std::set<std::string> identities;
for (const auto& body : data.at("reports"))
{
auto value = decode(body.get<std::string>());
if (!identities.emplace(value.id).second)
throw std::runtime_error("duplicate identity in bank outbox");
reports.push_back(std::move(value));
}
return reports;
}
void outbox::save(const std::vector<report>& reports) const
{
if (reports.size() > max_reports)
throw std::runtime_error("bank outbox is full; delivery blocked");
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
{"reports", nlohmann::json::array()}};
for (const auto& value : reports)
data["reports"].push_back(value.body);
const auto encoded = data.dump();
if (encoded.size() > max_file_size)
throw std::runtime_error("bank outbox is full; delivery blocked");
std::filesystem::create_directories(file_.parent_path());
auto temporary = file_;
temporary += ".tmp";
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
stream.flush();
if (!stream)
throw std::runtime_error("cannot write bank outbox; old file retained");
stream.close();
if (stream.fail())
throw std::runtime_error("cannot close bank outbox; old file retained");
#ifdef _WIN32
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("cannot replace bank outbox; old file retained");
#else
// Used by the portable tests; the production plugin targets Windows.
std::filesystem::rename(temporary, file_);
#endif
}
void outbox::store(const std::string& body) const
{
const auto incoming = decode(body);
auto reports = load();
for (auto& value : reports)
{
if (value.id != incoming.id)
continue;
auto old_data = nlohmann::json::parse(value.body);
auto new_data = nlohmann::json::parse(body);
old_data.erase("decision");
new_data.erase("decision");
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
throw std::runtime_error("bank identity/decision conflict");
if (value.body != body)
{
value = incoming;
save(reports);
}
return;
}
reports.push_back(incoming);
save(reports);
}
void outbox::erase(const std::string& id) const
{
auto reports = load();
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
{
return value.id == id;
});
if (end == reports.end())
return;
reports.erase(end, reports.end());
save(reports);
}
}
@@ -0,0 +1,30 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace utils::bank
{
struct report { std::string id, decision, body; };
struct acknowledgement
{
bool valid = false;
std::string state, code, id;
int amount = 0, balance = 0;
};
acknowledgement parse_acknowledgement(const std::string& response);
// One game-thread writer; cancel intent is replaced by commit before points move.
class outbox
{
public:
outbox(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<report> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
private:
std::filesystem::path file_;
std::string server_, endpoint_;
report decode(const std::string& body) const;
void save(const std::vector<report>& reports) const;
};
}