Add persistent cross-game player bank (Refs #3)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user