Add persistent AFK reserves for both engines (Refs #12)
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "afk.hpp"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
|
||||
constexpr int max_seconds = 604800;
|
||||
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
|
||||
std::map<std::string, active_clock> active;
|
||||
struct stopped_clock { journal::clock::time_point time; std::string reason; };
|
||||
std::map<std::string, stopped_clock> stopped;
|
||||
std::string hash(const std::string& value)
|
||||
{
|
||||
uint64_t result = 14695981039346656037ull;
|
||||
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0') << std::setw(16) << result;
|
||||
return out.str();
|
||||
}
|
||||
bool identifier(const nlohmann::json& value)
|
||||
{
|
||||
if (!value.is_string()) return false;
|
||||
const auto& text = value.get_ref<const std::string&>();
|
||||
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
|
||||
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
|
||||
}
|
||||
bool integer(const nlohmann::json& value, int maximum = 1000000000)
|
||||
{
|
||||
return value.is_number_integer() && value >= 0 && value <= maximum;
|
||||
}
|
||||
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
|
||||
void append(std::vector<request>& requests, const request& incoming)
|
||||
{
|
||||
for (const auto& value : requests)
|
||||
if (value.id == incoming.id)
|
||||
{
|
||||
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
|
||||
throw std::runtime_error("AFK immutable request conflict");
|
||||
return;
|
||||
}
|
||||
requests.push_back(incoming);
|
||||
}
|
||||
void erase_session_requests(std::vector<request>& requests, const std::string& id)
|
||||
{
|
||||
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
|
||||
{
|
||||
const auto data = nlohmann::json::parse(value.body);
|
||||
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
|
||||
}), requests.end());
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement value;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
value.state = data.at("state").get<std::string>();
|
||||
value.code = data.at("code").get<std::string>();
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
|
||||
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
|
||||
if (!states.count(value.state)) return invalid("state");
|
||||
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
|
||||
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
|
||||
const std::pair<const char*, int acknowledgement::*> fields[] = {
|
||||
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
|
||||
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
|
||||
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
|
||||
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
|
||||
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
|
||||
for (const auto& [name, member] : fields)
|
||||
{
|
||||
if (!integer(data.at(name))) return invalid("amount_range");
|
||||
value.*member = data.at(name).get<int>();
|
||||
}
|
||||
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
|
||||
if (data.contains("awarded_seconds"))
|
||||
{
|
||||
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
|
||||
value.awarded_seconds = data.at("awarded_seconds").get<int>();
|
||||
}
|
||||
if (data.contains("session_id") && !data.at("session_id").is_null())
|
||||
{
|
||||
value.session_id = data.at("session_id").get<std::string>();
|
||||
if (value.session_id.size() > 64) return invalid("field_limit");
|
||||
}
|
||||
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
|
||||
{
|
||||
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
|
||||
value.consumed_seconds = data.at("consumed_seconds").get<int>();
|
||||
}
|
||||
if ((value.state == "reserved" || value.state == "started") &&
|
||||
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
|
||||
value.valid = true;
|
||||
value.reason = "ok";
|
||||
return value;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
|
||||
{ file_ = root / (hash(scope_) + ".json"); }
|
||||
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
|
||||
|
||||
request journal::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
|
||||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
|
||||
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
|
||||
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
|
||||
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
|
||||
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
|
||||
throw std::runtime_error("invalid AFK session identity");
|
||||
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
|
||||
throw std::runtime_error("invalid AFK reservation size");
|
||||
if (value.action == "event")
|
||||
{
|
||||
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
|
||||
throw std::runtime_error("invalid AFK activity sequence");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
|
||||
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
journal::snapshot journal::read() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_)) return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error("cannot read AFK journal");
|
||||
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("requests").is_array() || !data.at("sessions").is_array() ||
|
||||
data.at("requests").size() + data.at("sessions").size() > max_entries)
|
||||
throw std::runtime_error("invalid AFK journal; preserve for recovery");
|
||||
snapshot result;
|
||||
std::set<std::string> requests, sessions;
|
||||
for (const auto& body : data.at("requests"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
|
||||
result.requests.push_back(std::move(value));
|
||||
}
|
||||
for (const auto& entry : data.at("sessions"))
|
||||
{
|
||||
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
|
||||
const auto request = decode(value.body);
|
||||
const auto body = nlohmann::json::parse(value.body);
|
||||
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
|
||||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
|
||||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
|
||||
throw std::runtime_error("invalid AFK session");
|
||||
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
|
||||
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
|
||||
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
|
||||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
|
||||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
|
||||
(value.phase != "active" && value.consumed_seconds != 0))
|
||||
throw std::runtime_error("invalid AFK session accounting");
|
||||
result.sessions.push_back(std::move(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void journal::save(const snapshot& value) const
|
||||
{
|
||||
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
|
||||
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
|
||||
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
|
||||
for (const auto& request : value.requests) data["requests"].push_back(request.body);
|
||||
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
|
||||
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
|
||||
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 AFK journal; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; 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 AFK journal; old file retained");
|
||||
#else
|
||||
std::filesystem::rename(temporary,file_);
|
||||
#endif
|
||||
}
|
||||
std::vector<request> journal::load() const { return read().requests; }
|
||||
void journal::store(const std::string& body) const
|
||||
{
|
||||
auto value = read();
|
||||
const auto incoming = decode(body);
|
||||
// Snapshot replacement is atomic with the new snapshot. Cumulative
|
||||
// counters retain every reward during an outage without an unbounded
|
||||
// request per kill. An in-flight older HTTP request remains idempotent;
|
||||
// removing its old ID cannot remove this newer request.
|
||||
if (incoming.action == "event")
|
||||
{
|
||||
const auto next = nlohmann::json::parse(body);
|
||||
for (const auto& entry : value.requests)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) continue;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
|
||||
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
|
||||
}
|
||||
// Check reused identities before removing any superseded request.
|
||||
append(value.requests,incoming);
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) return false;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
|
||||
}),value.requests.end());
|
||||
}
|
||||
else append(value.requests,incoming);
|
||||
save(value);
|
||||
}
|
||||
void journal::erase(const std::string& id) const
|
||||
{
|
||||
auto value = read();
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
|
||||
save(value);
|
||||
}
|
||||
void journal::prepare(const std::string& body) const
|
||||
{
|
||||
const auto request = decode(body);
|
||||
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
|
||||
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
|
||||
auto value = read();
|
||||
for (const auto& existing : value.sessions)
|
||||
if (session_id(existing) == id)
|
||||
{
|
||||
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
|
||||
return;
|
||||
}
|
||||
append(value.requests,request);
|
||||
value.sessions.push_back({body,"prepared",0,0});
|
||||
save(value);
|
||||
}
|
||||
void journal::authorize(const std::string& id, int seconds) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
|
||||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
|
||||
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
void journal::begin(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
|
||||
if (session.phase == "active" && active.count(key(id))) return;
|
||||
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
|
||||
session.phase = "active";
|
||||
save(value); // Failure prevents protection; never reset an existing clock.
|
||||
// Production clocks start after the durable write; slow storage
|
||||
// must not charge time before the script can apply protection.
|
||||
if (now == clock::time_point{}) now = clock::now();
|
||||
active.emplace(key(id),active_clock{now,session.authorized_seconds});
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
|
||||
{
|
||||
const auto found = active.find(key(session_id(value)));
|
||||
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
|
||||
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
|
||||
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
|
||||
}
|
||||
int journal::remaining(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
const auto found = active.find(key(id));
|
||||
if (found == active.end()) return -1;
|
||||
if (stopped.count(key(id))) return 0;
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
|
||||
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
|
||||
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
|
||||
}
|
||||
int journal::checkpoint(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
|
||||
const auto seconds = elapsed(session,now);
|
||||
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
|
||||
return seconds;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
request journal::settlement(const session& value, const std::string& reason, int consumed) const
|
||||
{
|
||||
auto data = nlohmann::json::parse(value.body);
|
||||
const auto id = session_id(value);
|
||||
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
|
||||
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
|
||||
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
|
||||
else body["consumed_seconds"] = consumed;
|
||||
return decode(body.dump());
|
||||
}
|
||||
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
|
||||
{
|
||||
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
|
||||
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
|
||||
// The caller has already removed protection. Freeze the first stop even
|
||||
// if disk read/write fails, so retries cannot charge time after return.
|
||||
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
|
||||
auto value = read();
|
||||
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
|
||||
if (session_id(*it) == id)
|
||||
{
|
||||
const auto end = stopped.find(key(id));
|
||||
const auto stop_time = end == stopped.end() ? now : end->second.time;
|
||||
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
|
||||
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
|
||||
if ((stop_reason == "never_started" && it->phase == "active") ||
|
||||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
|
||||
throw std::runtime_error("AFK stop reason conflicts with usage");
|
||||
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
|
||||
erase_session_requests(value.requests,id);
|
||||
value.sessions.erase(it);
|
||||
save(value);
|
||||
active.erase(key(id));
|
||||
stopped.erase(key(id));
|
||||
return;
|
||||
}
|
||||
// Duplicate stop is safe after a successful durable transition.
|
||||
}
|
||||
void journal::recover(const std::string& current_match) const
|
||||
{
|
||||
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
|
||||
auto value = read();
|
||||
for (const auto& session : value.sessions)
|
||||
{
|
||||
const auto id = session_id(session);
|
||||
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
|
||||
throw std::runtime_error("cannot recover live AFK protection");
|
||||
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
|
||||
erase_session_requests(value.requests,id);
|
||||
}
|
||||
const auto sessions = value.sessions;
|
||||
value.sessions.clear();
|
||||
save(value);
|
||||
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
struct request { std::string id, action, body; };
|
||||
struct session
|
||||
{
|
||||
std::string body, phase;
|
||||
int authorized_seconds = 0, consumed_seconds = 0;
|
||||
};
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string reason = "schema", state, code, id, session_id;
|
||||
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
|
||||
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
|
||||
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
|
||||
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// Single game-thread writer. Active clocks are process-local, so recovery
|
||||
// can never accidentally replay protection after a process restart.
|
||||
class journal
|
||||
{
|
||||
public:
|
||||
using clock = std::chrono::steady_clock;
|
||||
journal(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<request> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
void prepare(const std::string& reserve_body) const;
|
||||
void authorize(const std::string& id, int seconds) const;
|
||||
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
|
||||
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
|
||||
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
|
||||
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
|
||||
// Call only at map initialization, with its new persistent match ID.
|
||||
// Active current-map sessions are rejected. Unknown crash intervals
|
||||
// remain unresolved at EC, rather than refunding spendable authority.
|
||||
void recover(const std::string& current_match) const;
|
||||
private:
|
||||
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, scope_;
|
||||
request decode(const std::string& body) const;
|
||||
snapshot read() const;
|
||||
void save(const snapshot& value) const;
|
||||
std::string key(const std::string& id) const;
|
||||
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
|
||||
request settlement(const session& value, const std::string& reason, int consumed) const;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user