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
+8
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff