From 6781a4ed5a89b4d95b7249b54429c07540920cab Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 16:51:10 -0400 Subject: [PATCH] Add persistent AFK reserves for both engines (Refs #12) --- CHANGELOG.md | 37 + README.md | 25 +- docs/afk.md | 196 +++ t5/plugins/README.md | 8 + .../tws-t5-gsc-utils/src/component/afk.cpp | 93 ++ t5/plugins/tws-t5-gsc-utils/src/utils/afk.cpp | 381 ++++++ t5/plugins/tws-t5-gsc-utils/src/utils/afk.hpp | 58 + t5/scripts/README.md | 66 +- t5/scripts/tws_t5_afk.gsc | 1074 ++++++++++++++++- t6/plugins/README.md | 8 + .../tws-t6-gsc-utils/src/component/afk.cpp | 93 ++ t6/plugins/tws-t6-gsc-utils/src/utils/afk.cpp | 381 ++++++ t6/plugins/tws-t6-gsc-utils/src/utils/afk.hpp | 58 + t6/scripts/README.md | 28 +- t6/scripts/tws_t6_afk.gsc | 1012 ++++++++++++++-- tools/check_afk.mjs | 226 ++++ tools/check_core_hud.mjs | 49 +- tools/check_hud.mjs | 20 +- tools/test_plugin_afk.py | 74 ++ tools/tests/afk_cases.cpp | 158 +++ tools/tests/afk_component_cases.cpp | 35 + versions.json | 8 +- 22 files changed, 3840 insertions(+), 248 deletions(-) create mode 100644 docs/afk.md create mode 100644 t5/plugins/tws-t5-gsc-utils/src/component/afk.cpp create mode 100644 t5/plugins/tws-t5-gsc-utils/src/utils/afk.cpp create mode 100644 t5/plugins/tws-t5-gsc-utils/src/utils/afk.hpp create mode 100644 t6/plugins/tws-t6-gsc-utils/src/component/afk.cpp create mode 100644 t6/plugins/tws-t6-gsc-utils/src/utils/afk.cpp create mode 100644 t6/plugins/tws-t6-gsc-utils/src/utils/afk.hpp create mode 100644 tools/check_afk.mjs create mode 100644 tools/test_plugin_afk.py create mode 100644 tools/tests/afk_cases.cpp create mode 100644 tools/tests/afk_component_cases.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 7371aab..f63f971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 | diff --git a/README.md b/README.md index 2e516a2..267893a 100644 --- a/README.md +++ b/README.md @@ -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 ` and `.w `, 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. diff --git a/docs/afk.md b/docs/afk.md new file mode 100644 index 0000000..7a153f9 --- /dev/null +++ b/docs/afk.md @@ -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. diff --git a/t5/plugins/README.md b/t5/plugins/README.md index 989cd51..ac65aee 100644 --- a/t5/plugins/README.md +++ b/t5/plugins/README.md @@ -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. diff --git a/t5/plugins/tws-t5-gsc-utils/src/component/afk.cpp b/t5/plugins/tws-t5-gsc-utils/src/component/afk.cpp new file mode 100644 index 0000000..5888b53 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/component/afk.cpp @@ -0,0 +1,93 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "game/scripting/array.hpp" +#include +#include +#include + +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 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 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) diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/afk.cpp b/t5/plugins/tws-t5-gsc-utils/src/utils/afk.cpp new file mode 100644 index 0000000..ddd33a7 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/afk.cpp @@ -0,0 +1,381 @@ +#include +#include "afk.hpp" +#include +#include +#include +#include +#include + +namespace utils::afk +{ + namespace + { + constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096; + constexpr int max_seconds = 604800; + struct active_clock { journal::clock::time_point started; int authorized_seconds; }; + std::map active; + struct stopped_clock { journal::clock::time_point time; std::string reason; }; + std::map stopped; + std::string hash(const std::string& value) + { + uint64_t result = 14695981039346656037ull; + for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull; + std::ostringstream out; + out << std::hex << std::setfill('0') << std::setw(16) << result; + return out.str(); + } + bool identifier(const nlohmann::json& value) + { + if (!value.is_string()) return false; + const auto& text = value.get_ref(); + return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c) + { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; }); + } + bool integer(const nlohmann::json& value, int maximum = 1000000000) + { + return value.is_number_integer() && value >= 0 && value <= maximum; + } + std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get(); } + void append(std::vector& requests, const request& incoming) + { + for (const auto& value : requests) + if (value.id == incoming.id) + { + if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body)) + throw std::runtime_error("AFK immutable request conflict"); + return; + } + requests.push_back(incoming); + } + void erase_session_requests(std::vector& requests, const std::string& id) + { + requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value) + { + const auto data = nlohmann::json::parse(value.body); + return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id; + }), requests.end()); + } + } + + acknowledgement parse_acknowledgement(const std::string& response) + { + const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; }; + if (response.empty()) return invalid("empty_body"); + if (response.size() >= max_body_size) return invalid("body_limit"); + const auto data = nlohmann::json::parse(response, nullptr, false); + if (data.is_discarded()) return invalid("invalid_json"); + if (!data.is_object()) return invalid("schema"); + try + { + acknowledgement value; + if (!data.at("ok").is_boolean()) return invalid("schema"); + value.state = data.at("state").get(); + value.code = data.at("code").get(); + value.id = data.at("request_id").get(); + if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit"); + const std::set states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"}; + if (!states.count(value.state)) return invalid("state"); + if (data.at("ok").get() != (value.state != "rejected")) return invalid("ok_state"); + if (value.state != "balance" && value.id.empty()) return invalid("missing_id"); + const std::pair fields[] = { + {"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds}, + {"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds}, + {"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds}, + {"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points}, + {"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}}; + for (const auto& [name, member] : fields) + { + if (!integer(data.at(name))) return invalid("amount_range"); + value.*member = data.at(name).get(); + } + if (value.authorized_seconds > max_seconds) return invalid("amount_range"); + if (data.contains("awarded_seconds")) + { + if (!integer(data.at("awarded_seconds"))) return invalid("amount_range"); + value.awarded_seconds = data.at("awarded_seconds").get(); + } + if (data.contains("session_id") && !data.at("session_id").is_null()) + { + value.session_id = data.at("session_id").get(); + if (value.session_id.size() > 64) return invalid("field_limit"); + } + if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null()) + { + if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range"); + value.consumed_seconds = data.at("consumed_seconds").get(); + } + if ((value.state == "reserved" || value.state == "started") && + (value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session"); + value.valid = true; + value.reason = "ok"; + return value; + } + catch (...) { return invalid("schema"); } + } + + journal::journal(std::filesystem::path root, std::string server, std::string endpoint) + : server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_) + { file_ = root / (hash(scope_) + ".json"); } + std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; } + + request journal::decode(const std::string& body) const + { + if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size"); + const auto data = nlohmann::json::parse(body); + if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) || + !identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope"); + request value{data.at("request_id").get(), data.at("action").get(), body}; + const std::set actions = {"event","purchase","reserve","start","settle"}; + if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action"); + if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id"))) + throw std::runtime_error("invalid AFK session identity"); + if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0)) + throw std::runtime_error("invalid AFK reservation size"); + if (value.action == "event") + { + if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing") + throw std::runtime_error("invalid AFK activity sequence"); + for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"}) + if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter"); + if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter"); + } + return value; + } + + journal::snapshot journal::read() const + { + if (!std::filesystem::exists(file_)) return {}; + if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit"); + std::ifstream stream(file_, std::ios::binary); + if (!stream) throw std::runtime_error("cannot read AFK journal"); + const auto data = nlohmann::json::parse(stream); + if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ || + !data.at("requests").is_array() || !data.at("sessions").is_array() || + data.at("requests").size() + data.at("sessions").size() > max_entries) + throw std::runtime_error("invalid AFK journal; preserve for recovery"); + snapshot result; + std::set requests, sessions; + for (const auto& body : data.at("requests")) + { + auto value = decode(body.get()); + if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request"); + result.requests.push_back(std::move(value)); + } + for (const auto& entry : data.at("sessions")) + { + session value{entry.at("body").get(),entry.at("phase").get(),0,0}; + const auto request = decode(value.body); + const auto body = nlohmann::json::parse(value.body); + if (request.action != "reserve" || !sessions.emplace(session_id(value)).second || + (value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") || + !integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds)) + throw std::runtime_error("invalid AFK session"); + value.authorized_seconds = entry.at("authorized_seconds").get(); + value.consumed_seconds = entry.at("consumed_seconds").get(); + if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get() || + (value.phase == "prepared" && value.authorized_seconds != 0) || + (value.phase != "prepared" && value.authorized_seconds == 0) || + (value.phase != "active" && value.consumed_seconds != 0)) + throw std::runtime_error("invalid AFK session accounting"); + result.sessions.push_back(std::move(value)); + } + return result; + } + + void journal::save(const snapshot& value) const + { + if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full"); + nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_}, + {"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}}; + for (const auto& request : value.requests) data["requests"].push_back(request.body); + for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase}, + {"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}}); + const auto encoded = data.dump(); + if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full"); + std::filesystem::create_directories(file_.parent_path()); + auto temporary = file_; temporary += ".tmp"; + std::ofstream stream(temporary, std::ios::binary | std::ios::trunc); + stream.write(encoded.data(),static_cast(encoded.size())); + stream.flush(); + if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained"); + stream.close(); + if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained"); +#ifdef _WIN32 + if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw std::runtime_error("cannot replace AFK journal; old file retained"); +#else + std::filesystem::rename(temporary,file_); +#endif + } + std::vector journal::load() const { return read().requests; } + void journal::store(const std::string& body) const + { + auto value = read(); + const auto incoming = decode(body); + // Snapshot replacement is atomic with the new snapshot. Cumulative + // counters retain every reward during an outage without an unbounded + // request per kill. An in-flight older HTTP request remains idempotent; + // removing its old ID cannot remove this newer request. + if (incoming.action == "event") + { + const auto next = nlohmann::json::parse(body); + for (const auto& entry : value.requests) + { + if (entry.action != "event" || entry.id == incoming.id) continue; + const auto prior = nlohmann::json::parse(entry.body); + if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue; + if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression"); + for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"}) + if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression"); + } + // Check reused identities before removing any superseded request. + append(value.requests,incoming); + value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry) + { + if (entry.action != "event" || entry.id == incoming.id) return false; + const auto prior = nlohmann::json::parse(entry.body); + return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match"); + }),value.requests.end()); + } + else append(value.requests,incoming); + save(value); + } + void journal::erase(const std::string& id) const + { + auto value = read(); + value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end()); + save(value); + } + void journal::prepare(const std::string& body) const + { + const auto request = decode(body); + if (request.action != "reserve") throw std::runtime_error("expected AFK reserve"); + const auto id = nlohmann::json::parse(body).at("session_id").get(); + auto value = read(); + for (const auto& existing : value.sessions) + if (session_id(existing) == id) + { + if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict"); + return; + } + append(value.requests,request); + value.sessions.push_back({body,"prepared",0,0}); + save(value); + } + void journal::authorize(const std::string& id, int seconds) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get() || + (session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict"); + if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); } + return; + } + throw std::runtime_error("missing AFK session"); + } + void journal::begin(const std::string& id, clock::time_point now) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped"); + if (session.phase == "active" && active.count(key(id))) return; + if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict"); + session.phase = "active"; + save(value); // Failure prevents protection; never reset an existing clock. + // Production clocks start after the durable write; slow storage + // must not charge time before the script can apply protection. + if (now == clock::time_point{}) now = clock::now(); + active.emplace(key(id),active_clock{now,session.authorized_seconds}); + return; + } + throw std::runtime_error("missing AFK session"); + } + int journal::elapsed(const session& value, clock::time_point now, bool round_up) const + { + const auto found = active.find(key(session_id(value))); + if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart"); + const auto seconds = std::chrono::duration(now - found->second.started).count(); + return std::max(value.consumed_seconds,static_cast(std::min(static_cast(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds))))); + } + int journal::remaining(const std::string& id, clock::time_point now) const + { + const auto found = active.find(key(id)); + if (found == active.end()) return -1; + if (stopped.count(key(id))) return 0; + const auto elapsed_ms = std::chrono::duration_cast(now - found->second.started).count(); + const auto authorized_ms = static_cast(found->second.authorized_seconds) * 1000; + return static_cast(std::max(0, authorized_ms - std::max(0, elapsed_ms))); + } + int journal::checkpoint(const std::string& id, clock::time_point now) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active"); + const auto seconds = elapsed(session,now); + if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); } + return seconds; + } + throw std::runtime_error("missing AFK session"); + } + request journal::settlement(const session& value, const std::string& reason, int consumed) const + { + auto data = nlohmann::json::parse(value.body); + const auto id = session_id(value); + nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")}, + {"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}}; + if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; } + else body["consumed_seconds"] = consumed; + return decode(body.dump()); + } + void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const + { + const std::set reasons = {"manual","disconnect","death","match_end","expired","never_started"}; + if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason"); + // The caller has already removed protection. Freeze the first stop even + // if disk read/write fails, so retries cannot charge time after return. + if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason}); + auto value = read(); + for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it) + if (session_id(*it) == id) + { + const auto end = stopped.find(key(id)); + const auto stop_time = end == stopped.end() ? now : end->second.time; + const auto stop_reason = end == stopped.end() ? reason : end->second.reason; + const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0; + if ((stop_reason == "never_started" && it->phase == "active") || + (stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds))) + throw std::runtime_error("AFK stop reason conflicts with usage"); + append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed)); + erase_session_requests(value.requests,id); + value.sessions.erase(it); + save(value); + active.erase(key(id)); + stopped.erase(key(id)); + return; + } + // Duplicate stop is safe after a successful durable transition. + } + void journal::recover(const std::string& current_match) const + { + if (current_match.empty()) throw std::runtime_error("missing current AFK match"); + auto value = read(); + for (const auto& session : value.sessions) + { + const auto id = session_id(session); + if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match) + throw std::runtime_error("cannot recover live AFK protection"); + append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0)); + erase_session_requests(value.requests,id); + } + const auto sessions = value.sessions; + value.sessions.clear(); + save(value); + for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); } + } +} diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/afk.hpp b/t5/plugins/tws-t5-gsc-utils/src/utils/afk.hpp new file mode 100644 index 0000000..7b0291b --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/afk.hpp @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +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 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 requests; std::vector 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; + }; +} diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 927520d..74b0307 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -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. diff --git a/t5/scripts/tws_t5_afk.gsc b/t5/scripts/tws_t5_afk.gsc index fe96ffb..d1bfbed 100644 --- a/t5/scripts/tws_t5_afk.gsc +++ b/t5/scripts/tws_t5_afk.gsc @@ -1,31 +1,225 @@ -/* - tws_t5_afk.gsc — .afk / .back chat toggle. Owns the [AFK] indicator element. - Chat uses level waittill("say") — callbackPlayerSay never fires on T5 SP - dedicated (see NOTES.md). -*/ - +/* Persistent AFK reserve. Requires the TWS native AFK journal API and EC /api/afk. */ #include maps\_utility; #include common_scripts\utility; +main() +{ +} + init() { if (GetDvar(#"zombiemode") != "1") return; - scripts\sp\tws_t5_common::ensure_config(); - scripts\sp\tws_t5_common::register_help_command(".afk", "Pause with protection"); + level.afk_url = "https://ec.thewrightserver.net/api/afk"; + level.afk_server = GetDvar("tws_server_id"); + level.afk_token = GetDvar("tws_bank_token"); + level.afk_ready = false; + level.afk_ended = false; + level.afk_players = []; + level.afk_requests = []; + level.afk_stops = []; + level.afk_grace_seconds = 2; + level.afk_cooldown_seconds = 15; + level.afk_warning_sound = "zmb_perks_packa_ready"; + if (GetDvar("tws_afk_warning_sound") != "") + level.afk_warning_sound = GetDvar("tws_afk_warning_sound"); + if (GetDvar("tws_afk_cooldown_seconds") != "") + level.afk_cooldown_seconds = max(2, min(300, GetDvarInt("tws_afk_cooldown_seconds"))); + + scripts\sp\tws_t5_common::register_help_command(".afk", "Protected AFK using your time reserve"); + scripts\sp\tws_t5_common::register_help_command(".afk balance", "Free allowance and saved time"); + scripts\sp\tws_t5_common::register_help_command(".afk buy ", "Quote a bank purchase; confirmation required"); scripts\sp\tws_t5_common::register_help_command(".back", "Return from AFK"); - - afk_init(); - scripts\sp\tws_t5_common::register_spawn_handler(::afk_spawn); + + // Use the stock damage-callback chain, not the shared engine god-mode bit. + // At expiry this hook immediately falls through, preserving stock protection. + callbacks = []; + callbacks[0] = ::afk_damage; + if (isDefined(level.player_damage_callbacks)) + for (i = 0; i < level.player_damage_callbacks.size; i++) + callbacks[callbacks.size] = level.player_damage_callbacks[i]; + level.player_damage_callbacks = callbacks; + level thread afk_chat_listener(); + level thread afk_startup(); + level thread afk_end_watcher(); + level thread afk_round_watcher(); + level thread afk_team_watcher(); } -afk_init() +afk_startup() { - level.afk_grace_seconds = 2; // invuln tail on .back + if (GetDvar("tws_afk_enabled") == "0" || level.afk_server == "" || + level.afk_token.size < 40 || getSubStr(level.afk_token, 0, 8) != "ec_bank_") + { + println("[tws_afk] Disabled: configure tws_server_id and private tws_bank_token"); + return; + } + while (!is_true(level.telemetry_started) || !isDefined(level.telemetry_match)) + wait 0.1; + level.afk_match = level.telemetry_match; + if (!afkRecover(level.afk_server, level.afk_url, level.afk_match)) + { + println("[tws_afk] Journal recovery failed; protected AFK disabled"); + return; + } + afk_register_dogs(); + level.afk_ready = true; + println("[tws_afk] Ready native=" + afkVersion()); + level thread afk_sender(); +} - level thread afk_chat_listener(); +afk_guid(player) +{ + if (!isDefined(player)) + return undefined; + guid = player getGuid(); + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return undefined; + guid = "" + guid; + if (guid.size < 1 || guid.size > 64) + return undefined; + allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-"; + for (i = 0; i < guid.size; i++) + { + found = false; + for (j = 0; j < allowed.size; j++) + if (guid[i] == allowed[j]) + found = true; + if (!found) + return undefined; + } + return guid; +} + +afk_spawn() +{ + self endon("disconnect"); + if (isDefined(self.tws_afk_state)) + return; + guid = afk_guid(self); + if (!isDefined(guid)) + return; + if (!isDefined(level.afk_players[guid])) + { + state = spawnStruct(); + state.guid = guid; + state.seq = 0; + state.active_ms = 0; + state.kills = 0; + state.headshots = 0; + state.revives = 0; + state.rounds = 0; + state.last_round_awarded = 0; + state.round_active_ms = 0; + state.round_contribution = false; + level.afk_players[guid] = state; + } + progress = level.afk_players[guid]; + if (isDefined(progress.owner) && progress.owner.connected) + return; + // Connection state is never reused: an old HTTP completion cannot mutate a + // reconnect's session or confirmation. Only cumulative match counters persist. + state = spawnStruct(); + state.guid = guid; + state.progress = progress; + progress.owner = state; + state.player = self; + state.connected = true; + state.pending = false; + state.cancelled = false; + state.session = undefined; + state.quote = undefined; + state.last_exit = -300000; + state.last_input = getTime() - 60000; + state.last_engaged = getTime() - 60000; + state.last_flush = getTime(); + self.tws_afk_state = state; + self.hud_afk_on = false; + self.tws_afk_protected = false; + self.tws_afk_controls_locked = false; + self.tws_afk_ignore_owned = false; + self.tws_afk_grace_until = 0; + self.tws_afk_authorized = 0; + self.tws_afk_last_second = -1; + self afk_hud_create(); + self thread afk_disconnect_watcher(state); + self thread afk_lifecycle_watcher(); + self thread afk_kill_watcher(); + self thread afk_revive_watcher(); + self thread afk_activity_watcher(); + self thread afk_tick(); +} + +afk_hud_create() +{ + // Separate foreground elements stay visible when the optional core HUD hides. + timer = NewClientHudElem(self); + timer.horzAlign = "center"; + timer.vertAlign = "middle"; + timer.alignX = "center"; + timer.alignY = "middle"; + timer.x = 0; + timer.y = -30; + timer.font = "default"; + timer.fontScale = 3.0; + timer.foreground = true; + timer.sort = 100; + timer.alpha = 0; + timer SetText("AFK"); + self.hud_afk_elem = timer; + warning = NewClientHudElem(self); + warning.horzAlign = "center"; + warning.vertAlign = "middle"; + warning.alignX = "center"; + warning.alignY = "middle"; + warning.x = 0; + warning.y = 5; + warning.font = "default"; + warning.fontScale = 1.3; + warning.foreground = true; + warning.sort = 100; + warning.alpha = 0; + warning SetText("Protection ends when this timer reaches zero."); + self.tws_afk_warning_elem = warning; + elems = []; + elems[0] = timer; + elems[1] = warning; + self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); +} + +afk_playing(player) +{ + return isDefined(player) && !level.afk_ended && !is_true(level.intermission) && + !is_true(level.gameended) && isAlive(player) && + isDefined(player.sessionstate) && player.sessionstate == "playing" && + !(player maps\_laststand::player_is_in_laststand()) && + !is_true(player.is_zombie) && !is_true(player.afterlife); +} + +afk_stock_controls(player) +{ + return !afk_playing(player) || is_true(level.player_movement_suppressed) || + isDefined(level.hostmigrationtimer) || is_true(level.host_ended_game) || + is_true(player.hostmigrationcontrolsfrozen) || + is_true(player.disabledweapon) || is_true(player.disabledusability) || + is_true(player.tws_afk_external_controls); +} + +afk_can_enter(player) +{ + if (!afk_playing(player) || afk_stock_controls(player)) + return false; + return !is_true(player.ignoreme) && !isGodMode(player); +} + +afk_can_earn(player) +{ + // Manual .back restores play immediately; its separate protection tail + // must not discard successful revives or other contributions after return. + return afk_playing(player) && isDefined(player.tws_afk_state) && + !is_true(player.hud_afk_on); } afk_chat_listener() @@ -33,75 +227,827 @@ afk_chat_listener() for (;;) { level waittill("say", message, player); - if (!isDefined(player) || !isDefined(message)) continue; - - cmd = toLower(message); - - if (cmd == ".afk") - player thread afk_enter(); - else if (cmd == ".back") - player thread afk_exit(); + args = strTok(toLower(message), " "); + if (args.size == 0) + continue; + if (args[0] == ".back" && args.size == 1) + { + if (isDefined(player.tws_afk_state)) + { + player.tws_afk_state.cancelled = true; + player thread afk_exit(); + } + continue; + } + if (args[0] != ".afk") + continue; + if (!level.afk_ready || !isDefined(player.tws_afk_state) || level.afk_ended) + { + player iPrintLnBold("AFK reserve unavailable. No bank points have been spent."); + continue; + } + state = player.tws_afk_state; + if (state.pending) + { + player iPrintLnBold("AFK request is still pending. Use .back to cancel entry."); + continue; + } + if (args.size == 1) + { + if (!is_true(player.hud_afk_on)) + level thread afk_enter(player, state); + } + else if ((args.size == 2 && args[1] == "balance") || + (args.size >= 3 && args[1] == "buy")) + { + state.pending = true; + level thread afk_account_command(player, state, args); + } + else + player iPrintLnBold("Usage: .afk | .back | .afk balance | .afk buy "); } } -afk_spawn() +afk_base(state, action) { - self endon("disconnect"); - - afk = scripts\sp\tws_t5_common::make_core_overlay_text( - self, level.hud_overlay_afk_x, level.hud_overlay_afk_y, - "center", 1.0, level.hud_c_afk); - afk SetText("[AFK]"); - scripts\sp\tws_t5_common::set_core_alpha(self, afk, 0); - self.hud_afk_elem = afk; - - elems = []; - elems[elems.size] = afk; - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); + data = []; + data["action"] = action; + data["server"] = level.afk_server; + data["guid"] = state.guid; + data["match"] = level.afk_match; + if (action != "balance") + data["request_id"] = telemetryNewMatch(); + return data; } -afk_enter() +afk_say(state, message) +{ + if (state.connected && isDefined(state.player)) + state.player iPrintLnBold(message); +} + +afk_minutes(text) +{ + if (text.size < 1 || text.size > 4) + return 0; + digits = "0123456789"; + for (i = 0; i < text.size; i++) + { + found = false; + for (j = 0; j < digits.size; j++) + if (text[i] == digits[j]) + found = true; + if (!found) + return 0; + } + return int(text); +} + +afk_account_command(player, state, args) +{ + afk_account_request(player, state, args); + state.pending = false; +} + +afk_account_request(player, state, args) +{ + if (args[1] == "buy" && (is_true(player.hud_afk_on) || + is_true(player.tws_afk_protected) || isDefined(state.session))) + { + state.quote = undefined; + afk_say(state, "Use .back before buying time. Purchases apply to your next AFK session."); + return; + } + if (args[1] == "buy" && args.size == 4 && args[3] == "confirm") + { + quote = state.quote; + state.quote = undefined; + if (!isDefined(quote) || getTime() > quote.expires || + afk_minutes(args[2]) != quote.minutes || !state.connected) + { + afk_say(state, "Purchase confirmation expired. Request .afk buy again."); + return; + } + data = afk_base(state, "purchase"); + data["packs"] = quote.packs; + data["confirmed"] = true; + data["expected_purchase_points"] = quote.points; + data["expected_purchase_seconds"] = quote.seconds; + // Persist the same ID before sending. Retries never become a new charge. + if (!afk_enqueue(data, state)) + afk_say(state, "AFK storage unavailable. No purchase was sent."); + else + afk_say(state, "Purchase submitted; checking your bank and reserve capacity."); + return; + } + if (args[1] == "buy" && args.size != 3) + { + afk_say(state, "Usage: .afk buy , then .afk buy confirm"); + return; + } + data = afk_base(state, "balance"); + reply = afk_http("balance", jsonSerialize(data), ""); + if (!state.connected) + return; + if (!isDefined(reply) || reply["state"] != "balance") + { + afk_say(state, "AFK account unavailable. Existing authorized protection keeps counting down."); + return; + } + if (args[1] == "balance") + { + afk_say(state, "AFK free " + afk_format(reply["free_seconds"]) + + " | saved " + afk_format(reply["saved_seconds"]) + + " | reserved " + afk_format(reply["reserved_seconds"])); + return; + } + minutes = afk_minutes(args[2]); + seconds = minutes * 60; + if (minutes < 1 || reply["purchase_seconds"] < 1 || seconds % reply["purchase_seconds"] != 0) + { + afk_say(state, "Buy whole packs of " + afk_format(reply["purchase_seconds"]) + " each."); + return; + } + packs = int(seconds / reply["purchase_seconds"]); + if (packs > reply["max_purchase_packs"]) + { + afk_say(state, "Purchase would exceed reserve capacity. No points spent."); + return; + } + quote = spawnStruct(); + quote.minutes = minutes; + quote.packs = packs; + quote.points = reply["purchase_points"]; + quote.seconds = reply["purchase_seconds"]; + quote.expires = getTime() + 30000; + state.quote = quote; + afk_say(state, "Buy " + minutes + " minutes for " + (packs * reply["purchase_points"]) + + " bank points? Type .afk buy " + minutes + " confirm within 30 seconds."); +} + +afk_enter(player, state) +{ + state.pending = true; + state.cancelled = false; + afk_activate(player, state); + state.pending = false; +} + +afk_activate(player, state) +{ + if (!state.connected || !afk_can_enter(player)) + { + afk_say(state, "AFK requires a living, standing player with normal controls."); + return; + } + if (getTime() - state.last_exit < level.afk_cooldown_seconds * 1000 || + is_true(player.tws_afk_protected)) + { + afk_say(state, "Wait briefly after returning before entering AFK again."); + return; + } + if (afk_other_active(player) == 0) + { + afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight."); + return; + } + data = afk_base(state, "reserve"); + data["session_id"] = telemetryNewMatch(); + data["seconds"] = 604800; + session = data["session_id"]; + state.session = session; + body = jsonSerialize(data); + if (!afkPrepare(level.afk_server, level.afk_url, body)) + { + state.session = undefined; + afk_say(state, "AFK journal unavailable. Protection was not started."); + return; + } + reply = afk_http("reserve", body, data["request_id"]); + if (!isDefined(reply) || reply["state"] != "reserved" || reply["authorized_seconds"] <= 0 || + reply["session_id"] != session) + { + afk_stop_state(state, "never_started"); + if (isDefined(reply) && reply["code"] == "empty") + afk_say(state, "No AFK time available. Earn time through play or use .afk buy ."); + else + afk_say(state, "AFK authorization unavailable or already reserved elsewhere. Protection was not started."); + return; + } + if (!state.connected || state.cancelled || !isDefined(state.session) || state.session != session) + { + afk_stop_state(state, "never_started"); + return; + } + authorized = reply["authorized_seconds"]; + if (!afkAuthorize(level.afk_server, level.afk_url, state.session, authorized)) + { + afk_stop_state(state, "never_started"); + return; + } + afkRemove(level.afk_server, level.afk_url, data["request_id"]); + start = afk_base(state, "start"); + start["session_id"] = state.session; + start_body = jsonSerialize(start); + if (!afkStore(level.afk_server, level.afk_url, start_body)) + { + afk_stop_state(state, "never_started"); + return; + } + reply = afk_http("start", start_body, start["request_id"]); + if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || + reply["authorized_seconds"] != authorized || !state.connected || + state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) + { + afk_stop_state(state, "never_started"); + afk_say(state, "AFK entry cancelled. No protected time used."); + return; + } + afkRemove(level.afk_server, level.afk_url, start["request_id"]); + // No wait between the durable native start clock and applying protection. + if (!afkBegin(level.afk_server, level.afk_url, state.session)) + { + afk_stop_state(state, "never_started"); + return; + } + player.tws_afk_authorized = authorized; + player.tws_afk_next_checkpoint = getTime() + 1000; + player.tws_afk_ignore_before = player.ignoreme; + player.tws_afk_ignore_owned = true; + player.ignoreme = true; + player.hud_afk_on = true; + player.tws_afk_protected = true; + player.tws_afk_grace_until = 0; + player.tws_afk_last_second = -1; + player afk_lock_controls(); + player afk_countdown(); + afk_say(state, "AFK - type .back to return. Protection ends when this timer reaches zero."); +} + +afk_damage(inflictor, attacker, damage, flags, mod, weapon, point, direction, hitloc, modelindex, offset) +{ + if (is_true(self.tws_afk_protected) && afk_playing(self) && + ((is_true(self.hud_afk_on) && self afk_remaining() > 0) || + (!is_true(self.hud_afk_on) && getTime() < self.tws_afk_grace_until))) + return 0; + if (damage > 0 && afk_can_earn(self)) + self.tws_afk_state.last_engaged = getTime(); + return -1; +} + +afk_tick() { self endon("disconnect"); + for (;;) + { + if (is_true(self.hud_afk_on)) + { + if (self afk_remaining() <= 0) + { + self afk_finish("expired", false); + self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now."); + } + else if (!afk_playing(self) || afk_stock_controls(self)) + self afk_finish("death", false); + else + { + // Backend connectivity is irrelevant to this local deadline. + if (getTime() >= self.tws_afk_next_checkpoint) + { + elapsed = afkCheckpoint(level.afk_server, level.afk_url, self.tws_afk_state.session); + self.tws_afk_next_checkpoint = getTime() + 1000; + if (elapsed < 0) + { + self afk_finish("manual", false); + self iPrintLnBold("AFK journal unavailable. Protection ended; unused time will reconcile."); + } + } + if (self.hud_afk_on) + { + self afk_lock_controls(); + self afk_countdown(); + } + } + } + else if (is_true(self.tws_afk_protected) && getTime() >= self.tws_afk_grace_until) + self afk_release(); + else if (self.tws_afk_controls_locked) + self afk_restore_controls(); + if (!self.tws_afk_protected && self.tws_afk_ignore_owned) + self afk_restore_targeting(); + wait 0.05; + } +} - if (is_true(self.hud_afk_on)) +afk_remaining() +{ + if (!isDefined(self.tws_afk_state.session)) + return 0; + return afkRemaining(level.afk_server, level.afk_url, self.tws_afk_state.session); +} + +afk_countdown() +{ + remaining = int((max(0, self afk_remaining()) + 999) / 1000); + if (remaining == self.tws_afk_last_second) return; + self.tws_afk_last_second = remaining; + self.hud_afk_elem SetTextUnlimited("AFK " + afk_format(remaining)); + self.hud_afk_elem.alpha = 1; + self.tws_afk_warning_elem.alpha = 1; + self.hud_afk_elem.fontScale = 3.0; + self.hud_afk_elem.color = (1, 1, 1); + if (remaining <= 60) + { + self.hud_afk_elem.color = (1, 0.25, 0.1); + self.hud_afk_elem.fontScale = 3.5; + if (remaining == 60 || remaining == 30 || remaining <= 10) + self playLocalSound(level.afk_warning_sound); + } +} - self.hud_afk_on = true; - self.ignoreme = true; // filters new targeting decisions (1-3s aggro drop) - self enableInvulnerability(); - self freezeControls(true); - - if (isDefined(self.hud_afk_elem)) - scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 1); - - self iPrintLnBold("AFK — type .back to return"); +afk_format(seconds) +{ + minutes = int(seconds / 60); + seconds = int(seconds % 60); + tail = "" + seconds; + if (seconds < 10) + tail = "0" + seconds; + return minutes + ":" + tail; } afk_exit() { - self endon("disconnect"); - if (!is_true(self.hud_afk_on)) return; - - self.hud_afk_on = false; - self freezeControls(false); - - if (isDefined(self.hud_afk_elem)) - scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 0); - - self iPrintLnBold("Back in the fight"); - - // grace tail: keep invuln through the wait so zombies don't re-acquire - // and swing while controls are un-freezing - wait level.afk_grace_seconds; - - if (!is_true(self.hud_afk_on)) + if (self afk_remaining() <= 0) { - self.ignoreme = false; - self disableInvulnerability(); + self afk_finish("expired", false); + self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now."); + return; + } + self afk_finish("manual", true); + self iPrintLnBold("Back in the fight"); +} + +afk_finish(reason, manual_grace) +{ + state = self.tws_afk_state; + self.hud_afk_on = false; + state.last_exit = getTime(); + self.hud_afk_elem.alpha = 0; + self.tws_afk_warning_elem.alpha = 0; + self afk_restore_controls(); + if (manual_grace && afk_playing(self)) + self.tws_afk_grace_until = getTime() + level.afk_grace_seconds * 1000; + else + self afk_release(); + // Accepted transition, never inferred from the chat text. Manual grace is + // separate from paid AFK; its two-second tail cannot be renewed by re-entry. + afk_stop_state(state, reason); + afk_flush(state); +} + +afk_release() +{ + self.hud_afk_on = false; + self.tws_afk_protected = false; + self.tws_afk_grace_until = 0; + self afk_restore_controls(); + if (isDefined(self.hud_afk_elem)) + self.hud_afk_elem.alpha = 0; + if (isDefined(self.tws_afk_warning_elem)) + self.tws_afk_warning_elem.alpha = 0; + // Stock laststand/afterlife/intermission takes over ignoreme and controls. + // Our damage hook has already relinquished protection even in those states. + self afk_restore_targeting(); +} + +afk_restore_targeting() +{ + if (self.tws_afk_ignore_owned && afk_playing(self) && !afk_stock_controls(self)) + { + self.ignoreme = self.tws_afk_ignore_before; + self.tws_afk_ignore_owned = false; } } + +afk_lock_controls() +{ + self.tws_afk_controls_locked = true; + self freezeControls(true); + self disableWeapons(); + self disableOffhandWeapons(); +} + +afk_restore_controls() +{ + if (!self.tws_afk_controls_locked || afk_stock_controls(self)) + return; + self.tws_afk_controls_locked = false; + self freezeControls(false); + self enableWeapons(); + self enableOffhandWeapons(); +} + +afk_stop_state(state, reason) +{ + if (!isDefined(state.session)) + return; + stop = spawnStruct(); + stop.session = state.session; + stop.reason = reason; + state.session = undefined; + if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason)) + level.afk_stops[level.afk_stops.size] = stop; +} + +afk_disconnect_watcher(state) +{ + self waittill("disconnect"); + // No endon: checkpoint/stop while the departing entity is still valid. + if (is_true(self.hud_afk_on)) + self afk_finish("disconnect", false); + else + afk_stop_state(state, "never_started"); + state.connected = false; + state.cancelled = true; + afk_flush(state); +} + +afk_lifecycle_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill_any("killed_player", "spawned_player"); + if (is_true(self.hud_afk_on)) + self afk_finish("death", false); + else if (is_true(self.tws_afk_protected)) + self afk_release(); + self.tws_afk_state.cancelled = true; + } +} + +afk_end_watcher() +{ + level waittill_any("end_game", "game_ended", "intermission"); + // Stop without yielding before stock intermission takes control. + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + if (!isDefined(players[i].tws_afk_state)) + continue; + if (is_true(players[i].hud_afk_on)) + players[i] afk_finish("match_end", false); + else if (is_true(players[i].tws_afk_protected)) + players[i] afk_release(); + players[i].tws_afk_state.cancelled = true; + afk_flush(players[i].tws_afk_state); + } + level.afk_ended = true; +} + +afk_other_active(excluded) +{ + count = 0; + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (isDefined(excluded) && player == excluded) + continue; + if (!afk_playing(player) || is_true(player.hud_afk_on)) + continue; + if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending) + continue; + count++; + } + return count; +} + +afk_team_watcher() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + for (;;) + { + players = getPlayers(); + protected = 0; + survivors = 0; + stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (is_true(player.afterlife)) + stock_recovery = true; + if (is_true(player.hud_afk_on)) + protected++; + else if (afk_playing(player)) + survivors++; + } + if (protected > 0 && survivors == 0 && !stock_recovery) + { + for (i = 0; i < players.size; i++) + if (is_true(players[i].hud_afk_on)) + players[i] afk_finish("match_end", false); + // AFK does not change sessionstate/player count or zombie scaling. + // The verified stock end signal prevents AFK bodies prolonging play. + level notify("end_game"); + return; + } + wait 0.05; + } +} + +// Both stock engines notify the credited attacker, with the actual dead zombie. +// Never infer contributions from spendable score, score_total, or bank actions. +afk_kill_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill("zom_kill", zombie); + afk_credit_kill(self, zombie); + } +} + +afk_credit_kill(player, zombie) +{ + if (!afk_can_earn(player) || !isDefined(zombie) || + !isDefined(zombie.attacker) || zombie.attacker != player || + is_true(zombie.tws_afk_rewarded)) + return; + zombie.tws_afk_rewarded = true; + state = player.tws_afk_state; + state.progress.kills++; + if (afk_headshot(zombie)) + state.progress.headshots++; + state.progress.round_contribution = true; + state.last_engaged = getTime(); + // Cumulative kill/headshot totals are journaled by the 30-second flush. +} + +afk_headshot(zombie) +{ + if (!isDefined(zombie.damagelocation) || !isDefined(zombie.damagemod) || + !isDefined(zombie.damageweapon)) + return false; + // Count lethal head/helmet hits, not T5's nonlethal head-gib statistic. + return (zombie.damagelocation == "head" || zombie.damagelocation == "helmet") && + zombie.damagemod != "MOD_MELEE" && zombie.damagemod != "MOD_BAYONET" && + zombie.damagemod != "MOD_UNKNOWN" && zombie.damagemod != "MOD_IMPACT"; +} + +// The revived teammate owns this notification. A successful repeat revive is a +// new event every time; no teammate cache, cooldown, or reward limiter belongs here. +afk_revive_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill("player_revived", reviver); + if (!isDefined(reviver) || reviver == self || !afk_can_earn(reviver)) + continue; + state = reviver.tws_afk_state; + state.progress.revives++; + state.progress.round_contribution = true; + state.last_engaged = getTime(); + afk_flush(state); + } +} + +afk_round_watcher() +{ + for (;;) + { + level waittill("end_of_round"); + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (!isDefined(player.tws_afk_state)) + continue; + state = player.tws_afk_state; + if (afk_can_earn(player) && state.progress.last_round_awarded != level.round_number && + (state.progress.round_contribution || state.progress.round_active_ms >= 30000)) + { + state.progress.rounds++; + state.progress.last_round_awarded = level.round_number; + afk_flush(state); + } + } + identities = getArrayKeys(level.afk_players); + for (i = 0; i < identities.size; i++) + { + progress = level.afk_players[identities[i]]; + progress.round_active_ms = 0; + progress.round_contribution = false; + } + } +} + +afk_activity_watcher() +{ + self endon("disconnect"); + state = self.tws_afk_state; + previous_origin = self.origin; + previous_angles = self getPlayerAngles(); + previous_buttons = self afk_buttons(); + previous_time = getTime(); + for (;;) + { + wait 1; + now = getTime(); + elapsed = min(1000, max(0, now - previous_time)); + angles = self getPlayerAngles(); + buttons = self afk_buttons(); + if (afk_can_earn(self)) + { + changed = distanceSquared(previous_origin, self.origin) > 16 || + distanceSquared(previous_angles, angles) > 4 || buttons != previous_buttons; + if (changed) + { + state.last_input = now; + state.last_engaged = now; + } + // A stationary revive is real support. Holding a zombie also counts + // with recent deliberate input; simply being targeted indefinitely + // or leaving a button held does not constitute active play. + if (self afk_supporting() || + (now - state.last_input <= 60000 && self afk_holding_zombie())) + state.last_engaged = now; + if (now - state.last_engaged <= 15000) + { + state.progress.active_ms += elapsed; + state.progress.round_active_ms += elapsed; + } + } + previous_origin = self.origin; + previous_angles = angles; + previous_buttons = buttons; + previous_time = now; + if (now - state.last_flush >= 30000) + afk_flush(state); + } +} + +afk_buttons() +{ + return (self useButtonPressed()) + 2 * (self attackButtonPressed()) + + 4 * (self adsButtonPressed()) + 8 * (self meleeButtonPressed()); +} + +afk_supporting() +{ + if (!(self useButtonPressed())) + return false; + players = getPlayers(); + for (i = 0; i < players.size; i++) + if (players[i] != self && + (players[i] maps\_laststand::player_is_in_laststand()) && + isDefined(players[i].revivetrigger) && + (self maps\_laststand::is_reviving(players[i]))) + return true; + return false; +} + +afk_holding_zombie() +{ + zombies = GetAiSpeciesArray("axis", "all"); + for (i = 0; i < zombies.size; i++) + if (isAlive(zombies[i]) && isDefined(zombies[i].enemy) && zombies[i].enemy == self && + distanceSquared(self.origin, zombies[i].origin) <= 262144) + return true; + return false; +} + +afk_flush(state) +{ + if (!level.afk_ready || !isDefined(level.afk_match)) + return; + data = afk_base(state, "event"); + state.progress.seq++; + data["seq"] = state.progress.seq; + data["active_seconds"] = int(state.progress.active_ms / 1000); + data["kills"] = state.progress.kills; + data["headshots"] = state.progress.headshots; + data["rounds"] = state.progress.rounds; + data["revives"] = state.progress.revives; + data["activity"] = "playing"; + if (afk_enqueue(data, undefined)) + state.last_flush = getTime(); +} + +afk_enqueue(data, state) +{ + if (!isDefined(data["request_id"])) + return false; + if (!afkStore(level.afk_server, level.afk_url, jsonSerialize(data))) + return false; + if (isDefined(state)) + level.afk_requests[data["request_id"]] = state; + return true; +} + +afk_sender() +{ + for (;;) + { + pending_stops = []; + for (i = 0; i < level.afk_stops.size; i++) + { + stop = level.afk_stops[i]; + if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason)) + pending_stops[pending_stops.size] = stop; + } + level.afk_stops = pending_stops; + entries = afkLoad(level.afk_server, level.afk_url); + if (isDefined(entries)) + for (i = 0; i < entries.size; i++) + { + entry = entries[i]; + // Never activate orphaned protection from a background retry. + if (entry["action"] == "reserve" || entry["action"] == "start") + continue; + reply = afk_http(entry["action"], entry["body"], entry["id"]); + if (!isDefined(reply)) + break; + if (isDefined(level.afk_requests[entry["id"]])) + { + state = level.afk_requests[entry["id"]]; + if (reply["state"] == "purchased") + afk_say(state, "Purchased AFK reserve. Saved: " + afk_format(reply["saved_seconds"])); + else + afk_say(state, "AFK request: " + reply["code"] + ". Use .afk balance to check your account."); + level.afk_requests[entry["id"]] = undefined; + } + if (!afkRemove(level.afk_server, level.afk_url, entry["id"])) + break; + } + wait 1; + } +} + +afk_http(action, body, request_id) +{ + headers = []; + headers["Content-Type"] = "application/json"; + headers["Authorization"] = "Bearer " + level.afk_token; + options = []; + options["method"] = "POST"; + options["headers"] = headers; + options["body"] = body; + options["connect_timeout_ms"] = 5000; + options["timeout_ms"] = 15000; + request = http::request(level.afk_url + "/" + action, options); + if (!isDefined(request)) + return undefined; + request waittill("done", response, success, error, diagnostics); + if (!isDefined(success) || !success || !isDefined(response) || + !isDefined(diagnostics) || !isDefined(diagnostics["http_status"])) + return undefined; + status = diagnostics["http_status"]; + if (status < 200 || status >= 500 || status == 429) + return undefined; + reply = afkResponse(response); + if (!isDefined(reply) || !reply["valid"] || reply["request_id"] != request_id || + (status == 409 && reply["code"] == "not_in_match")) + return undefined; + return reply; +} + +// T5 hellhounds do not emit zom_kill; attach to the stock dog spawner list. +// T6 dog_init already installs _zm_spawner::zombie_death_event and needs no extra hook. +afk_register_dogs() +{ + spawners = getEntArray("zombie_dog_spawner", "script_noteworthy"); + later = getEntArray("later_round_dog_spawners", "script_noteworthy"); + for (i = 0; i < later.size; i++) + spawners[spawners.size] = later[i]; + for (i = 0; i < spawners.size; i++) + spawners[i] add_spawn_function(::afk_dog_spawn); + // Also cover a late module startup after a dog has already spawned. + dogs = GetAiSpeciesArray("axis", "all"); + for (i = 0; i < dogs.size; i++) + if (isDefined(dogs[i].animname) && dogs[i].animname == "zombie_dog") + dogs[i] afk_dog_spawn(); +} + +afk_dog_spawn() +{ + if (is_true(self.tws_afk_dog_bound)) + return; + self.tws_afk_dog_bound = true; + self thread afk_dog_death(); +} + +afk_dog_death() +{ + self waittill("death"); + if (isDefined(self.attacker)) + afk_credit_kill(self.attacker, self); +} diff --git a/t6/plugins/README.md b/t6/plugins/README.md index 2a7b631..9b96bcc 100644 --- a/t6/plugins/README.md +++ b/t6/plugins/README.md @@ -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. diff --git a/t6/plugins/tws-t6-gsc-utils/src/component/afk.cpp b/t6/plugins/tws-t6-gsc-utils/src/component/afk.cpp new file mode 100644 index 0000000..94121a6 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/component/afk.cpp @@ -0,0 +1,93 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "game/scripting/array.hpp" +#include +#include +#include + +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 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 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) diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/afk.cpp b/t6/plugins/tws-t6-gsc-utils/src/utils/afk.cpp new file mode 100644 index 0000000..ddd33a7 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/afk.cpp @@ -0,0 +1,381 @@ +#include +#include "afk.hpp" +#include +#include +#include +#include +#include + +namespace utils::afk +{ + namespace + { + constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096; + constexpr int max_seconds = 604800; + struct active_clock { journal::clock::time_point started; int authorized_seconds; }; + std::map active; + struct stopped_clock { journal::clock::time_point time; std::string reason; }; + std::map stopped; + std::string hash(const std::string& value) + { + uint64_t result = 14695981039346656037ull; + for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull; + std::ostringstream out; + out << std::hex << std::setfill('0') << std::setw(16) << result; + return out.str(); + } + bool identifier(const nlohmann::json& value) + { + if (!value.is_string()) return false; + const auto& text = value.get_ref(); + return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c) + { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; }); + } + bool integer(const nlohmann::json& value, int maximum = 1000000000) + { + return value.is_number_integer() && value >= 0 && value <= maximum; + } + std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get(); } + void append(std::vector& requests, const request& incoming) + { + for (const auto& value : requests) + if (value.id == incoming.id) + { + if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body)) + throw std::runtime_error("AFK immutable request conflict"); + return; + } + requests.push_back(incoming); + } + void erase_session_requests(std::vector& requests, const std::string& id) + { + requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value) + { + const auto data = nlohmann::json::parse(value.body); + return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id; + }), requests.end()); + } + } + + acknowledgement parse_acknowledgement(const std::string& response) + { + const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; }; + if (response.empty()) return invalid("empty_body"); + if (response.size() >= max_body_size) return invalid("body_limit"); + const auto data = nlohmann::json::parse(response, nullptr, false); + if (data.is_discarded()) return invalid("invalid_json"); + if (!data.is_object()) return invalid("schema"); + try + { + acknowledgement value; + if (!data.at("ok").is_boolean()) return invalid("schema"); + value.state = data.at("state").get(); + value.code = data.at("code").get(); + value.id = data.at("request_id").get(); + if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit"); + const std::set states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"}; + if (!states.count(value.state)) return invalid("state"); + if (data.at("ok").get() != (value.state != "rejected")) return invalid("ok_state"); + if (value.state != "balance" && value.id.empty()) return invalid("missing_id"); + const std::pair fields[] = { + {"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds}, + {"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds}, + {"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds}, + {"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points}, + {"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}}; + for (const auto& [name, member] : fields) + { + if (!integer(data.at(name))) return invalid("amount_range"); + value.*member = data.at(name).get(); + } + if (value.authorized_seconds > max_seconds) return invalid("amount_range"); + if (data.contains("awarded_seconds")) + { + if (!integer(data.at("awarded_seconds"))) return invalid("amount_range"); + value.awarded_seconds = data.at("awarded_seconds").get(); + } + if (data.contains("session_id") && !data.at("session_id").is_null()) + { + value.session_id = data.at("session_id").get(); + if (value.session_id.size() > 64) return invalid("field_limit"); + } + if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null()) + { + if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range"); + value.consumed_seconds = data.at("consumed_seconds").get(); + } + if ((value.state == "reserved" || value.state == "started") && + (value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session"); + value.valid = true; + value.reason = "ok"; + return value; + } + catch (...) { return invalid("schema"); } + } + + journal::journal(std::filesystem::path root, std::string server, std::string endpoint) + : server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_) + { file_ = root / (hash(scope_) + ".json"); } + std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; } + + request journal::decode(const std::string& body) const + { + if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size"); + const auto data = nlohmann::json::parse(body); + if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) || + !identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope"); + request value{data.at("request_id").get(), data.at("action").get(), body}; + const std::set actions = {"event","purchase","reserve","start","settle"}; + if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action"); + if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id"))) + throw std::runtime_error("invalid AFK session identity"); + if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0)) + throw std::runtime_error("invalid AFK reservation size"); + if (value.action == "event") + { + if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing") + throw std::runtime_error("invalid AFK activity sequence"); + for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"}) + if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter"); + if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter"); + } + return value; + } + + journal::snapshot journal::read() const + { + if (!std::filesystem::exists(file_)) return {}; + if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit"); + std::ifstream stream(file_, std::ios::binary); + if (!stream) throw std::runtime_error("cannot read AFK journal"); + const auto data = nlohmann::json::parse(stream); + if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ || + !data.at("requests").is_array() || !data.at("sessions").is_array() || + data.at("requests").size() + data.at("sessions").size() > max_entries) + throw std::runtime_error("invalid AFK journal; preserve for recovery"); + snapshot result; + std::set requests, sessions; + for (const auto& body : data.at("requests")) + { + auto value = decode(body.get()); + if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request"); + result.requests.push_back(std::move(value)); + } + for (const auto& entry : data.at("sessions")) + { + session value{entry.at("body").get(),entry.at("phase").get(),0,0}; + const auto request = decode(value.body); + const auto body = nlohmann::json::parse(value.body); + if (request.action != "reserve" || !sessions.emplace(session_id(value)).second || + (value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") || + !integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds)) + throw std::runtime_error("invalid AFK session"); + value.authorized_seconds = entry.at("authorized_seconds").get(); + value.consumed_seconds = entry.at("consumed_seconds").get(); + if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get() || + (value.phase == "prepared" && value.authorized_seconds != 0) || + (value.phase != "prepared" && value.authorized_seconds == 0) || + (value.phase != "active" && value.consumed_seconds != 0)) + throw std::runtime_error("invalid AFK session accounting"); + result.sessions.push_back(std::move(value)); + } + return result; + } + + void journal::save(const snapshot& value) const + { + if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full"); + nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_}, + {"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}}; + for (const auto& request : value.requests) data["requests"].push_back(request.body); + for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase}, + {"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}}); + const auto encoded = data.dump(); + if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full"); + std::filesystem::create_directories(file_.parent_path()); + auto temporary = file_; temporary += ".tmp"; + std::ofstream stream(temporary, std::ios::binary | std::ios::trunc); + stream.write(encoded.data(),static_cast(encoded.size())); + stream.flush(); + if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained"); + stream.close(); + if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained"); +#ifdef _WIN32 + if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw std::runtime_error("cannot replace AFK journal; old file retained"); +#else + std::filesystem::rename(temporary,file_); +#endif + } + std::vector journal::load() const { return read().requests; } + void journal::store(const std::string& body) const + { + auto value = read(); + const auto incoming = decode(body); + // Snapshot replacement is atomic with the new snapshot. Cumulative + // counters retain every reward during an outage without an unbounded + // request per kill. An in-flight older HTTP request remains idempotent; + // removing its old ID cannot remove this newer request. + if (incoming.action == "event") + { + const auto next = nlohmann::json::parse(body); + for (const auto& entry : value.requests) + { + if (entry.action != "event" || entry.id == incoming.id) continue; + const auto prior = nlohmann::json::parse(entry.body); + if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue; + if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression"); + for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"}) + if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression"); + } + // Check reused identities before removing any superseded request. + append(value.requests,incoming); + value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry) + { + if (entry.action != "event" || entry.id == incoming.id) return false; + const auto prior = nlohmann::json::parse(entry.body); + return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match"); + }),value.requests.end()); + } + else append(value.requests,incoming); + save(value); + } + void journal::erase(const std::string& id) const + { + auto value = read(); + value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end()); + save(value); + } + void journal::prepare(const std::string& body) const + { + const auto request = decode(body); + if (request.action != "reserve") throw std::runtime_error("expected AFK reserve"); + const auto id = nlohmann::json::parse(body).at("session_id").get(); + auto value = read(); + for (const auto& existing : value.sessions) + if (session_id(existing) == id) + { + if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict"); + return; + } + append(value.requests,request); + value.sessions.push_back({body,"prepared",0,0}); + save(value); + } + void journal::authorize(const std::string& id, int seconds) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get() || + (session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict"); + if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); } + return; + } + throw std::runtime_error("missing AFK session"); + } + void journal::begin(const std::string& id, clock::time_point now) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped"); + if (session.phase == "active" && active.count(key(id))) return; + if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict"); + session.phase = "active"; + save(value); // Failure prevents protection; never reset an existing clock. + // Production clocks start after the durable write; slow storage + // must not charge time before the script can apply protection. + if (now == clock::time_point{}) now = clock::now(); + active.emplace(key(id),active_clock{now,session.authorized_seconds}); + return; + } + throw std::runtime_error("missing AFK session"); + } + int journal::elapsed(const session& value, clock::time_point now, bool round_up) const + { + const auto found = active.find(key(session_id(value))); + if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart"); + const auto seconds = std::chrono::duration(now - found->second.started).count(); + return std::max(value.consumed_seconds,static_cast(std::min(static_cast(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds))))); + } + int journal::remaining(const std::string& id, clock::time_point now) const + { + const auto found = active.find(key(id)); + if (found == active.end()) return -1; + if (stopped.count(key(id))) return 0; + const auto elapsed_ms = std::chrono::duration_cast(now - found->second.started).count(); + const auto authorized_ms = static_cast(found->second.authorized_seconds) * 1000; + return static_cast(std::max(0, authorized_ms - std::max(0, elapsed_ms))); + } + int journal::checkpoint(const std::string& id, clock::time_point now) const + { + auto value = read(); + for (auto& session : value.sessions) + if (session_id(session) == id) + { + if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active"); + const auto seconds = elapsed(session,now); + if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); } + return seconds; + } + throw std::runtime_error("missing AFK session"); + } + request journal::settlement(const session& value, const std::string& reason, int consumed) const + { + auto data = nlohmann::json::parse(value.body); + const auto id = session_id(value); + nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")}, + {"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}}; + if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; } + else body["consumed_seconds"] = consumed; + return decode(body.dump()); + } + void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const + { + const std::set reasons = {"manual","disconnect","death","match_end","expired","never_started"}; + if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason"); + // The caller has already removed protection. Freeze the first stop even + // if disk read/write fails, so retries cannot charge time after return. + if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason}); + auto value = read(); + for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it) + if (session_id(*it) == id) + { + const auto end = stopped.find(key(id)); + const auto stop_time = end == stopped.end() ? now : end->second.time; + const auto stop_reason = end == stopped.end() ? reason : end->second.reason; + const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0; + if ((stop_reason == "never_started" && it->phase == "active") || + (stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds))) + throw std::runtime_error("AFK stop reason conflicts with usage"); + append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed)); + erase_session_requests(value.requests,id); + value.sessions.erase(it); + save(value); + active.erase(key(id)); + stopped.erase(key(id)); + return; + } + // Duplicate stop is safe after a successful durable transition. + } + void journal::recover(const std::string& current_match) const + { + if (current_match.empty()) throw std::runtime_error("missing current AFK match"); + auto value = read(); + for (const auto& session : value.sessions) + { + const auto id = session_id(session); + if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match) + throw std::runtime_error("cannot recover live AFK protection"); + append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0)); + erase_session_requests(value.requests,id); + } + const auto sessions = value.sessions; + value.sessions.clear(); + save(value); + for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); } + } +} diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/afk.hpp b/t6/plugins/tws-t6-gsc-utils/src/utils/afk.hpp new file mode 100644 index 0000000..7b0291b --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/afk.hpp @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +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 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 requests; std::vector 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; + }; +} diff --git a/t6/scripts/README.md b/t6/scripts/README.md index d3da8f6..007be21 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -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. diff --git a/t6/scripts/tws_t6_afk.gsc b/t6/scripts/tws_t6_afk.gsc index baaec8e..93b5bb6 100644 --- a/t6/scripts/tws_t6_afk.gsc +++ b/t6/scripts/tws_t6_afk.gsc @@ -1,8 +1,7 @@ -/* T5-style .afk / .back listener with T6 control and weapon blocking. */ +/* Persistent AFK reserve. Requires the TWS native AFK journal API and EC /api/afk. */ #include maps\mp\_utility; #include common_scripts\utility; -// Plutonium probes both entry points; runtime setup belongs in init. main() { } @@ -10,17 +9,214 @@ main() init() { scripts\zm\tws_t6_common::ensure_config(); - scripts\zm\tws_t6_common::register_help_command(".afk", "Pause with protection"); + level.afk_url = "https://ec.thewrightserver.net/api/afk"; + level.afk_server = GetDvar("tws_server_id"); + level.afk_token = GetDvar("tws_bank_token"); + level.afk_ready = false; + level.afk_ended = false; + level.afk_players = []; + level.afk_requests = []; + level.afk_stops = []; + level.afk_grace_seconds = 2; + level.afk_cooldown_seconds = 15; + level.afk_warning_sound = "zmb_perks_packa_ready"; + if (GetDvar("tws_afk_warning_sound") != "") + level.afk_warning_sound = GetDvar("tws_afk_warning_sound"); + if (GetDvar("tws_afk_cooldown_seconds") != "") + level.afk_cooldown_seconds = max(2, min(300, GetDvarInt("tws_afk_cooldown_seconds"))); + + scripts\zm\tws_t6_common::register_help_command(".afk", "Protected AFK using your time reserve"); + scripts\zm\tws_t6_common::register_help_command(".afk balance", "Free allowance and saved time"); + scripts\zm\tws_t6_common::register_help_command(".afk buy ", "Quote a bank purchase; confirmation required"); scripts\zm\tws_t6_common::register_help_command(".back", "Return from AFK"); - afk_init(); scripts\zm\tws_t6_common::register_spawn_handler(::afk_spawn); + + // Use the stock damage-callback chain, not the shared engine god-mode bit. + // At expiry this hook immediately falls through, preserving stock protection. + callbacks = []; + callbacks[0] = ::afk_damage; + if (isDefined(level.player_damage_callbacks)) + for (i = 0; i < level.player_damage_callbacks.size; i++) + callbacks[callbacks.size] = level.player_damage_callbacks[i]; + level.player_damage_callbacks = callbacks; + level thread afk_chat_listener(); + level thread afk_startup(); + level thread afk_end_watcher(); + level thread afk_round_watcher(); + level thread afk_team_watcher(); } -afk_init() +afk_startup() { - level.afk_grace_seconds = 2; // invuln tail on .back + if (GetDvar("tws_afk_enabled") == "0" || level.afk_server == "" || + level.afk_token.size < 40 || getSubStr(level.afk_token, 0, 8) != "ec_bank_") + { + println("[tws_afk] Disabled: configure tws_server_id and private tws_bank_token"); + return; + } + while (!is_true(level.telemetry_started) || !isDefined(level.telemetry_match)) + wait 0.1; + level.afk_match = level.telemetry_match; + if (!afkRecover(level.afk_server, level.afk_url, level.afk_match)) + { + println("[tws_afk] Journal recovery failed; protected AFK disabled"); + return; + } + level.afk_ready = true; + println("[tws_afk] Ready native=" + afkVersion()); + level thread afk_sender(); +} - level thread afk_chat_listener(); +afk_guid(player) +{ + if (!isDefined(player)) + return undefined; + guid = player.guid; + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return undefined; + guid = "" + guid; + if (guid.size < 1 || guid.size > 64) + return undefined; + allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-"; + for (i = 0; i < guid.size; i++) + { + found = false; + for (j = 0; j < allowed.size; j++) + if (guid[i] == allowed[j]) + found = true; + if (!found) + return undefined; + } + return guid; +} + +afk_spawn() +{ + self endon("disconnect"); + if (isDefined(self.tws_afk_state)) + return; + guid = afk_guid(self); + if (!isDefined(guid)) + return; + if (!isDefined(level.afk_players[guid])) + { + state = spawnStruct(); + state.guid = guid; + state.seq = 0; + state.active_ms = 0; + state.kills = 0; + state.headshots = 0; + state.revives = 0; + state.rounds = 0; + state.last_round_awarded = 0; + state.round_active_ms = 0; + state.round_contribution = false; + level.afk_players[guid] = state; + } + progress = level.afk_players[guid]; + if (isDefined(progress.owner) && progress.owner.connected) + return; + // Connection state is never reused: an old HTTP completion cannot mutate a + // reconnect's session or confirmation. Only cumulative match counters persist. + state = spawnStruct(); + state.guid = guid; + state.progress = progress; + progress.owner = state; + state.player = self; + state.connected = true; + state.pending = false; + state.cancelled = false; + state.session = undefined; + state.quote = undefined; + state.last_exit = -300000; + state.last_input = getTime() - 60000; + state.last_engaged = getTime() - 60000; + state.last_flush = getTime(); + self.tws_afk_state = state; + self.hud_afk_on = false; + self.tws_afk_protected = false; + self.tws_afk_controls_locked = false; + self.tws_afk_ignore_owned = false; + self.tws_afk_grace_until = 0; + self.tws_afk_authorized = 0; + self.tws_afk_last_second = -1; + self afk_hud_create(); + self thread afk_disconnect_watcher(state); + self thread afk_lifecycle_watcher(); + self thread afk_kill_watcher(); + self thread afk_revive_watcher(); + self thread afk_activity_watcher(); + self thread afk_tick(); +} + +afk_hud_create() +{ + // Separate foreground elements stay visible when the optional core HUD hides. + timer = NewClientHudElem(self); + timer.horzAlign = "center"; + timer.vertAlign = "middle"; + timer.alignX = "center"; + timer.alignY = "middle"; + timer.x = 0; + timer.y = -30; + timer.font = "default"; + timer.fontScale = 3.0; + timer.foreground = true; + timer.sort = 100; + timer.alpha = 0; + timer SetText("AFK"); + self.hud_afk_elem = timer; + warning = NewClientHudElem(self); + warning.horzAlign = "center"; + warning.vertAlign = "middle"; + warning.alignX = "center"; + warning.alignY = "middle"; + warning.x = 0; + warning.y = 5; + warning.font = "default"; + warning.fontScale = 1.3; + warning.foreground = true; + warning.sort = 100; + warning.alpha = 0; + warning SetText("Protection ends when this timer reaches zero."); + self.tws_afk_warning_elem = warning; + elems = []; + elems[0] = timer; + elems[1] = warning; + self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); +} + +afk_playing(player) +{ + return isDefined(player) && !level.afk_ended && !is_true(level.intermission) && + !is_true(level.gameended) && isAlive(player) && + isDefined(player.sessionstate) && player.sessionstate == "playing" && + !(player maps\mp\zombies\_zm_laststand::player_is_in_laststand()) && + !is_true(player.is_zombie) && !is_true(player.afterlife); +} + +afk_stock_controls(player) +{ + return !afk_playing(player) || is_true(level.player_movement_suppressed) || + isDefined(level.hostmigrationtimer) || is_true(level.host_ended_game) || + is_true(player.hostmigrationcontrolsfrozen) || + is_true(player.disabledweapon) || is_true(player.disabledusability) || + is_true(player.tws_afk_external_controls); +} + +afk_can_enter(player) +{ + if (!afk_playing(player) || afk_stock_controls(player)) + return false; + return !is_true(player.ignoreme) && !isGodMode(player); +} + +afk_can_earn(player) +{ + // Manual .back restores play immediately; its separate protection tail + // must not discard successful revives or other contributions after return. + return afk_playing(player) && isDefined(player.tws_afk_state) && + !is_true(player.hud_afk_on); } afk_chat_listener() @@ -28,101 +224,415 @@ afk_chat_listener() for (;;) { level waittill("say", message, player); - if (!isDefined(player) || !isDefined(message)) continue; - - cmd = toLower(message); - - if (cmd == ".afk") - player thread afk_enter(); - else if (cmd == ".back") - player thread afk_exit(); + args = strTok(toLower(message), " "); + if (args.size == 0) + continue; + if (args[0] == ".back" && args.size == 1) + { + if (isDefined(player.tws_afk_state)) + { + player.tws_afk_state.cancelled = true; + player thread afk_exit(); + } + continue; + } + if (args[0] != ".afk") + continue; + if (!level.afk_ready || !isDefined(player.tws_afk_state) || level.afk_ended) + { + player iPrintLnBold("AFK reserve unavailable. No bank points have been spent."); + continue; + } + state = player.tws_afk_state; + if (state.pending) + { + player iPrintLnBold("AFK request is still pending. Use .back to cancel entry."); + continue; + } + if (args.size == 1) + { + if (!is_true(player.hud_afk_on)) + level thread afk_enter(player, state); + } + else if ((args.size == 2 && args[1] == "balance") || + (args.size >= 3 && args[1] == "buy")) + { + state.pending = true; + level thread afk_account_command(player, state, args); + } + else + player iPrintLnBold("Usage: .afk | .back | .afk balance | .afk buy "); } } -afk_spawn() +afk_base(state, action) +{ + data = []; + data["action"] = action; + data["server"] = level.afk_server; + data["guid"] = state.guid; + data["match"] = level.afk_match; + if (action != "balance") + data["request_id"] = telemetryNewMatch(); + return data; +} + +afk_say(state, message) +{ + if (state.connected && isDefined(state.player)) + state.player iPrintLnBold(message); +} + +afk_minutes(text) +{ + if (text.size < 1 || text.size > 4) + return 0; + digits = "0123456789"; + for (i = 0; i < text.size; i++) + { + found = false; + for (j = 0; j < digits.size; j++) + if (text[i] == digits[j]) + found = true; + if (!found) + return 0; + } + return int(text); +} + +afk_account_command(player, state, args) +{ + afk_account_request(player, state, args); + state.pending = false; +} + +afk_account_request(player, state, args) +{ + if (args[1] == "buy" && (is_true(player.hud_afk_on) || + is_true(player.tws_afk_protected) || isDefined(state.session))) + { + state.quote = undefined; + afk_say(state, "Use .back before buying time. Purchases apply to your next AFK session."); + return; + } + if (args[1] == "buy" && args.size == 4 && args[3] == "confirm") + { + quote = state.quote; + state.quote = undefined; + if (!isDefined(quote) || getTime() > quote.expires || + afk_minutes(args[2]) != quote.minutes || !state.connected) + { + afk_say(state, "Purchase confirmation expired. Request .afk buy again."); + return; + } + data = afk_base(state, "purchase"); + data["packs"] = quote.packs; + data["confirmed"] = true; + data["expected_purchase_points"] = quote.points; + data["expected_purchase_seconds"] = quote.seconds; + // Persist the same ID before sending. Retries never become a new charge. + if (!afk_enqueue(data, state)) + afk_say(state, "AFK storage unavailable. No purchase was sent."); + else + afk_say(state, "Purchase submitted; checking your bank and reserve capacity."); + return; + } + if (args[1] == "buy" && args.size != 3) + { + afk_say(state, "Usage: .afk buy , then .afk buy confirm"); + return; + } + data = afk_base(state, "balance"); + reply = afk_http("balance", jsonSerialize(data), ""); + if (!state.connected) + return; + if (!isDefined(reply) || reply["state"] != "balance") + { + afk_say(state, "AFK account unavailable. Existing authorized protection keeps counting down."); + return; + } + if (args[1] == "balance") + { + afk_say(state, "AFK free " + afk_format(reply["free_seconds"]) + + " | saved " + afk_format(reply["saved_seconds"]) + + " | reserved " + afk_format(reply["reserved_seconds"])); + return; + } + minutes = afk_minutes(args[2]); + seconds = minutes * 60; + if (minutes < 1 || reply["purchase_seconds"] < 1 || seconds % reply["purchase_seconds"] != 0) + { + afk_say(state, "Buy whole packs of " + afk_format(reply["purchase_seconds"]) + " each."); + return; + } + packs = int(seconds / reply["purchase_seconds"]); + if (packs > reply["max_purchase_packs"]) + { + afk_say(state, "Purchase would exceed reserve capacity. No points spent."); + return; + } + quote = spawnStruct(); + quote.minutes = minutes; + quote.packs = packs; + quote.points = reply["purchase_points"]; + quote.seconds = reply["purchase_seconds"]; + quote.expires = getTime() + 30000; + state.quote = quote; + afk_say(state, "Buy " + minutes + " minutes for " + (packs * reply["purchase_points"]) + + " bank points? Type .afk buy " + minutes + " confirm within 30 seconds."); +} + +afk_enter(player, state) +{ + state.pending = true; + state.cancelled = false; + afk_activate(player, state); + state.pending = false; +} + +afk_activate(player, state) +{ + if (!state.connected || !afk_can_enter(player)) + { + afk_say(state, "AFK requires a living, standing player with normal controls."); + return; + } + if (getTime() - state.last_exit < level.afk_cooldown_seconds * 1000 || + is_true(player.tws_afk_protected)) + { + afk_say(state, "Wait briefly after returning before entering AFK again."); + return; + } + if (afk_other_active(player) == 0) + { + afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight."); + return; + } + data = afk_base(state, "reserve"); + data["session_id"] = telemetryNewMatch(); + data["seconds"] = 604800; + session = data["session_id"]; + state.session = session; + body = jsonSerialize(data); + if (!afkPrepare(level.afk_server, level.afk_url, body)) + { + state.session = undefined; + afk_say(state, "AFK journal unavailable. Protection was not started."); + return; + } + reply = afk_http("reserve", body, data["request_id"]); + if (!isDefined(reply) || reply["state"] != "reserved" || reply["authorized_seconds"] <= 0 || + reply["session_id"] != session) + { + afk_stop_state(state, "never_started"); + if (isDefined(reply) && reply["code"] == "empty") + afk_say(state, "No AFK time available. Earn time through play or use .afk buy ."); + else + afk_say(state, "AFK authorization unavailable or already reserved elsewhere. Protection was not started."); + return; + } + if (!state.connected || state.cancelled || !isDefined(state.session) || state.session != session) + { + afk_stop_state(state, "never_started"); + return; + } + authorized = reply["authorized_seconds"]; + if (!afkAuthorize(level.afk_server, level.afk_url, state.session, authorized)) + { + afk_stop_state(state, "never_started"); + return; + } + afkRemove(level.afk_server, level.afk_url, data["request_id"]); + start = afk_base(state, "start"); + start["session_id"] = state.session; + start_body = jsonSerialize(start); + if (!afkStore(level.afk_server, level.afk_url, start_body)) + { + afk_stop_state(state, "never_started"); + return; + } + reply = afk_http("start", start_body, start["request_id"]); + if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || + reply["authorized_seconds"] != authorized || !state.connected || + state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) + { + afk_stop_state(state, "never_started"); + afk_say(state, "AFK entry cancelled. No protected time used."); + return; + } + afkRemove(level.afk_server, level.afk_url, start["request_id"]); + // No wait between the durable native start clock and applying protection. + if (!afkBegin(level.afk_server, level.afk_url, state.session)) + { + afk_stop_state(state, "never_started"); + return; + } + player.tws_afk_authorized = authorized; + player.tws_afk_next_checkpoint = getTime() + 1000; + player.tws_afk_ignore_before = player.ignoreme; + player.tws_afk_ignore_owned = true; + player.ignoreme = true; + player.hud_afk_on = true; + player.tws_afk_protected = true; + player.tws_afk_grace_until = 0; + player.tws_afk_last_second = -1; + player afk_lock_controls(); + player afk_countdown(); + afk_say(state, "AFK - type .back to return. Protection ends when this timer reaches zero."); +} + +afk_damage(inflictor, attacker, damage, flags, mod, weapon, point, direction, hitloc, offset) +{ + if (is_true(self.tws_afk_protected) && afk_playing(self) && + ((is_true(self.hud_afk_on) && self afk_remaining() > 0) || + (!is_true(self.hud_afk_on) && getTime() < self.tws_afk_grace_until))) + return 0; + if (damage > 0 && afk_can_earn(self)) + self.tws_afk_state.last_engaged = getTime(); + return -1; +} + +afk_tick() { self endon("disconnect"); - self.tws_afk_generation = 0; - self.hud_afk_on = false; - self.tws_afk_protected = false; - self.tws_afk_controls_locked = false; - elem = scripts\zm\tws_t6_common::make_core_overlay_text(self, - level.hud_overlay_afk_x, level.hud_overlay_afk_y, "center", - 1.0, level.hud_c_afk); - elem SetText("[AFK]"); - scripts\zm\tws_t6_common::set_core_alpha(self, elem, 0); - self.hud_afk_elem = elem; - elems = []; - elems[0] = elem; - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); for (;;) { - if (self.tws_afk_protected && !scripts\zm\tws_t6_common::player_active(self)) + if (is_true(self.hud_afk_on)) + { + if (self afk_remaining() <= 0) + { + self afk_finish("expired", false); + self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now."); + } + else if (!afk_playing(self) || afk_stock_controls(self)) + self afk_finish("death", false); + else + { + // Backend connectivity is irrelevant to this local deadline. + if (getTime() >= self.tws_afk_next_checkpoint) + { + elapsed = afkCheckpoint(level.afk_server, level.afk_url, self.tws_afk_state.session); + self.tws_afk_next_checkpoint = getTime() + 1000; + if (elapsed < 0) + { + self afk_finish("manual", false); + self iPrintLnBold("AFK journal unavailable. Protection ended; unused time will reconcile."); + } + } + if (self.hud_afk_on) + { + self afk_lock_controls(); + self afk_countdown(); + } + } + } + else if (is_true(self.tws_afk_protected) && getTime() >= self.tws_afk_grace_until) self afk_release(); - if (self.hud_afk_on) - self afk_lock_controls(); else if (self.tws_afk_controls_locked) self afk_restore_controls(); + if (!self.tws_afk_protected && self.tws_afk_ignore_owned) + self afk_restore_targeting(); wait 0.05; } } -afk_enter() +afk_remaining() { - if (!isDefined(self.hud_afk_on) || self.hud_afk_on) + if (!isDefined(self.tws_afk_state.session)) + return 0; + return afkRemaining(level.afk_server, level.afk_url, self.tws_afk_state.session); +} + +afk_countdown() +{ + remaining = int((max(0, self afk_remaining()) + 999) / 1000); + if (remaining == self.tws_afk_last_second) return; - if (!scripts\zm\tws_t6_common::player_active(self)) - return; - // Do not take ownership of stock ignoreme (laststand, afterlife, etc.). - if (is_true(self.ignoreme) && !self.tws_afk_protected) - return; - self.tws_afk_generation++; - self.hud_afk_on = true; - self.tws_afk_protected = true; - self.ignoreme = true; - self enableInvulnerability(); - self afk_lock_controls(); - scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 1); - self iPrintLnBold("AFK - type .back to return"); + self.tws_afk_last_second = remaining; + self.hud_afk_elem SetTextUnlimited("AFK " + afk_format(remaining)); + self.hud_afk_elem.alpha = 1; + self.tws_afk_warning_elem.alpha = 1; + self.hud_afk_elem.fontScale = 3.0; + self.hud_afk_elem.color = (1, 1, 1); + if (remaining <= 60) + { + self.hud_afk_elem.color = (1, 0.25, 0.1); + self.hud_afk_elem.fontScale = 3.5; + if (remaining == 60 || remaining == 30 || remaining <= 10) + self playLocalSound(level.afk_warning_sound); + } +} + +afk_format(seconds) +{ + minutes = int(seconds / 60); + seconds = int(seconds % 60); + tail = "" + seconds; + if (seconds < 10) + tail = "0" + seconds; + return minutes + ":" + tail; } afk_exit() { - self endon("disconnect"); - if (!isDefined(self.hud_afk_on) || !self.hud_afk_on) + if (!is_true(self.hud_afk_on)) return; - self.hud_afk_on = false; - self afk_restore_controls(); - scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0); - self.tws_afk_generation++; - generation = self.tws_afk_generation; + if (self afk_remaining() <= 0) + { + self afk_finish("expired", false); + self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now."); + return; + } + self afk_finish("manual", true); self iPrintLnBold("Back in the fight"); - wait level.afk_grace_seconds; - if (generation == self.tws_afk_generation && self.tws_afk_protected) +} + +afk_finish(reason, manual_grace) +{ + state = self.tws_afk_state; + self.hud_afk_on = false; + state.last_exit = getTime(); + self.hud_afk_elem.alpha = 0; + self.tws_afk_warning_elem.alpha = 0; + self afk_restore_controls(); + if (manual_grace && afk_playing(self)) + self.tws_afk_grace_until = getTime() + level.afk_grace_seconds * 1000; + else self afk_release(); + // Accepted transition, never inferred from the chat text. Manual grace is + // separate from paid AFK; its two-second tail cannot be renewed by re-entry. + afk_stop_state(state, reason); + afk_flush(state); } afk_release() { - self.tws_afk_generation++; self.hud_afk_on = false; self.tws_afk_protected = false; + self.tws_afk_grace_until = 0; self afk_restore_controls(); - scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0); - // Stock scripts own protection during laststand/intermission/death. - if (scripts\zm\tws_t6_common::player_active(self)) + if (isDefined(self.hud_afk_elem)) + self.hud_afk_elem.alpha = 0; + if (isDefined(self.tws_afk_warning_elem)) + self.tws_afk_warning_elem.alpha = 0; + // Stock laststand/afterlife/intermission takes over ignoreme and controls. + // Our damage hook has already relinquished protection even in those states. + self afk_restore_targeting(); +} + +afk_restore_targeting() +{ + if (self.tws_afk_ignore_owned && afk_playing(self) && !afk_stock_controls(self)) { - self.ignoreme = false; - self disableInvulnerability(); + self.ignoreme = self.tws_afk_ignore_before; + self.tws_afk_ignore_owned = false; } } afk_lock_controls() { - // Stock gameplay can clear the freeze flag after AFK entry. Maintain it - // while AFK and explicitly block guns and offhand weapons as well. self.tws_afk_controls_locked = true; self freezeControls(true); self disableWeapons(); @@ -131,14 +641,378 @@ afk_lock_controls() afk_restore_controls() { - if (!self.tws_afk_controls_locked) - return; - // Leave laststand/intermission controls to stock scripts. If the player - // respawns, the watcher restores our controls once they are active again. - if (!scripts\zm\tws_t6_common::player_active(self)) + if (!self.tws_afk_controls_locked || afk_stock_controls(self)) return; self.tws_afk_controls_locked = false; self freezeControls(false); self enableWeapons(); self enableOffhandWeapons(); } + +afk_stop_state(state, reason) +{ + if (!isDefined(state.session)) + return; + stop = spawnStruct(); + stop.session = state.session; + stop.reason = reason; + state.session = undefined; + if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason)) + level.afk_stops[level.afk_stops.size] = stop; +} + +afk_disconnect_watcher(state) +{ + self waittill("disconnect"); + // No endon: checkpoint/stop while the departing entity is still valid. + if (is_true(self.hud_afk_on)) + self afk_finish("disconnect", false); + else + afk_stop_state(state, "never_started"); + state.connected = false; + state.cancelled = true; + afk_flush(state); +} + +afk_lifecycle_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill_any("killed_player", "spawned_player"); + if (is_true(self.hud_afk_on)) + self afk_finish("death", false); + else if (is_true(self.tws_afk_protected)) + self afk_release(); + self.tws_afk_state.cancelled = true; + } +} + +afk_end_watcher() +{ + level waittill_any("end_game", "game_ended", "intermission"); + // Stop without yielding before stock intermission takes control. + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + if (!isDefined(players[i].tws_afk_state)) + continue; + if (is_true(players[i].hud_afk_on)) + players[i] afk_finish("match_end", false); + else if (is_true(players[i].tws_afk_protected)) + players[i] afk_release(); + players[i].tws_afk_state.cancelled = true; + afk_flush(players[i].tws_afk_state); + } + level.afk_ended = true; +} + +afk_other_active(excluded) +{ + count = 0; + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (isDefined(excluded) && player == excluded) + continue; + if (!afk_playing(player) || is_true(player.hud_afk_on)) + continue; + if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending) + continue; + count++; + } + return count; +} + +afk_team_watcher() +{ + level endon("end_game"); + level endon("game_ended"); + level endon("intermission"); + for (;;) + { + players = getPlayers(); + protected = 0; + survivors = 0; + stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (is_true(player.afterlife)) + stock_recovery = true; + if (is_true(player.hud_afk_on)) + protected++; + else if (afk_playing(player)) + survivors++; + } + if (protected > 0 && survivors == 0 && !stock_recovery) + { + for (i = 0; i < players.size; i++) + if (is_true(players[i].hud_afk_on)) + players[i] afk_finish("match_end", false); + // AFK does not change sessionstate/player count or zombie scaling. + // The verified stock end signal prevents AFK bodies prolonging play. + level notify("end_game"); + return; + } + wait 0.05; + } +} + +// Both stock engines notify the credited attacker, with the actual dead zombie. +// Never infer contributions from spendable score, score_total, or bank actions. +afk_kill_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill("zom_kill", zombie); + afk_credit_kill(self, zombie); + } +} + +afk_credit_kill(player, zombie) +{ + if (!afk_can_earn(player) || !isDefined(zombie) || + !isDefined(zombie.attacker) || zombie.attacker != player || + is_true(zombie.tws_afk_rewarded)) + return; + zombie.tws_afk_rewarded = true; + state = player.tws_afk_state; + state.progress.kills++; + if (afk_headshot(zombie)) + state.progress.headshots++; + state.progress.round_contribution = true; + state.last_engaged = getTime(); + // Cumulative kill/headshot totals are journaled by the 30-second flush. +} + +afk_headshot(zombie) +{ + if (!isDefined(zombie.damagelocation) || !isDefined(zombie.damagemod) || + !isDefined(zombie.damageweapon)) + return false; + return maps\mp\zombies\_zm_utility::is_headshot(zombie.damageweapon, + zombie.damagelocation, zombie.damagemod); +} + +// The revived teammate owns this notification. A successful repeat revive is a +// new event every time; no teammate cache, cooldown, or reward limiter belongs here. +afk_revive_watcher() +{ + self endon("disconnect"); + for (;;) + { + self waittill("player_revived", reviver); + if (!isDefined(reviver) || reviver == self || !afk_can_earn(reviver)) + continue; + state = reviver.tws_afk_state; + state.progress.revives++; + state.progress.round_contribution = true; + state.last_engaged = getTime(); + afk_flush(state); + } +} + +afk_round_watcher() +{ + for (;;) + { + level waittill("end_of_round"); + players = getPlayers(); + for (i = 0; i < players.size; i++) + { + player = players[i]; + if (!isDefined(player.tws_afk_state)) + continue; + state = player.tws_afk_state; + if (afk_can_earn(player) && state.progress.last_round_awarded != level.round_number && + (state.progress.round_contribution || state.progress.round_active_ms >= 30000)) + { + state.progress.rounds++; + state.progress.last_round_awarded = level.round_number; + afk_flush(state); + } + } + identities = getArrayKeys(level.afk_players); + for (i = 0; i < identities.size; i++) + { + progress = level.afk_players[identities[i]]; + progress.round_active_ms = 0; + progress.round_contribution = false; + } + } +} + +afk_activity_watcher() +{ + self endon("disconnect"); + state = self.tws_afk_state; + previous_origin = self.origin; + previous_angles = self getPlayerAngles(); + previous_buttons = self afk_buttons(); + previous_time = getTime(); + for (;;) + { + wait 1; + now = getTime(); + elapsed = min(1000, max(0, now - previous_time)); + angles = self getPlayerAngles(); + buttons = self afk_buttons(); + if (afk_can_earn(self)) + { + changed = distanceSquared(previous_origin, self.origin) > 16 || + distanceSquared(previous_angles, angles) > 4 || buttons != previous_buttons; + if (changed) + { + state.last_input = now; + state.last_engaged = now; + } + // A stationary revive is real support. Holding a zombie also counts + // with recent deliberate input; simply being targeted indefinitely + // or leaving a button held does not constitute active play. + if (self afk_supporting() || + (now - state.last_input <= 60000 && self afk_holding_zombie())) + state.last_engaged = now; + if (now - state.last_engaged <= 15000) + { + state.progress.active_ms += elapsed; + state.progress.round_active_ms += elapsed; + } + } + previous_origin = self.origin; + previous_angles = angles; + previous_buttons = buttons; + previous_time = now; + if (now - state.last_flush >= 30000) + afk_flush(state); + } +} + +afk_buttons() +{ + return (self useButtonPressed()) + 2 * (self attackButtonPressed()) + + 4 * (self adsButtonPressed()) + 8 * (self meleeButtonPressed()); +} + +afk_supporting() +{ + if (!(self useButtonPressed())) + return false; + players = getPlayers(); + for (i = 0; i < players.size; i++) + if (players[i] != self && + (players[i] maps\mp\zombies\_zm_laststand::player_is_in_laststand()) && + isDefined(players[i].revivetrigger) && + (self maps\mp\zombies\_zm_laststand::is_reviving(players[i]))) + return true; + return false; +} + +afk_holding_zombie() +{ + if (!isDefined(level.zombie_team)) + return false; + zombies = GetAiSpeciesArray(level.zombie_team, "all"); + for (i = 0; i < zombies.size; i++) + if (isAlive(zombies[i]) && isDefined(zombies[i].enemy) && zombies[i].enemy == self && + distanceSquared(self.origin, zombies[i].origin) <= 262144) + return true; + return false; +} + +afk_flush(state) +{ + if (!level.afk_ready || !isDefined(level.afk_match)) + return; + data = afk_base(state, "event"); + state.progress.seq++; + data["seq"] = state.progress.seq; + data["active_seconds"] = int(state.progress.active_ms / 1000); + data["kills"] = state.progress.kills; + data["headshots"] = state.progress.headshots; + data["rounds"] = state.progress.rounds; + data["revives"] = state.progress.revives; + data["activity"] = "playing"; + if (afk_enqueue(data, undefined)) + state.last_flush = getTime(); +} + +afk_enqueue(data, state) +{ + if (!isDefined(data["request_id"])) + return false; + if (!afkStore(level.afk_server, level.afk_url, jsonSerialize(data))) + return false; + if (isDefined(state)) + level.afk_requests[data["request_id"]] = state; + return true; +} + +afk_sender() +{ + for (;;) + { + pending_stops = []; + for (i = 0; i < level.afk_stops.size; i++) + { + stop = level.afk_stops[i]; + if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason)) + pending_stops[pending_stops.size] = stop; + } + level.afk_stops = pending_stops; + entries = afkLoad(level.afk_server, level.afk_url); + if (isDefined(entries)) + for (i = 0; i < entries.size; i++) + { + entry = entries[i]; + // Never activate orphaned protection from a background retry. + if (entry["action"] == "reserve" || entry["action"] == "start") + continue; + reply = afk_http(entry["action"], entry["body"], entry["id"]); + if (!isDefined(reply)) + break; + if (isDefined(level.afk_requests[entry["id"]])) + { + state = level.afk_requests[entry["id"]]; + if (reply["state"] == "purchased") + afk_say(state, "Purchased AFK reserve. Saved: " + afk_format(reply["saved_seconds"])); + else + afk_say(state, "AFK request: " + reply["code"] + ". Use .afk balance to check your account."); + level.afk_requests[entry["id"]] = undefined; + } + if (!afkRemove(level.afk_server, level.afk_url, entry["id"])) + break; + } + wait 1; + } +} + +afk_http(action, body, request_id) +{ + headers = []; + headers["Content-Type"] = "application/json"; + headers["Authorization"] = "Bearer " + level.afk_token; + options = []; + options["method"] = "POST"; + options["headers"] = headers; + options["body"] = body; + options["connect_timeout_ms"] = 5000; + options["timeout_ms"] = 15000; + request = httpPost(level.afk_url + "/" + action, options); + if (!isDefined(request)) + return undefined; + request waittill("done", response, success, error, diagnostics); + if (!isDefined(success) || !success || !isDefined(response) || + !isDefined(diagnostics) || !isDefined(diagnostics["http_status"])) + return undefined; + status = diagnostics["http_status"]; + if (status < 200 || status >= 500 || status == 429) + return undefined; + reply = afkResponse(response); + if (!isDefined(reply) || !reply["valid"] || reply["request_id"] != request_id || + (status == 409 && reply["code"] == "not_in_match")) + return undefined; + return reply; +} diff --git a/tools/check_afk.mjs b/tools/check_afk.mjs new file mode 100644 index 0000000..d9af6f0 --- /dev/null +++ b/tools/check_afk.mjs @@ -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 && endm[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).`); diff --git a/tools/check_core_hud.mjs b/tools/check_core_hud.mjs index 7bfab8a..f7f063a 100644 --- a/tools/check_core_hud.mjs +++ b/tools/check_core_hud.mjs @@ -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).`); diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs index 0a54683..b87163b 100644 --- a/tools/check_hud.mjs +++ b/tools/check_hud.mjs @@ -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); diff --git a/tools/test_plugin_afk.py b/tools/test_plugin_afk.py new file mode 100644 index 0000000..bed8c5a --- /dev/null +++ b/tools/test_plugin_afk.py @@ -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 \n#include \n#include \n#include \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 get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector get_keys() const") + stub = stub.replace('template void add(const char*, F) {}', r""" + inline std::map&)>> functions; + template void add(const char* name, F callback) + { + functions[name] = [callback](const std::vector& args) -> scripting::script_value + { + if constexpr (std::is_invocable_v) return callback(); + else if constexpr (std::is_invocable_v) return callback(args.at(0)); + else if constexpr (std::is_invocable_v) return callback(args.at(0), args.at(1)); + else if constexpr (std::is_invocable_v) return callback(args.at(0), args.at(1), args.at(2)); + else if constexpr (std::is_invocable_v) 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 \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) diff --git a/tools/tests/afk_cases.cpp b/tools/tests/afk_cases.cpp new file mode 100644 index 0000000..5dcb103 --- /dev/null +++ b/tools/tests/afk_cases.cpp @@ -0,0 +1,158 @@ +#include "afk.hpp" +#include +#include +#include +#include +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 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; ipath(); + 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 : {"", "private-token", "{", "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"; +} diff --git a/tools/tests/afk_component_cases.cpp b/tools/tests/afk_component_cases.cpp new file mode 100644 index 0000000..55f2d05 --- /dev/null +++ b/tools/tests/afk_component_cases.cpp @@ -0,0 +1,35 @@ +// Compile the actual GSC registration adapter against portable VM interfaces. +#include +int main() +{ + afk::component component; component.on_startup(nullptr); + const auto call = [](const std::string& name, std::vector args = {}) + { return gsc::function::functions.at(name)(args); }; + assert(call("afkversion").as() == "1.0.0"); + auto ack=call("afkresponse",{"private-secret-invalid-json"}).as(); + assert(!ack["valid"].as() && ack["reason"].as() == "invalid_json"); + assert(call("afkload",{"server","endpoint"}).is()); + for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as()); + 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()); + assert(call("afkprepare",{"server","endpoint",body}).as()); + assert(call("afkauthorize",{"server","endpoint","session","600"}).as()); + assert(call("afkbegin",{"server","endpoint","session"}).as()); + assert(call("afkremaining",{"server","endpoint","session"}).as() > 0); + assert(call("afkcheckpoint",{"server","endpoint","session"}).as() >= 0); + assert(call("afkstop",{"server","endpoint","session","manual"}).as()); + assert(call("afkrecover",{"server","endpoint","new-match"}).as()); + 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()); + } + std::filesystem::remove(file); + assert(call("afkload",{"server","endpoint"}).is()); + assert(call("afkremove",{"server","endpoint","one"}).as()); +} diff --git a/versions.json b/versions.json index ecfe7e1..da205ad 100644 --- a/versions.json +++ b/versions.json @@ -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" }