Add persistent AFK reserves for both engines (Refs #12)

This commit is contained in:
2026-10-17 16:56:06 -04:00
parent 7b95f604b4
commit 6781a4ed5a
22 changed files with 3840 additions and 248 deletions
@@ -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;
};
}