Add persistent AFK reserves for both engines (Refs #12)
This commit is contained in:
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
|
||||
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
|
||||
storage and native registration adapter with sanitizers and engine shims.
|
||||
|
||||
## AFK journal API (`1.4.0+`)
|
||||
|
||||
The AFK component exports API `1.0.0` for immutable requests, reservations,
|
||||
monotonic countdowns, durable stopped-session decisions and crash recovery.
|
||||
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
|
||||
credential, uses a separate `tws-afk` journal, and never stores credentials in
|
||||
that journal. Installing this native change requires a full process restart.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/afk.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
|
||||
{ return {"tws-afk/t6",server,endpoint}; }
|
||||
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
|
||||
static std::array<status,9> states;
|
||||
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
|
||||
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
|
||||
state.failed = failed; state.logged = now; state.suppressed = 0;
|
||||
}
|
||||
template <typename F> bool mutate(size_t operation, F action)
|
||||
{
|
||||
try { action(); storage_result(operation,false); return true; }
|
||||
catch (...) { storage_result(operation,true); return false; }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
|
||||
gsc::function::add("afkload", [](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["action"] = value.action; entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0,false); return entries;
|
||||
}
|
||||
catch (...) { storage_result(0,true); return {}; }
|
||||
});
|
||||
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
|
||||
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
|
||||
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
|
||||
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
|
||||
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
|
||||
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
|
||||
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return spool(server,endpoint).remaining(id); });
|
||||
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
|
||||
catch (...) { storage_result(6,true); return -1; }
|
||||
});
|
||||
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
|
||||
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
|
||||
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
|
||||
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
|
||||
gsc::function::add("afkresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::afk::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid; result["reason"] = ack.reason;
|
||||
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
|
||||
result["session_id"] = ack.session_id;
|
||||
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
|
||||
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
|
||||
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
|
||||
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
|
||||
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
|
||||
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(afk::component)
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
+17
-11
@@ -9,7 +9,7 @@ private server token.
|
||||
| --- | --- | --- |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, paginated `.help`, service window, connection and first-spawn dispatchers | Stock T6 |
|
||||
| `tws_t6_hud.gsc` | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle | Common 1.4.0+, Plutonium r5334+ |
|
||||
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.4.0+ |
|
||||
| `tws_t6_afk.gsc` | Persistent AFK reserve, rewards and central countdown | Common 1.4.0+, telemetry 1.0.0+, plugin 1.4.0+, EC #151 |
|
||||
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
|
||||
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
@@ -78,8 +78,7 @@ slim health bar at the bottom center. `N ZOMBIES` is one centered element at
|
||||
the health track's midpoint, using uniform font scale `1.25` so the whole group
|
||||
stays centered as the digit count changes. Its initial placeholder is
|
||||
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
|
||||
injured; it disappears at full health. The AFK indicator sits above the zombie
|
||||
count. There are no panel backgrounds or timer labels.
|
||||
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
|
||||
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
|
||||
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
|
||||
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
|
||||
@@ -90,7 +89,7 @@ clock. It persists across death and respawn. Clocks, health and zombie-count
|
||||
displays freeze at game over. Health fallbacks only affect the display and
|
||||
never modify gameplay max health.
|
||||
|
||||
The exact, case-insensitive `.hud` command toggles your core and AFK indicator.
|
||||
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
|
||||
It defaults visible, keeps your choice across death/respawn on the current
|
||||
connection/map, and resets visible on reconnect or map restart. Timers and data
|
||||
continue updating while hidden; game-over values remain frozen. Help, records
|
||||
@@ -99,22 +98,22 @@ and the stock HUD are unaffected. `.hud` requires the HUD module.
|
||||
Common provides one unboxed upper-right service area below the Plutonium tag.
|
||||
Records are right aligned, with `Map record | Round N` followed by up to three
|
||||
holder lines; there is no replay footer or single-page counter. Help displays
|
||||
the full nine-command list in aligned rows. Services remain private, leave the
|
||||
the installed command list in pages of nine rows in aligned rows. Services remain private, leave the
|
||||
core in place and work without the HUD module installed. See the
|
||||
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
|
||||
Only the active service view holds HUD elements: 19 for nine-command help
|
||||
(20 with a multi-page counter), or four for records (five with a multi-page
|
||||
(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page
|
||||
counter). Pages reuse elements; switching views or closing the service releases
|
||||
them. Idle services hold no HUD elements, while the controller retains pending
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 1 elements.
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
|
||||
|
||||
HUD `1.4.2` requires common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
|
||||
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
|
||||
updated HUD script and restart the map on r5334+; no full server-process
|
||||
restart is required. Older Plutonium revisions need client/server updates and
|
||||
full restarts first. AFK `1.2.0` and leaderboard `1.1.0` remain compatible;
|
||||
AFK still requires common `1.4.0+`. No new native-plugin or backend requirement.
|
||||
full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching
|
||||
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
|
||||
|
||||
## Map record announcements
|
||||
|
||||
@@ -149,8 +148,8 @@ explicit row positions so T5 does not concatenate newline-separated text.
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
|
||||
| `.afk` | Enter protected AFK using your time reserve |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
@@ -282,3 +281,10 @@ research sources and live checks are in [NOTES.md](NOTES.md).
|
||||
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
|
||||
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
|
||||
configuration, persistence, action results and deployment checks.
|
||||
|
||||
## Persistent AFK reserve
|
||||
|
||||
AFK `2.0.0` uses EC authorization and a local central countdown. See the
|
||||
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
|
||||
and live checks. Install plugin `1.4.0+` and fully restart the server process.
|
||||
The countdown remains visible with `.hud` hidden; expiration has no grace period.
|
||||
|
||||
+943
-69
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user