#include #include "afk.hpp" #include #include #include #include #include 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 active; struct stopped_clock { journal::clock::time_point time; std::string reason; }; std::map 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(); 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(); } void append(std::vector& 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& 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(); value.code = data.at("code").get(); value.id = data.at("request_id").get(); if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit"); const std::set states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"}; if (!states.count(value.state)) return invalid("state"); if (data.at("ok").get() != (value.state != "rejected")) return invalid("ok_state"); if (value.state != "balance" && value.id.empty()) return invalid("missing_id"); const std::pair 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(); } 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(); } if (data.contains("session_id") && !data.at("session_id").is_null()) { value.session_id = data.at("session_id").get(); 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(); } 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(), data.at("action").get(), body}; const std::set 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 requests, sessions; for (const auto& body : data.at("requests")) { auto value = decode(body.get()); 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(),entry.at("phase").get(),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(); value.consumed_seconds = entry.at("consumed_seconds").get(); if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get() || (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(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 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(); 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() || (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(now - found->second.started).count(); return std::max(value.consumed_seconds,static_cast(std::min(static_cast(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(now - found->second.started).count(); const auto authorized_ms = static_cast(found->second.authorized_seconds) * 1000; return static_cast(std::max(0, authorized_ms - std::max(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 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))); } } }