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
+37
View File
@@ -6,6 +6,43 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-17
### Persistent AFK reserve and immediate expiration (Refs #12)
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.0.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.0.0` | `t6/scripts/tws_t6_afk.gsc` |
| `t5-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
| `t6-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
- Replace unlimited AFK with shared GUID reserves, authenticated authorization,
explicit bank purchase confirmation, real contribution rewards and durable
request/session journals. Both engines use matching contracts and behavior.
- Show an independent central countdown, final-minute urgency and audible warnings.
Zero immediately releases AFK protection and owned controls/weapons. Manual
`.back` retains its separate two-second grace; it cannot renew an AFK session.
Reject downed activation (including T5), stock restrictions and last-active-player
entry. End the match when only AFK players remain, respecting stock recovery.
- Award each successful revive, including repeated revives of one teammate. Count
kill/headshot events, round participation and active engagement without using
score changes. EC owns configurable rates, caps, persistent progress and atomic
bank conversion; no second points bank is introduced.
- Compatibility: AFK `2.0.0` requires matching TWS plugin `1.4.0+` (native AFK API
`1.0.0`), common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+ and the EC #151
endpoints. EC host agent `1.12.0+` must be deployed before activation. Existing
private `tws_bank_token` and `tws_server_id` authenticate requests. There is no
new upstream dependency pin or native loader `plugin_version()` requirement.
- Deployment: deploy EC and upgraded host agents first; build/install both plugin
DLLs and matching scripts, retain persistent `tws-afk` storage, then **fully
restart each game-server process**. No deployment, Windows DLL build or live
Plutonium validation is claimed. See [AFK operation and validation](docs/afk.md).
- Validation: both engines pass native AFK journal/adapter tests with ASan/UBSan,
39 AFK behavior checks, 14 core HUD and 56 service HUD checks. All nine T6 scripts
compiled/assembled with upstream gsc-tool `8f1d6bd` T6 PC compiler/assembler
libraries. Script and integration checks are documented in the AFK guide; final
in-game T5/T6 validation remains required. Unchanged common/HUD/bank/telemetry
scripts retain their individual versions.
### Center the complete zombie count above the health bar
| Component | Version | Affected source |
+16 -9
View File
@@ -368,7 +368,7 @@ count centered near the bottom above a slim health bar. The complete
keeping the group centered as the count gains or loses digits. It uses a
uniform font scale of `1.25` and shows `-- ZOMBIES` before data is available.
Current/maximum HP appears to the right of the bar only while injured, then
disappears at full health. The AFK indicator sits above the zombie count. Text
disappears at full health. The AFK reserve countdown is independently centered on screen. Text
uses an outline for contrast; there are no panel backgrounds or timer labels.
Fullscreen X/Y anchors bypass safe-area insets. Overlay positions and shader
@@ -386,7 +386,7 @@ health and zombie-count displays freeze at game over. Health display fallbacks
never modify gameplay max health.
Type `.hud` in chat to hide or show your core, including its branding, timers,
zombie count, health bar, damage-only HP and AFK indicator. The exact command
zombie count, health bar and damage-only HP. The AFK countdown stays visible. The exact command
is case-insensitive. Visibility defaults on and persists across death/respawn
within the current connection/map; reconnecting or restarting the map restores
the visible default. Data and timers continue updating while hidden, so showing
@@ -394,7 +394,7 @@ the HUD restores current values or the frozen game-over values. Help, records
and the stock HUD remain unaffected.
Common owns one unboxed service area at the upper right. `.help` and its
`.commands` alias show all nine installed commands in one list with command
`.commands` alias show installed commands in pages of up to nine rows with command
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
@@ -414,8 +414,7 @@ remain private and work when the HUD module is omitted.
Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's
elements. The core uses 10 archived elements, plus one for AFK. The standard
nine-command help view uses 19 nonarchived elements, or 20 with a multi-page
elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements
still occupy slots. The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
@@ -427,8 +426,8 @@ avoiding dynamic counter strings in the text config-string pool. Numeric HP
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
the map; no full server-process restart is required. On older Plutonium
revisions, update clients and server and fully restart them before deploying.
AFK `1.2.0` and leaderboard `1.1.0` remain compatible; AFK still requires
common `1.4.0+`. There is no new native-plugin or backend requirement. Existing
Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires plugin `1.4.0+`,
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
leaderboard plugin requirements still apply. Common retains its older panel
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
with the same integer shader-width handling.
@@ -462,7 +461,7 @@ Before rollout, validate both engines in Plutonium:
appear only while injured and disappear on full healing. Toggle AFK and back
without overlapping zombies/health or changing existing protection behavior.
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
one list of nine commands, full bank syntax, no single-page counter and
the installed commands including AFK balance/buy, full bank syntax and a page counter and
automatic dismissal after 12 seconds. Add custom registered commands to
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
Remove optional modules and restart the map to confirm filtering, including
@@ -476,7 +475,7 @@ Before rollout, validate both engines in Plutonium:
surviving service windows. Confirm `.join`, bank commands and lobby lock
retain their existing behavior with their required modules installed.
6. Toggle `.hud` and `.HUD` before spawning, during play, while AFK and after
game over. Check private visibility, all core elements and the AFK indicator,
game over. Check private visibility, all core elements and the independently visible AFK countdown,
continued updates while hidden, and unchanged help/records/stock HUD. Verify
death/respawn preserves the choice and reconnect/map restart restores visibility.
7. On T5, watch the first automatic record announcement and its dismissal, then
@@ -610,3 +609,11 @@ policy persistence, command-result limits and deployment validation. New
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
configuration it stays disabled. Existing bank and telemetry credentials are
never reused. Native plugin updates require a full game-server process restart.
## Persistent AFK reserves (T5 and T6)
See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior,
recovery, coordinated EC deployment and the required live validation. AFK `2.0.0`
requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`,
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
is required when installing the updated plugins.
+196
View File
@@ -0,0 +1,196 @@
# Persistent AFK reserve
AFK `2.0.0` replaces unlimited protected breaks on both T5 and T6. A GUID owns
one free allowance and saved reserve across servers, games, maps and reconnects.
Electric Cherry owns balances, policy, purchase transactions and the time ledger.
The game owns verified contribution events, player commands, countdown rendering,
protection and accepted stop decisions. This uses the existing bank account and
private instance credential, not a second points bank.
## Commands and behavior
| Command | Result |
| --- | --- |
| `.afk` | Reserve available time, acknowledge its start, then enter protected AFK |
| `.back` | Stop time accounting and return, preserving the existing two-second manual grace |
| `.afk balance` | Show free, saved and currently reserved seconds as a clock |
| `.afk buy 10` | Quote ten minutes and its bank-point price |
| `.afk buy 10 confirm` | Accept that quote within 30 seconds |
Return with `.back` before purchasing; an existing reservation never extends
automatically. Purchases require whole policy packs; the initial pack is 10 minutes for 10,000
points. There is no automatic purchase. EC atomically rejects insufficient funds,
pending bank transfers, changed quotes and over-capacity purchases without a
point debit. A submitted purchase retains its ID until EC acknowledges it.
The central countdown and “Protection ends when this timer reaches zero.” remain
visible even with `.hud` hidden. The final minute is larger and red; warnings
sound at 60, 30, and each of the final ten seconds. At zero, AFK protection ends
immediately, including in the damage callback; the script restores its own controls,
weapons and zombie-targeting state. Expiration has no spectator transition, purchase
or extra grace. Repeating `.afk` does not restart the timer or reserve twice.
Entry rejects downed/dead/spectating players, stock protection/control restrictions,
and the last active player. A default 15-second reentry cooldown prevents repeated
combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage
callback passes through when it does not own protection, preserving stock game
invulnerability. Other mods that restrict controls should set
`player.tws_afk_external_controls` while their restriction applies.
AFK leaves player session state unchanged, so stock player-count zombie scaling
continues to include AFK players. If the last non-AFK active player dies or leaves,
the scripts release AFK sessions and send the stock end-game signal. Stock afterlife,
`no_end_game_check` and Grief reset recovery defer this behavior. A lobby with no
AFK player is unaffected. Verify these rules on each deployed map/mode.
## Rewards
Initial EC policy:
| Source | Time |
| --- | --- |
| Free allowance | 30 minutes per rolling 24 hours |
| Five minutes of active play | 1 minute |
| Completed round with participation | 15 seconds |
| 100 kills | 30 seconds |
| 25 headshot kills | 15 seconds |
| Each successful teammate revive | 30 seconds |
Saved earned and purchased time has a combined six-hour capacity and no expiry.
Free time is separate, non-accumulating and consumed first. EC retains fractional
reward progress across reconnects. Round/kill/headshot rewards share eight minutes
per rolling active hour; headshots stack with kills. Active-play rewards and
**every successful revive** are outside that cap. There is no per-teammate,
per-match or hourly revive limit. Retrying a report cannot award it again.
Both engines consume stock `zom_kill`, `player_revived` and `end_of_round`
notifications. T5 also attaches to stock hellhound spawn/death callbacks because
those enemies omit `zom_kill`; T6 hellhounds already emit the stock zombie death
event. Completed round numbers are deduplicated locally. Headshots require a lethal head/helmet hit with a qualifying damage
type. No reward uses spendable score or `score_total`. A round requires a kill,
revive, or at least 30 seconds of active engagement and eligibility at completion.
The one-second activity sampler uses movement over four units, a view change over
two degrees or a changed input button to refresh a 15-second engagement interval.
The stock `is_reviving` state counts support; holding a zombie that targets
the player within 512 units counts while deliberate input occurred in the last
60 seconds. Kill/revive events also refresh engagement. AFK, dead and spectator
time do not earn rewards. Contributions after manual `.back` count immediately,
including during its separate two-second grace. This permits support and zombie
holding without a minimum kill rate, while passive connection alone earns nothing.
Stock integration was checked against
[T5 scripts 70689b8](https://github.com/plutoniummod/t5-scripts/tree/70689b8288e60079d48926657e670c41aab4abdf)
and [T6 scripts 2d84403](https://github.com/plutoniummod/t6-scripts/tree/2d8440321602f22f63f5335817110d60ddf223d5).
Recheck callback contracts if the stock runtime changes.
## Configuration and coordinated rollout
1. Back up EC and the game runtime journals. Deploy EC #151 and restart its service.
2. Update all EC host agents to **1.12.0+** before enabling these scripts. This
adds AFK settlement before deleting/rebuilding stopped runtime storage.
3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK
**2.0.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and
Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes.
4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must
establish the current match before AFK commands can be authorized.
5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory,
on persistent writable storage with one process per server/endpoint scope.
| Game dvar | Default | Meaning |
| --- | --- | --- |
| `tws_afk_enabled` | enabled | `0` disables new AFK service startup |
| `tws_afk_cooldown_seconds` | `15` | Reentry cooldown, clamped to 2–300 seconds |
| `tws_afk_warning_sound` | `zmb_perks_packa_ready` | Local warning sound alias; verify on deployed maps |
Economic configuration lives in EC's `EC_AFK_*` environment settings. See the
[EC AFK guide](https://gitea.thewrightserver.net/josh/electric-cherry/src/branch/main/docs/guides/afk.md)
for exact rates, rolling-window accounting and JSON schemas. The fixed game
endpoint is `https://ec.thewrightserver.net/api/afk`; requests reuse the private
`ec_bank_` credential. Shared telemetry tokens cannot spend or reserve time.
## API and recovery ownership
The matching native API `1.0.0` exposes `afkVersion`, `afkResponse`, `afkStore`,
`afkLoad`, `afkRemove`, `afkPrepare`, `afkAuthorize`, `afkBegin`, `afkRemaining`,
`afkCheckpoint`, `afkStop`, and `afkRecover`. Responses must validate their complete
schema and request/session identity before authorization takes effect. Requests
are immutable; journal writes precede network transmission and native activation.
EC actions are `balance`, `event`, `purchase`, `reserve`, `start` and `settle`.
Events carry cumulative counters scoped to GUID, instance and match, with a
monotonic sequence. The native journal coalesces newer cumulative event snapshots
to bound outage queues; EC prevents retries and reordered reports awarding twice.
Revive counts still increase for every distinct successful revive. Kill/active
progress is flushed at most every 30 seconds and on normal disconnect/end; a hard
process crash can lose progress that had not yet reached the local journal.
Already accepted rewards and journaled snapshots persist.
Protection starts only after reservation and start acknowledgments, a final
player/lobby recheck, and a durable native clock start. Once authorized, an EC
outage cannot shorten protection; a local monotonic deadline still ends it at zero.
Manual return, disconnect, death and match end stop accounting immediately. A
failed stop-journal write freezes the original stop time for retry rather than
charging during the delay. Never replay a reserve/start from a background sender.
Before activation, an interrupted session recovers as `never_started` with zero
consumption. Its tombstone also blocks delayed reserve/start delivery. After
activation, a crash with no durable stop becomes `unresolved`, preserving its last
confirmed checkpoint and full reservation. Absence of a heartbeat never releases
spendable time. An administrator must reconcile such a stopped session using EC's
audited correction endpoint; corrected charges remain distinct from exact durations.
Do not delete journals or change IDs to unblock a hold.
The EC agent independently proves the old process is stopped before recovering
journals during remove/rebuild/cancel-create. It sends requests only to its enrolled
controller using the assigned teardown operation and host credential. Lost replies,
unrecognized endpoints or malformed journals preserve runtime storage for retry.
## Validation
Portable checks (engine shims are not live Plutonium):
```sh
node tools/check_afk.mjs
python3 tools/test_plugin_afk.py --json-header /path/to/pinned/json.hpp
node tools/check_bank.mjs
node tools/check_hud.mjs
node tools/check_telemetry.mjs
```
Use each plugin's pinned JSON header; the native harness also accepts a path with
`{game}` for different T5/T6 dependency revisions. It compiles native utilities
and actual registration adapters with ASan/UBSan, then exercises separate-process
journal recovery. T6 source compilation follows its [script instructions](../t6/scripts/README.md#validation).
T5 linking and behavior require a live server.
Before production activation, validate **both engines**:
- Enter/return/expire, reconnect, die, change maps and restart the process; compare
the displayed countdown and EC balances/ledger. At expiry attempt moving, firing,
throwing equipment and receiving zombie damage immediately, including `.hud` off.
- Try T5 entry while downed, then after a successful revive. Check T6 laststand,
afterlife, intermission and stock control restrictions; no stock protection should
be removed. Repeat rapid `.afk`/`.back` and concurrent commands during slow replies.
- Earn each reward; revive the same teammate repeatedly beyond the performance cap.
Verify passive, spectating, AFK and bank point changes earn nothing. Test support
and zombie holding. Carry partial progress across maps/servers/T5/T6.
- Confirm the displayed purchase price; try expired/changed quotes, insufficient
bank funds, pending bank transfers and capacity overflow. Drop purchase replies
and retry without a second debit. Attempt simultaneous reservations on two servers.
- Disconnect EC after authorization; the countdown must continue and expire locally.
Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and
restart. Verify never-started refunds, durable exact stops and unconfirmed crash
holds. Correct only a proven stopped session and confirm the ledger audit.
- Reject the last active player's entry. Kill/disconnect the last active teammate
while another is AFK; validate normal end reporting and all AFK settlement.
Exercise stock recovery exceptions and confirm expected zombie scaling.
- Rebuild/remove with journals from both games, retire the instance credential,
interrupt the agent after a lost reply, and retry. Runtime deletion must wait for
acknowledged recovery and cannot authorize a new purchase.
Validation performed for this change: 39 AFK behavior checks, 14 core HUD and
56 service HUD checks passed. All nine T6 scripts compiled and assembled with
the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306)
using its T6 PC compiler/assembler library in a temporary local driver. No Windows
DLL build, production deployment or live T5/T6 validation is implied by these checks.
+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/t5",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;
};
}
+28 -38
View File
@@ -12,7 +12,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|---|---|
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, paginated `.help`, service window, connect/spawn registries, single `connected` listener + dispatcher |
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator |
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | Persistent AFK reserve, contribution rewards, purchases and central countdown |
| [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
@@ -65,8 +65,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.
@@ -77,7 +76,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
@@ -86,22 +85,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
@@ -133,8 +132,8 @@ explicit row positions so T5 does not concatenate newline-separated text.
| Command | Effect |
|---|---|
| `.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,9 +281,9 @@ gameplay updates start through the existing spawn lifecycle.
**Features**
- Upper-left match timer above round timer (round resets each round, both freeze on game over)
- `.hud` toggles this player's core and AFK indicator; data updates continue while hidden
- `.hud` toggles this player's core; the AFK countdown stays visible and data updates continue
- Top-center `TWS ZOMBIES | discord.tws.lol` branding
- One `N ZOMBIES` element centered above the health track, with the AFK indicator above it
- One `N ZOMBIES` element centered above the health track, separate from the central AFK countdown
- Slim health bar with green / amber / red ramp; current/maximum HP appears
beside it only while injured
- Spectator placeholders without starting the match clock on connection
@@ -319,32 +318,16 @@ without seeding clocks; `hud_start` calls it as a fallback if needed.
## tws_t5_afk.gsc
Self-invoked pause mode. Owns the `[AFK]` indicator element, centered above the
zombie count through common layout values. It does not require HUD.
Persistent protected breaks using the shared GUID reserve. See the
[AFK guide](../../docs/afk.md) for commands, policy, ownership, native APIs,
crash recovery and live validation. Requires common `1.4.0+`, telemetry `1.0.0+`,
matching plugin `1.4.0+`, Plutonium r5334+ and EC #151.
**Features**
- Chat commands `.afk` and `.back` (via `level waittill("say")` — not
`callbackPlayerSay`, which never fires on T5 SP dedicated)
- On enter: `enableInvulnerability`, `freezeControls(true)`,
`self.ignoreme = true` (drops out of zombie targeting)
- On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through
the target-reacquisition window
- `[AFK]` HUD element created per-player via spawn handler; alpha-toggled
by enter / exit through common's core visibility helpers, respecting `.hud`
**Provides**
- Per-player: `self.hud_afk_on` (bool), `self.hud_afk_elem` (HUD element),
writes `self.ignoreme` (stock BO1 targeting flag)
- Level var: `level.afk_grace_seconds` (default 2)
**Depends on**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`set_core_alpha`, `destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
- Shared config: overlay AFK position, `level.hud_font_afk`, `level.hud_c_afk`
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
Spawn handler registered: `::afk_spawn`.
Chat uses `level waittill("say")`; the independent central countdown stays visible
with `.hud` hidden. Activation rejects laststand before changing state. A stock
damage callback owns only AFK protection; expiration immediately relinquishes it
and AFK-owned controls/targeting, while manual `.back` keeps its separate two-second
grace. Spawn handler `::afk_spawn` binds contribution and lifecycle watchers.
---
@@ -614,3 +597,10 @@ process. No database is required.
Optional `tws_t5_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.
+1008 -62
View File
File diff suppressed because it is too large Load Diff
+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.
+941 -67
View File
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
function compile(source) {
const strings=[];
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
if(token.startsWith('/')) return '';
strings.push(token); return `__str_${strings.length-1}__`;
});
const compiled = [];
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
for(let match; (match=headers.exec(source));) {
let end=headers.lastIndex, depth=1;
while(depth && end<source.length) {
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
}
assert.equal(depth,0);
let body=source.slice(headers.lastIndex,end-1);
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
body=body.replace(/#.?(?=__str_)/g,'');
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
body=body.replace(/player getGuid\(\)/g,'player.guid');
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
body=body.replace(/level notify\(/g,'level.notify(');
body=body.replace(/::(afk_\w+)/g,'$1');
body=body.replace(/http::request/g,'httpPost');
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
if(match[1]==='afk_register_dogs')continue;
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
}
return compiled.join('\n');
}
function engine(game, options={}) {
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
let now=100000, wall=100000, serial=0;
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
const players=[],zombies=[];
const c=vm.createContext({level,
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
telemetryNewMatch:()=>`id-${++serial}`,
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
afkRemove:(_s,_u,id)=>saved.delete(id),
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
httpComplete:request=>{options.during?.(request,c);const d=request.data;
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
afkResponse:JSON.parse,
});
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
function player(guid='18446744073709551615') {
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
p.getPlayerAngles=()=>p.angles;
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
players.push(p);p.afk_spawn();return p;
}
const p=player(), other=player('other');
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
};
}
let checks=0;
for(const game of ['t5','t6']) {
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
});
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
});
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
});
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
assert.equal(e.p.afk_remaining(),1799000);
});
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
assert.equal(e.p.sessionstate,'playing');
});
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
});
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
});
test('manual command at zero cannot create a grace period',()=>{
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
});
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
});
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
}
});
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false);
assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability('));
});
test('last active cannot enter; last active death ends only AFK-only matches, respecting afterlife',()=>{
const alone=engine(game);alone.other.downed=true;alone.enter();assert(!alone.p.hud_afk_on);assert.equal(alone.sent.length,0);
const e=engine(game);e.enter();e.other.alive=false;e.c.afk_team_watcher().next();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'match_end');assert(e.log.some(x=>x[0]==='notify'&&x[1]==='end_game'));
const recovering=engine(game);recovering.enter();recovering.other.afterlife=true;recovering.c.afk_team_watcher().next();assert(recovering.p.hud_afk_on);
const noAfk=engine(game);noAfk.p.alive=false;noAfk.other.alive=false;noAfk.c.afk_team_watcher().next();assert(!noAfk.log.some(x=>x[0]==='notify'));
});
test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
for(let i=0;i<40;i++)watcher.next(e.p);
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
});
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
assert(!e.source.includes('.score'));
});
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
});
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
});
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
});
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
});
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
for(const at of ['reserve','start']){
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
}
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
});
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
});
}
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);
+6 -43
View File
@@ -1,4 +1,5 @@
// Execute selected core HUD/AFK functions with engine-value shims.
// Execute selected core HUD functions with engine-value shims.
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
@@ -99,8 +100,6 @@ function engine(game, source, afk, hidden = false) {
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
@@ -126,9 +125,8 @@ for (const game of ['t5', 't6']) {
const bind = body(source, 'match_timer_bind')[1];
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
assert(afk.includes('make_core_overlay_text'));
assert(afk.includes('level.hud_overlay_afk_y'));
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
assert(afk.includes('NewClientHudElem(self)'), 'protection countdown is an independent critical HUD');
assert(!afk.includes('set_core_alpha'), 'hiding the optional HUD must not hide the protection deadline');
});
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
@@ -266,41 +264,6 @@ for (const game of ['t5', 't6']) {
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
});
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
const e = make(); e.context.hud_connect(); e.spawnAfk();
const indicator = e.self.hud_afk_elem;
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
e.context.hud_toggle(); e.context.afk_enter();
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
const actions = e.actions.length;
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
e.context.hud_toggle(); const exit = e.context.afk_exit();
assert.equal(exit.next().value, 2);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
exit.next(); assert.equal(e.self.ignoreme, false);
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
assert.equal(e.elems.length, 11);
});
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
watcher.next();
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true);
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
e.self.active = true; watcher.next();
assert.equal(e.self.tws_afk_controls_locked, false);
assert(e.actions.some(([name]) => name === 'enableWeapons'));
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
});
}
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
+10 -10
View File
@@ -24,7 +24,7 @@ function translate(source) {
const result = [];
for (let match; (match = headers.exec(protectedSource));) {
const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', 'destroy_on_disconnect'].includes(name)) continue;
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
@@ -129,7 +129,7 @@ function engine(game, count = 8, withHud = false) {
assert.equal(game, 't5', 'combined fixture currently covers T5 construction');
for (const module of ['hud', 'afk'])
vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c);
c.hud_connect(); c.afk_spawn();
c.hud_connect(); c.afk_hud_create();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
@@ -523,8 +523,8 @@ check('T5 service transitions preserve actual core element identities, styles, d
const e = engine('t5', 8, true);
e.createCore();
const core = Array.from(e.self.tws_core_hud_elems);
assert.equal(core.length, 11, 'ten core elements plus one optional AFK indicator');
assert.equal(e.live.length, 11);
assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES');
e.self.tws_hud_health.SetValue(100);
e.self.tws_hud_maxhealth.SetValue(100);
@@ -537,17 +537,17 @@ check('T5 service transitions preserve actual core element identities, styles, d
e.advance(3000);
e.record(snapshot(['Jeff', 'Lokester', 'Moniialx']));
const controller = e.state, tasks = e.tasks.length;
assert.equal(e.live.length, 15);
assert.equal(e.live.length, 16);
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.state, controller);
assert.equal(e.state.closed, false, 'idle releases the view, not its controller');
assert.equal(e.state.elems.length, 0);
assert.equal(e.live.length, 11);
assert.equal(e.live.length, 12);
assert.deepEqual(state(), initial);
for (let i = 0; i < 4; i++) {
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
assert.equal(e.live.length, 30, '11 core/AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
e.c.toggle_core_hud();
assert(core.every(elem => elem.alpha === 0));
@@ -557,17 +557,17 @@ check('T5 service transitions preserve actual core element identities, styles, d
e.advance(12000);
assert.equal(e.state.rows[0].text, 'Latest');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.live.length, 15);
assert.equal(e.live.length, 16);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.live.length, 11);
assert.equal(e.live.length, 12);
assert.equal(e.state.elems.length, 0);
assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers');
assert.deepEqual(state(), initial);
}
assert.equal(e.peakLive, 30);
assert.equal(e.peakLive, 31);
assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core);
e.event('disconnect');
assert.equal(e.live.length, 0);
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
template <typename F> void add(const char* name, F callback)
{
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
{
if constexpr (std::is_invocable_v<F>) return callback();
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
};
}
""")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
lines = result.stdout.splitlines()
for operation in ("load", "write", "remove"):
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+158
View File
@@ -0,0 +1,158 @@
#include "afk.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
using utils::afk::journal;
using utils::afk::parse_acknowledgement;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
std::string reserve(const std::string& id)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
}
std::string request(const std::string& id, const std::string& action)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
}
template <typename F> void must_fail(F fn)
{
bool failed = false;
try { fn(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const journal queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.prepare(reserve("pending"));
queue.store(request("pending-start","start"));
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
queue.prepare(reserve("active")); queue.authorize("active",120);
const auto now = journal::clock::now();
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
}
else if (mode == "recover")
{
// Separate process: the same match ID cannot recreate a live clock.
assert(queue.remaining("active") == -1);
must_fail([&]{queue.begin("active");});
queue.recover("match");
const auto values = queue.load();
assert(values.size() == 5);
int recovered = 0;
for (const auto& value : values)
{
if (value.action != "settle") continue;
auto body = nlohmann::json::parse(value.body);
assert(value.id.size() == 25);
if (body.at("session_id") == "active")
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
++recovered;
}
assert(recovered == 3);
queue.recover("new-match"); // Stable requests are unchanged across restarts.
assert(queue.load().size() == values.size());
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
for (const auto& value : values) queue.erase(value.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
assert(queue.load().size() == 1);
must_fail([&]{queue.store(request("one","event"));});
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
must_fail([&]{queue.store(changed.dump());});
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
assert(other_server.load().empty() && other_endpoint.load().empty());
queue.erase("one");
queue.store(request("first-event","event"));
auto snapshot = nlohmann::json::parse(request("second-event","event"));
snapshot["seq"] = 2; snapshot["revives"] = 2;
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
assert(queue.load().size() == 1);
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
must_fail([&]{queue.store(regressed.dump());});
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
queue.erase("second-event");
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
assert(queue.load().size() == 1);
must_fail([&]{queue.begin("normal");});
must_fail([&]{queue.authorize("normal",601);});
queue.authorize("normal",60); queue.authorize("normal",60);
must_fail([&]{queue.authorize("normal",120);});
const auto now = journal::clock::now();
queue.begin("normal",now); queue.begin("normal",now+20s);
assert(queue.remaining("normal",now+1250ms) == 58750);
assert(queue.remaining("normal",now+59999ms) == 1);
assert(queue.remaining("normal",now+60s) == 0);
assert(queue.remaining("normal",now+61s) == 0);
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
assert(queue.checkpoint("normal",now+59001ms) == 59);
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
auto settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
assert(queue.checkpoint("expire",now+60s) == 60);
queue.stop("expire","expired",now+61s);
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
queue.erase(queue.load()[0].id);
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
queue.erase(queue.load()[0].id);
// Interrupted temp writes preserve the prior durable snapshot.
const auto file = fs::directory_iterator(root)->path();
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
assert(queue.remaining("failure",now+30s) == 0);
must_fail([&]{queue.checkpoint("failure",now+30s);});
must_fail([&]{queue.begin("failure",now+30s);});
fs::remove(file.string()+".tmp");
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
assert(parse_acknowledgement(valid.dump()).valid);
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
fs::remove(file);
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
}
+35
View File
@@ -0,0 +1,35 @@
// Compile the actual GSC registration adapter against portable VM interfaces.
#include <fstream>
int main()
{
afk::component component; component.on_startup(nullptr);
const auto call = [](const std::string& name, std::vector<std::string> args = {})
{ return gsc::function::functions.at(name)(args); };
assert(call("afkversion").as<std::string>() == "1.0.0");
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
std::filesystem::path file;
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
if (entry.path().extension() == ".json") file=entry.path();
assert(!file.empty());
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
for (int i=0;i<2;++i)
{
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
}
std::filesystem::remove(file);
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
}
+4 -4
View File
@@ -1,5 +1,5 @@
{
"tws_t5_afk.gsc": "1.2.0",
"tws_t5_afk.gsc": "2.0.0",
"tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.4.1",
"tws_t5_hud.gsc": "1.4.2",
@@ -7,7 +7,7 @@
"tws_t5_leaderboard.gsc": "1.1.0",
"tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.2.0",
"tws_t6_afk.gsc": "2.0.0",
"tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.4.1",
"tws_t6_hud.gsc": "1.4.2",
@@ -15,8 +15,8 @@
"tws_t6_leaderboard.gsc": "1.1.0",
"tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.3.0",
"t6-plugin": "1.3.0",
"t5-plugin": "1.4.0",
"t6-plugin": "1.4.0",
"tws_t5_moderation.gsc": "1.0.0",
"tws_t6_moderation.gsc": "1.0.0"
}