Add persistent AFK reserves for both engines (Refs #12)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -368,7 +368,7 @@ count centered near the bottom above a slim health bar. The complete
|
||||
keeping the group centered as the count gains or loses digits. It uses a
|
||||
uniform font scale of `1.25` and shows `-- ZOMBIES` before data is available.
|
||||
Current/maximum HP appears to the right of the bar only while injured, then
|
||||
disappears at full health. The AFK indicator sits above the zombie count. Text
|
||||
disappears at full health. The AFK reserve countdown is independently centered on screen. Text
|
||||
uses an outline for contrast; there are no panel backgrounds or timer labels.
|
||||
|
||||
Fullscreen X/Y anchors bypass safe-area insets. Overlay positions and shader
|
||||
@@ -386,7 +386,7 @@ health and zombie-count displays freeze at game over. Health display fallbacks
|
||||
never modify gameplay max health.
|
||||
|
||||
Type `.hud` in chat to hide or show your core, including its branding, timers,
|
||||
zombie count, health bar, damage-only HP and AFK indicator. The exact command
|
||||
zombie count, health bar and damage-only HP. The AFK countdown stays visible. The exact command
|
||||
is case-insensitive. Visibility defaults on and persists across death/respawn
|
||||
within the current connection/map; reconnecting or restarting the map restores
|
||||
the visible default. Data and timers continue updating while hidden, so showing
|
||||
@@ -394,7 +394,7 @@ the HUD restores current values or the frozen game-over values. Help, records
|
||||
and the stock HUD remain unaffected.
|
||||
|
||||
Common owns one unboxed service area at the upper right. `.help` and its
|
||||
`.commands` alias show all nine installed commands in one list with command
|
||||
`.commands` alias show installed commands in pages of up to nine rows with command
|
||||
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
|
||||
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
|
||||
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
|
||||
@@ -414,8 +414,7 @@ remain private and work when the HUD module is omitted.
|
||||
|
||||
Both games allocate only the active service view and release it when idle or
|
||||
before switching between help and records. Pages reuse the active view's
|
||||
elements. The core uses 10 archived elements, plus one for AFK. The standard
|
||||
nine-command help view uses 19 nonarchived elements, or 20 with a multi-page
|
||||
elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page
|
||||
counter; records use four, or five with a multi-page counter. Hidden elements
|
||||
still occupy slots. The controller and pending requests are separate from
|
||||
display lifetime, so an idle service holds no HUD elements.
|
||||
@@ -427,8 +426,8 @@ avoiding dynamic counter strings in the text config-string pool. Numeric HP
|
||||
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
|
||||
the map; no full server-process restart is required. On older Plutonium
|
||||
revisions, update clients and server and fully restart them before deploying.
|
||||
AFK `1.2.0` and leaderboard `1.1.0` remain compatible; AFK still requires
|
||||
common `1.4.0+`. There is no new native-plugin or backend requirement. Existing
|
||||
Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires plugin `1.4.0+`,
|
||||
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
|
||||
leaderboard plugin requirements still apply. Common retains its older panel
|
||||
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
|
||||
with the same integer shader-width handling.
|
||||
@@ -462,7 +461,7 @@ Before rollout, validate both engines in Plutonium:
|
||||
appear only while injured and disappear on full healing. Toggle AFK and back
|
||||
without overlapping zombies/health or changing existing protection behavior.
|
||||
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
|
||||
one list of nine commands, full bank syntax, no single-page counter and
|
||||
the installed commands including AFK balance/buy, full bank syntax and a page counter and
|
||||
automatic dismissal after 12 seconds. Add custom registered commands to
|
||||
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
|
||||
Remove optional modules and restart the map to confirm filtering, including
|
||||
@@ -476,7 +475,7 @@ Before rollout, validate both engines in Plutonium:
|
||||
surviving service windows. Confirm `.join`, bank commands and lobby lock
|
||||
retain their existing behavior with their required modules installed.
|
||||
6. Toggle `.hud` and `.HUD` before spawning, during play, while AFK and after
|
||||
game over. Check private visibility, all core elements and the AFK indicator,
|
||||
game over. Check private visibility, all core elements and the independently visible AFK countdown,
|
||||
continued updates while hidden, and unchanged help/records/stock HUD. Verify
|
||||
death/respawn preserves the choice and reconnect/map restart restores visibility.
|
||||
7. On T5, watch the first automatic record announcement and its dismissal, then
|
||||
@@ -610,3 +609,11 @@ policy persistence, command-result limits and deployment validation. New
|
||||
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
|
||||
configuration it stays disabled. Existing bank and telemetry credentials are
|
||||
never reused. Native plugin updates require a full game-server process restart.
|
||||
|
||||
## Persistent AFK reserves (T5 and T6)
|
||||
|
||||
See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior,
|
||||
recovery, coordinated EC deployment and the required live validation. AFK `2.0.0`
|
||||
requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`,
|
||||
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
|
||||
is required when installing the updated plugins.
|
||||
|
||||
+196
@@ -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.
|
||||
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
|
||||
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
|
||||
storage and native registration adapter with sanitizers and engine shims.
|
||||
|
||||
## AFK journal API (`1.4.0+`)
|
||||
|
||||
The AFK component exports API `1.0.0` for immutable requests, reservations,
|
||||
monotonic countdowns, durable stopped-session decisions and crash recovery.
|
||||
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
|
||||
credential, uses a separate `tws-afk` journal, and never stores credentials in
|
||||
that journal. Installing this native change requires a full process restart.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/afk.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
|
||||
{ return {"tws-afk/t5",server,endpoint}; }
|
||||
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
|
||||
static std::array<status,9> states;
|
||||
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
|
||||
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
|
||||
state.failed = failed; state.logged = now; state.suppressed = 0;
|
||||
}
|
||||
template <typename F> bool mutate(size_t operation, F action)
|
||||
{
|
||||
try { action(); storage_result(operation,false); return true; }
|
||||
catch (...) { storage_result(operation,true); return false; }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
|
||||
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server,endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0,false); return entries;
|
||||
}
|
||||
catch (...) { storage_result(0,true); return {}; }
|
||||
});
|
||||
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
|
||||
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
|
||||
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
|
||||
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
|
||||
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
|
||||
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
|
||||
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return spool(server,endpoint).remaining(id); });
|
||||
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
|
||||
catch (...) { storage_result(6,true); return -1; }
|
||||
});
|
||||
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
|
||||
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
|
||||
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
|
||||
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
|
||||
gsc::function::add("afkresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::afk::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid; result["reason"] = ack.reason;
|
||||
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
|
||||
result["session_id"] = ack.session_id;
|
||||
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
|
||||
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
|
||||
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
|
||||
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
|
||||
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
|
||||
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(afk::component)
|
||||
@@ -0,0 +1,381 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "afk.hpp"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
|
||||
constexpr int max_seconds = 604800;
|
||||
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
|
||||
std::map<std::string, active_clock> active;
|
||||
struct stopped_clock { journal::clock::time_point time; std::string reason; };
|
||||
std::map<std::string, stopped_clock> stopped;
|
||||
std::string hash(const std::string& value)
|
||||
{
|
||||
uint64_t result = 14695981039346656037ull;
|
||||
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0') << std::setw(16) << result;
|
||||
return out.str();
|
||||
}
|
||||
bool identifier(const nlohmann::json& value)
|
||||
{
|
||||
if (!value.is_string()) return false;
|
||||
const auto& text = value.get_ref<const std::string&>();
|
||||
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
|
||||
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
|
||||
}
|
||||
bool integer(const nlohmann::json& value, int maximum = 1000000000)
|
||||
{
|
||||
return value.is_number_integer() && value >= 0 && value <= maximum;
|
||||
}
|
||||
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
|
||||
void append(std::vector<request>& requests, const request& incoming)
|
||||
{
|
||||
for (const auto& value : requests)
|
||||
if (value.id == incoming.id)
|
||||
{
|
||||
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
|
||||
throw std::runtime_error("AFK immutable request conflict");
|
||||
return;
|
||||
}
|
||||
requests.push_back(incoming);
|
||||
}
|
||||
void erase_session_requests(std::vector<request>& requests, const std::string& id)
|
||||
{
|
||||
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
|
||||
{
|
||||
const auto data = nlohmann::json::parse(value.body);
|
||||
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
|
||||
}), requests.end());
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement value;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
value.state = data.at("state").get<std::string>();
|
||||
value.code = data.at("code").get<std::string>();
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
|
||||
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
|
||||
if (!states.count(value.state)) return invalid("state");
|
||||
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
|
||||
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
|
||||
const std::pair<const char*, int acknowledgement::*> fields[] = {
|
||||
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
|
||||
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
|
||||
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
|
||||
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
|
||||
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
|
||||
for (const auto& [name, member] : fields)
|
||||
{
|
||||
if (!integer(data.at(name))) return invalid("amount_range");
|
||||
value.*member = data.at(name).get<int>();
|
||||
}
|
||||
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
|
||||
if (data.contains("awarded_seconds"))
|
||||
{
|
||||
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
|
||||
value.awarded_seconds = data.at("awarded_seconds").get<int>();
|
||||
}
|
||||
if (data.contains("session_id") && !data.at("session_id").is_null())
|
||||
{
|
||||
value.session_id = data.at("session_id").get<std::string>();
|
||||
if (value.session_id.size() > 64) return invalid("field_limit");
|
||||
}
|
||||
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
|
||||
{
|
||||
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
|
||||
value.consumed_seconds = data.at("consumed_seconds").get<int>();
|
||||
}
|
||||
if ((value.state == "reserved" || value.state == "started") &&
|
||||
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
|
||||
value.valid = true;
|
||||
value.reason = "ok";
|
||||
return value;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
|
||||
{ file_ = root / (hash(scope_) + ".json"); }
|
||||
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
|
||||
|
||||
request journal::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
|
||||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
|
||||
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
|
||||
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
|
||||
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
|
||||
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
|
||||
throw std::runtime_error("invalid AFK session identity");
|
||||
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
|
||||
throw std::runtime_error("invalid AFK reservation size");
|
||||
if (value.action == "event")
|
||||
{
|
||||
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
|
||||
throw std::runtime_error("invalid AFK activity sequence");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
|
||||
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
journal::snapshot journal::read() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_)) return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error("cannot read AFK journal");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
|
||||
data.at("requests").size() + data.at("sessions").size() > max_entries)
|
||||
throw std::runtime_error("invalid AFK journal; preserve for recovery");
|
||||
snapshot result;
|
||||
std::set<std::string> requests, sessions;
|
||||
for (const auto& body : data.at("requests"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
|
||||
result.requests.push_back(std::move(value));
|
||||
}
|
||||
for (const auto& entry : data.at("sessions"))
|
||||
{
|
||||
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
|
||||
const auto request = decode(value.body);
|
||||
const auto body = nlohmann::json::parse(value.body);
|
||||
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
|
||||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
|
||||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
|
||||
throw std::runtime_error("invalid AFK session");
|
||||
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
|
||||
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
|
||||
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
|
||||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
|
||||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
|
||||
(value.phase != "active" && value.consumed_seconds != 0))
|
||||
throw std::runtime_error("invalid AFK session accounting");
|
||||
result.sessions.push_back(std::move(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void journal::save(const snapshot& value) const
|
||||
{
|
||||
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
|
||||
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
|
||||
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
|
||||
for (const auto& request : value.requests) data["requests"].push_back(request.body);
|
||||
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
|
||||
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_; temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace AFK journal; old file retained");
|
||||
#else
|
||||
std::filesystem::rename(temporary,file_);
|
||||
#endif
|
||||
}
|
||||
std::vector<request> journal::load() const { return read().requests; }
|
||||
void journal::store(const std::string& body) const
|
||||
{
|
||||
auto value = read();
|
||||
const auto incoming = decode(body);
|
||||
// Snapshot replacement is atomic with the new snapshot. Cumulative
|
||||
// counters retain every reward during an outage without an unbounded
|
||||
// request per kill. An in-flight older HTTP request remains idempotent;
|
||||
// removing its old ID cannot remove this newer request.
|
||||
if (incoming.action == "event")
|
||||
{
|
||||
const auto next = nlohmann::json::parse(body);
|
||||
for (const auto& entry : value.requests)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) continue;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
|
||||
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
|
||||
}
|
||||
// Check reused identities before removing any superseded request.
|
||||
append(value.requests,incoming);
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) return false;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
|
||||
}),value.requests.end());
|
||||
}
|
||||
else append(value.requests,incoming);
|
||||
save(value);
|
||||
}
|
||||
void journal::erase(const std::string& id) const
|
||||
{
|
||||
auto value = read();
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
|
||||
save(value);
|
||||
}
|
||||
void journal::prepare(const std::string& body) const
|
||||
{
|
||||
const auto request = decode(body);
|
||||
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
|
||||
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
|
||||
auto value = read();
|
||||
for (const auto& existing : value.sessions)
|
||||
if (session_id(existing) == id)
|
||||
{
|
||||
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
|
||||
return;
|
||||
}
|
||||
append(value.requests,request);
|
||||
value.sessions.push_back({body,"prepared",0,0});
|
||||
save(value);
|
||||
}
|
||||
void journal::authorize(const std::string& id, int seconds) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
|
||||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
|
||||
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
void journal::begin(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
|
||||
if (session.phase == "active" && active.count(key(id))) return;
|
||||
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
|
||||
session.phase = "active";
|
||||
save(value); // Failure prevents protection; never reset an existing clock.
|
||||
// Production clocks start after the durable write; slow storage
|
||||
// must not charge time before the script can apply protection.
|
||||
if (now == clock::time_point{}) now = clock::now();
|
||||
active.emplace(key(id),active_clock{now,session.authorized_seconds});
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
|
||||
{
|
||||
const auto found = active.find(key(session_id(value)));
|
||||
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
|
||||
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
|
||||
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
|
||||
}
|
||||
int journal::remaining(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
const auto found = active.find(key(id));
|
||||
if (found == active.end()) return -1;
|
||||
if (stopped.count(key(id))) return 0;
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
|
||||
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
|
||||
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
|
||||
}
|
||||
int journal::checkpoint(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
|
||||
const auto seconds = elapsed(session,now);
|
||||
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
|
||||
return seconds;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
request journal::settlement(const session& value, const std::string& reason, int consumed) const
|
||||
{
|
||||
auto data = nlohmann::json::parse(value.body);
|
||||
const auto id = session_id(value);
|
||||
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
|
||||
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
|
||||
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
|
||||
else body["consumed_seconds"] = consumed;
|
||||
return decode(body.dump());
|
||||
}
|
||||
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
|
||||
{
|
||||
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
|
||||
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
|
||||
// The caller has already removed protection. Freeze the first stop even
|
||||
// if disk read/write fails, so retries cannot charge time after return.
|
||||
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
|
||||
auto value = read();
|
||||
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
|
||||
if (session_id(*it) == id)
|
||||
{
|
||||
const auto end = stopped.find(key(id));
|
||||
const auto stop_time = end == stopped.end() ? now : end->second.time;
|
||||
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
|
||||
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
|
||||
if ((stop_reason == "never_started" && it->phase == "active") ||
|
||||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
|
||||
throw std::runtime_error("AFK stop reason conflicts with usage");
|
||||
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
|
||||
erase_session_requests(value.requests,id);
|
||||
value.sessions.erase(it);
|
||||
save(value);
|
||||
active.erase(key(id));
|
||||
stopped.erase(key(id));
|
||||
return;
|
||||
}
|
||||
// Duplicate stop is safe after a successful durable transition.
|
||||
}
|
||||
void journal::recover(const std::string& current_match) const
|
||||
{
|
||||
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
|
||||
auto value = read();
|
||||
for (const auto& session : value.sessions)
|
||||
{
|
||||
const auto id = session_id(session);
|
||||
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
|
||||
throw std::runtime_error("cannot recover live AFK protection");
|
||||
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
|
||||
erase_session_requests(value.requests,id);
|
||||
}
|
||||
const auto sessions = value.sessions;
|
||||
value.sessions.clear();
|
||||
save(value);
|
||||
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
struct request { std::string id, action, body; };
|
||||
struct session
|
||||
{
|
||||
std::string body, phase;
|
||||
int authorized_seconds = 0, consumed_seconds = 0;
|
||||
};
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string reason = "schema", state, code, id, session_id;
|
||||
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
|
||||
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
|
||||
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
|
||||
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// Single game-thread writer. Active clocks are process-local, so recovery
|
||||
// can never accidentally replay protection after a process restart.
|
||||
class journal
|
||||
{
|
||||
public:
|
||||
using clock = std::chrono::steady_clock;
|
||||
journal(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<request> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
void prepare(const std::string& reserve_body) const;
|
||||
void authorize(const std::string& id, int seconds) const;
|
||||
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
|
||||
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
|
||||
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
|
||||
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
|
||||
// Call only at map initialization, with its new persistent match ID.
|
||||
// Active current-map sessions are rejected. Unknown crash intervals
|
||||
// remain unresolved at EC, rather than refunding spendable authority.
|
||||
void recover(const std::string& current_match) const;
|
||||
private:
|
||||
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, scope_;
|
||||
request decode(const std::string& body) const;
|
||||
snapshot read() const;
|
||||
void save(const snapshot& value) const;
|
||||
std::string key(const std::string& id) const;
|
||||
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
|
||||
request settlement(const session& value, const std::string& reason, int consumed) const;
|
||||
};
|
||||
}
|
||||
+28
-38
@@ -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.
|
||||
|
||||
+1010
-64
File diff suppressed because it is too large
Load Diff
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
|
||||
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
|
||||
storage and native registration adapter with sanitizers and engine shims.
|
||||
|
||||
## AFK journal API (`1.4.0+`)
|
||||
|
||||
The AFK component exports API `1.0.0` for immutable requests, reservations,
|
||||
monotonic countdowns, durable stopped-session decisions and crash recovery.
|
||||
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
|
||||
credential, uses a separate `tws-afk` journal, and never stores credentials in
|
||||
that journal. Installing this native change requires a full process restart.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/afk.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
|
||||
{ return {"tws-afk/t6",server,endpoint}; }
|
||||
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
|
||||
static std::array<status,9> states;
|
||||
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
|
||||
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
|
||||
state.failed = failed; state.logged = now; state.suppressed = 0;
|
||||
}
|
||||
template <typename F> bool mutate(size_t operation, F action)
|
||||
{
|
||||
try { action(); storage_result(operation,false); return true; }
|
||||
catch (...) { storage_result(operation,true); return false; }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
|
||||
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server,endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0,false); return entries;
|
||||
}
|
||||
catch (...) { storage_result(0,true); return {}; }
|
||||
});
|
||||
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
|
||||
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
|
||||
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
|
||||
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
|
||||
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
|
||||
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
|
||||
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return spool(server,endpoint).remaining(id); });
|
||||
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
|
||||
catch (...) { storage_result(6,true); return -1; }
|
||||
});
|
||||
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
|
||||
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
|
||||
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
|
||||
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
|
||||
gsc::function::add("afkresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::afk::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid; result["reason"] = ack.reason;
|
||||
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
|
||||
result["session_id"] = ack.session_id;
|
||||
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
|
||||
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
|
||||
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
|
||||
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
|
||||
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
|
||||
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(afk::component)
|
||||
@@ -0,0 +1,381 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "afk.hpp"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
|
||||
constexpr int max_seconds = 604800;
|
||||
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
|
||||
std::map<std::string, active_clock> active;
|
||||
struct stopped_clock { journal::clock::time_point time; std::string reason; };
|
||||
std::map<std::string, stopped_clock> stopped;
|
||||
std::string hash(const std::string& value)
|
||||
{
|
||||
uint64_t result = 14695981039346656037ull;
|
||||
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0') << std::setw(16) << result;
|
||||
return out.str();
|
||||
}
|
||||
bool identifier(const nlohmann::json& value)
|
||||
{
|
||||
if (!value.is_string()) return false;
|
||||
const auto& text = value.get_ref<const std::string&>();
|
||||
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
|
||||
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
|
||||
}
|
||||
bool integer(const nlohmann::json& value, int maximum = 1000000000)
|
||||
{
|
||||
return value.is_number_integer() && value >= 0 && value <= maximum;
|
||||
}
|
||||
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
|
||||
void append(std::vector<request>& requests, const request& incoming)
|
||||
{
|
||||
for (const auto& value : requests)
|
||||
if (value.id == incoming.id)
|
||||
{
|
||||
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
|
||||
throw std::runtime_error("AFK immutable request conflict");
|
||||
return;
|
||||
}
|
||||
requests.push_back(incoming);
|
||||
}
|
||||
void erase_session_requests(std::vector<request>& requests, const std::string& id)
|
||||
{
|
||||
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
|
||||
{
|
||||
const auto data = nlohmann::json::parse(value.body);
|
||||
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
|
||||
}), requests.end());
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement value;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
value.state = data.at("state").get<std::string>();
|
||||
value.code = data.at("code").get<std::string>();
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
|
||||
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
|
||||
if (!states.count(value.state)) return invalid("state");
|
||||
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
|
||||
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
|
||||
const std::pair<const char*, int acknowledgement::*> fields[] = {
|
||||
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
|
||||
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
|
||||
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
|
||||
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
|
||||
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
|
||||
for (const auto& [name, member] : fields)
|
||||
{
|
||||
if (!integer(data.at(name))) return invalid("amount_range");
|
||||
value.*member = data.at(name).get<int>();
|
||||
}
|
||||
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
|
||||
if (data.contains("awarded_seconds"))
|
||||
{
|
||||
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
|
||||
value.awarded_seconds = data.at("awarded_seconds").get<int>();
|
||||
}
|
||||
if (data.contains("session_id") && !data.at("session_id").is_null())
|
||||
{
|
||||
value.session_id = data.at("session_id").get<std::string>();
|
||||
if (value.session_id.size() > 64) return invalid("field_limit");
|
||||
}
|
||||
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
|
||||
{
|
||||
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
|
||||
value.consumed_seconds = data.at("consumed_seconds").get<int>();
|
||||
}
|
||||
if ((value.state == "reserved" || value.state == "started") &&
|
||||
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
|
||||
value.valid = true;
|
||||
value.reason = "ok";
|
||||
return value;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
|
||||
{ file_ = root / (hash(scope_) + ".json"); }
|
||||
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
|
||||
|
||||
request journal::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
|
||||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
|
||||
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
|
||||
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
|
||||
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
|
||||
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
|
||||
throw std::runtime_error("invalid AFK session identity");
|
||||
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
|
||||
throw std::runtime_error("invalid AFK reservation size");
|
||||
if (value.action == "event")
|
||||
{
|
||||
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
|
||||
throw std::runtime_error("invalid AFK activity sequence");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
|
||||
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
journal::snapshot journal::read() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_)) return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error("cannot read AFK journal");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
|
||||
data.at("requests").size() + data.at("sessions").size() > max_entries)
|
||||
throw std::runtime_error("invalid AFK journal; preserve for recovery");
|
||||
snapshot result;
|
||||
std::set<std::string> requests, sessions;
|
||||
for (const auto& body : data.at("requests"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
|
||||
result.requests.push_back(std::move(value));
|
||||
}
|
||||
for (const auto& entry : data.at("sessions"))
|
||||
{
|
||||
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
|
||||
const auto request = decode(value.body);
|
||||
const auto body = nlohmann::json::parse(value.body);
|
||||
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
|
||||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
|
||||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
|
||||
throw std::runtime_error("invalid AFK session");
|
||||
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
|
||||
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
|
||||
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
|
||||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
|
||||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
|
||||
(value.phase != "active" && value.consumed_seconds != 0))
|
||||
throw std::runtime_error("invalid AFK session accounting");
|
||||
result.sessions.push_back(std::move(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void journal::save(const snapshot& value) const
|
||||
{
|
||||
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
|
||||
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
|
||||
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
|
||||
for (const auto& request : value.requests) data["requests"].push_back(request.body);
|
||||
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
|
||||
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_; temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace AFK journal; old file retained");
|
||||
#else
|
||||
std::filesystem::rename(temporary,file_);
|
||||
#endif
|
||||
}
|
||||
std::vector<request> journal::load() const { return read().requests; }
|
||||
void journal::store(const std::string& body) const
|
||||
{
|
||||
auto value = read();
|
||||
const auto incoming = decode(body);
|
||||
// Snapshot replacement is atomic with the new snapshot. Cumulative
|
||||
// counters retain every reward during an outage without an unbounded
|
||||
// request per kill. An in-flight older HTTP request remains idempotent;
|
||||
// removing its old ID cannot remove this newer request.
|
||||
if (incoming.action == "event")
|
||||
{
|
||||
const auto next = nlohmann::json::parse(body);
|
||||
for (const auto& entry : value.requests)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) continue;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
|
||||
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
|
||||
}
|
||||
// Check reused identities before removing any superseded request.
|
||||
append(value.requests,incoming);
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) return false;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
|
||||
}),value.requests.end());
|
||||
}
|
||||
else append(value.requests,incoming);
|
||||
save(value);
|
||||
}
|
||||
void journal::erase(const std::string& id) const
|
||||
{
|
||||
auto value = read();
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
|
||||
save(value);
|
||||
}
|
||||
void journal::prepare(const std::string& body) const
|
||||
{
|
||||
const auto request = decode(body);
|
||||
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
|
||||
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
|
||||
auto value = read();
|
||||
for (const auto& existing : value.sessions)
|
||||
if (session_id(existing) == id)
|
||||
{
|
||||
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
|
||||
return;
|
||||
}
|
||||
append(value.requests,request);
|
||||
value.sessions.push_back({body,"prepared",0,0});
|
||||
save(value);
|
||||
}
|
||||
void journal::authorize(const std::string& id, int seconds) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
|
||||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
|
||||
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
void journal::begin(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
|
||||
if (session.phase == "active" && active.count(key(id))) return;
|
||||
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
|
||||
session.phase = "active";
|
||||
save(value); // Failure prevents protection; never reset an existing clock.
|
||||
// Production clocks start after the durable write; slow storage
|
||||
// must not charge time before the script can apply protection.
|
||||
if (now == clock::time_point{}) now = clock::now();
|
||||
active.emplace(key(id),active_clock{now,session.authorized_seconds});
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
|
||||
{
|
||||
const auto found = active.find(key(session_id(value)));
|
||||
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
|
||||
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
|
||||
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
|
||||
}
|
||||
int journal::remaining(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
const auto found = active.find(key(id));
|
||||
if (found == active.end()) return -1;
|
||||
if (stopped.count(key(id))) return 0;
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
|
||||
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
|
||||
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
|
||||
}
|
||||
int journal::checkpoint(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
|
||||
const auto seconds = elapsed(session,now);
|
||||
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
|
||||
return seconds;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
request journal::settlement(const session& value, const std::string& reason, int consumed) const
|
||||
{
|
||||
auto data = nlohmann::json::parse(value.body);
|
||||
const auto id = session_id(value);
|
||||
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
|
||||
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
|
||||
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
|
||||
else body["consumed_seconds"] = consumed;
|
||||
return decode(body.dump());
|
||||
}
|
||||
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
|
||||
{
|
||||
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
|
||||
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
|
||||
// The caller has already removed protection. Freeze the first stop even
|
||||
// if disk read/write fails, so retries cannot charge time after return.
|
||||
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
|
||||
auto value = read();
|
||||
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
|
||||
if (session_id(*it) == id)
|
||||
{
|
||||
const auto end = stopped.find(key(id));
|
||||
const auto stop_time = end == stopped.end() ? now : end->second.time;
|
||||
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
|
||||
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
|
||||
if ((stop_reason == "never_started" && it->phase == "active") ||
|
||||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
|
||||
throw std::runtime_error("AFK stop reason conflicts with usage");
|
||||
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
|
||||
erase_session_requests(value.requests,id);
|
||||
value.sessions.erase(it);
|
||||
save(value);
|
||||
active.erase(key(id));
|
||||
stopped.erase(key(id));
|
||||
return;
|
||||
}
|
||||
// Duplicate stop is safe after a successful durable transition.
|
||||
}
|
||||
void journal::recover(const std::string& current_match) const
|
||||
{
|
||||
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
|
||||
auto value = read();
|
||||
for (const auto& session : value.sessions)
|
||||
{
|
||||
const auto id = session_id(session);
|
||||
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
|
||||
throw std::runtime_error("cannot recover live AFK protection");
|
||||
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
|
||||
erase_session_requests(value.requests,id);
|
||||
}
|
||||
const auto sessions = value.sessions;
|
||||
value.sessions.clear();
|
||||
save(value);
|
||||
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
struct request { std::string id, action, body; };
|
||||
struct session
|
||||
{
|
||||
std::string body, phase;
|
||||
int authorized_seconds = 0, consumed_seconds = 0;
|
||||
};
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string reason = "schema", state, code, id, session_id;
|
||||
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
|
||||
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
|
||||
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
|
||||
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// Single game-thread writer. Active clocks are process-local, so recovery
|
||||
// can never accidentally replay protection after a process restart.
|
||||
class journal
|
||||
{
|
||||
public:
|
||||
using clock = std::chrono::steady_clock;
|
||||
journal(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<request> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
void prepare(const std::string& reserve_body) const;
|
||||
void authorize(const std::string& id, int seconds) const;
|
||||
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
|
||||
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
|
||||
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
|
||||
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
|
||||
// Call only at map initialization, with its new persistent match ID.
|
||||
// Active current-map sessions are rejected. Unknown crash intervals
|
||||
// remain unresolved at EC, rather than refunding spendable authority.
|
||||
void recover(const std::string& current_match) const;
|
||||
private:
|
||||
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, scope_;
|
||||
request decode(const std::string& body) const;
|
||||
snapshot read() const;
|
||||
void save(const snapshot& value) const;
|
||||
std::string key(const std::string& id) const;
|
||||
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
|
||||
request settlement(const session& value, const std::string& reason, int consumed) const;
|
||||
};
|
||||
}
|
||||
+17
-11
@@ -9,7 +9,7 @@ private server token.
|
||||
| --- | --- | --- |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, paginated `.help`, service window, connection and first-spawn dispatchers | Stock T6 |
|
||||
| `tws_t6_hud.gsc` | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle | Common 1.4.0+, Plutonium r5334+ |
|
||||
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.4.0+ |
|
||||
| `tws_t6_afk.gsc` | Persistent AFK reserve, rewards and central countdown | Common 1.4.0+, telemetry 1.0.0+, plugin 1.4.0+, EC #151 |
|
||||
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
|
||||
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
@@ -78,8 +78,7 @@ slim health bar at the bottom center. `N ZOMBIES` is one centered element at
|
||||
the health track's midpoint, using uniform font scale `1.25` so the whole group
|
||||
stays centered as the digit count changes. Its initial placeholder is
|
||||
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
|
||||
injured; it disappears at full health. The AFK indicator sits above the zombie
|
||||
count. There are no panel backgrounds or timer labels.
|
||||
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
|
||||
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
|
||||
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
|
||||
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
|
||||
@@ -90,7 +89,7 @@ clock. It persists across death and respawn. Clocks, health and zombie-count
|
||||
displays freeze at game over. Health fallbacks only affect the display and
|
||||
never modify gameplay max health.
|
||||
|
||||
The exact, case-insensitive `.hud` command toggles your core and AFK indicator.
|
||||
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
|
||||
It defaults visible, keeps your choice across death/respawn on the current
|
||||
connection/map, and resets visible on reconnect or map restart. Timers and data
|
||||
continue updating while hidden; game-over values remain frozen. Help, records
|
||||
@@ -99,22 +98,22 @@ and the stock HUD are unaffected. `.hud` requires the HUD module.
|
||||
Common provides one unboxed upper-right service area below the Plutonium tag.
|
||||
Records are right aligned, with `Map record | Round N` followed by up to three
|
||||
holder lines; there is no replay footer or single-page counter. Help displays
|
||||
the full nine-command list in aligned rows. Services remain private, leave the
|
||||
the installed command list in pages of nine rows in aligned rows. Services remain private, leave the
|
||||
core in place and work without the HUD module installed. See the
|
||||
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
|
||||
Only the active service view holds HUD elements: 19 for nine-command help
|
||||
(20 with a multi-page counter), or four for records (five with a multi-page
|
||||
(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page
|
||||
counter). Pages reuse elements; switching views or closing the service releases
|
||||
them. Idle services hold no HUD elements, while the controller retains pending
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 1 elements.
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
|
||||
|
||||
HUD `1.4.2` requires common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
|
||||
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
|
||||
updated HUD script and restart the map on r5334+; no full server-process
|
||||
restart is required. Older Plutonium revisions need client/server updates and
|
||||
full restarts first. AFK `1.2.0` and leaderboard `1.1.0` remain compatible;
|
||||
AFK still requires common `1.4.0+`. No new native-plugin or backend requirement.
|
||||
full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching
|
||||
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
|
||||
|
||||
## Map record announcements
|
||||
|
||||
@@ -149,8 +148,8 @@ explicit row positions so T5 does not concatenate newline-separated text.
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
|
||||
| `.afk` | Enter protected AFK using your time reserve |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
@@ -282,3 +281,10 @@ research sources and live checks are in [NOTES.md](NOTES.md).
|
||||
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
|
||||
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
|
||||
configuration, persistence, action results and deployment checks.
|
||||
|
||||
## Persistent AFK reserve
|
||||
|
||||
AFK `2.0.0` uses EC authorization and a local central countdown. See the
|
||||
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
|
||||
and live checks. Install plugin `1.4.0+` and fully restart the server process.
|
||||
The countdown remains visible with `.hud` hidden; expiration has no grace period.
|
||||
|
||||
+943
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
|
||||
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const root = new URL('../', import.meta.url);
|
||||
|
||||
function compile(source) {
|
||||
const strings=[];
|
||||
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
|
||||
if(token.startsWith('/')) return '';
|
||||
strings.push(token); return `__str_${strings.length-1}__`;
|
||||
});
|
||||
const compiled = [];
|
||||
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||
for(let match; (match=headers.exec(source));) {
|
||||
let end=headers.lastIndex, depth=1;
|
||||
while(depth && end<source.length) {
|
||||
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
|
||||
}
|
||||
assert.equal(depth,0);
|
||||
let body=source.slice(headers.lastIndex,end-1);
|
||||
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
|
||||
body=body.replace(/#.?(?=__str_)/g,'');
|
||||
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
|
||||
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
|
||||
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
|
||||
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
|
||||
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
|
||||
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
|
||||
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
|
||||
body=body.replace(/player getGuid\(\)/g,'player.guid');
|
||||
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
|
||||
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
|
||||
body=body.replace(/level notify\(/g,'level.notify(');
|
||||
body=body.replace(/::(afk_\w+)/g,'$1');
|
||||
body=body.replace(/http::request/g,'httpPost');
|
||||
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
|
||||
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
|
||||
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
|
||||
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
|
||||
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
|
||||
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
|
||||
if(match[1]==='afk_register_dogs')continue;
|
||||
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
|
||||
}
|
||||
return compiled.join('\n');
|
||||
}
|
||||
|
||||
function engine(game, options={}) {
|
||||
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
|
||||
let now=100000, wall=100000, serial=0;
|
||||
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
|
||||
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
|
||||
const players=[],zombies=[];
|
||||
const c=vm.createContext({level,
|
||||
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
|
||||
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
|
||||
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
|
||||
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
|
||||
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
|
||||
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
|
||||
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
|
||||
telemetryNewMatch:()=>`id-${++serial}`,
|
||||
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
|
||||
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
|
||||
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
|
||||
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
|
||||
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
|
||||
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
|
||||
afkRemove:(_s,_u,id)=>saved.delete(id),
|
||||
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
|
||||
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
|
||||
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
|
||||
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
|
||||
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
|
||||
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
|
||||
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
|
||||
httpComplete:request=>{options.during?.(request,c);const d=request.data;
|
||||
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
|
||||
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
|
||||
afkResponse:JSON.parse,
|
||||
});
|
||||
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
|
||||
function player(guid='18446744073709551615') {
|
||||
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
|
||||
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
|
||||
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
|
||||
p.getPlayerAngles=()=>p.angles;
|
||||
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
|
||||
players.push(p);p.afk_spawn();return p;
|
||||
}
|
||||
const p=player(), other=player('other');
|
||||
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
|
||||
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
|
||||
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
|
||||
};
|
||||
}
|
||||
let checks=0;
|
||||
for(const game of ['t5','t6']) {
|
||||
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
|
||||
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
|
||||
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
|
||||
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
|
||||
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
|
||||
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
|
||||
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
|
||||
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
|
||||
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
|
||||
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
|
||||
});
|
||||
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
|
||||
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
|
||||
});
|
||||
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
|
||||
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
|
||||
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
|
||||
});
|
||||
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
|
||||
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
|
||||
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
|
||||
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
|
||||
assert.equal(e.p.afk_remaining(),1799000);
|
||||
});
|
||||
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
|
||||
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
|
||||
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
|
||||
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
|
||||
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
|
||||
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
|
||||
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
|
||||
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
|
||||
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
|
||||
assert.equal(e.p.sessionstate,'playing');
|
||||
});
|
||||
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
|
||||
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
|
||||
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
|
||||
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
|
||||
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
|
||||
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
|
||||
});
|
||||
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
|
||||
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
|
||||
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
|
||||
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
|
||||
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
|
||||
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||
});
|
||||
test('manual command at zero cannot create a grace period',()=>{
|
||||
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
|
||||
});
|
||||
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
|
||||
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
|
||||
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
|
||||
});
|
||||
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
|
||||
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
|
||||
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
|
||||
}
|
||||
});
|
||||
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
|
||||
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
|
||||
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
|
||||
assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false);
|
||||
assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability('));
|
||||
});
|
||||
test('last active cannot enter; last active death ends only AFK-only matches, respecting afterlife',()=>{
|
||||
const alone=engine(game);alone.other.downed=true;alone.enter();assert(!alone.p.hud_afk_on);assert.equal(alone.sent.length,0);
|
||||
const e=engine(game);e.enter();e.other.alive=false;e.c.afk_team_watcher().next();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'match_end');assert(e.log.some(x=>x[0]==='notify'&&x[1]==='end_game'));
|
||||
const recovering=engine(game);recovering.enter();recovering.other.afterlife=true;recovering.c.afk_team_watcher().next();assert(recovering.p.hud_afk_on);
|
||||
const noAfk=engine(game);noAfk.p.alive=false;noAfk.other.alive=false;noAfk.c.afk_team_watcher().next();assert(!noAfk.log.some(x=>x[0]==='notify'));
|
||||
});
|
||||
test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{
|
||||
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
|
||||
for(let i=0;i<40;i++)watcher.next(e.p);
|
||||
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
|
||||
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
|
||||
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
|
||||
});
|
||||
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
|
||||
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
|
||||
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
|
||||
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
|
||||
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
|
||||
assert(!e.source.includes('.score'));
|
||||
});
|
||||
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
|
||||
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||
});
|
||||
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
|
||||
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
|
||||
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
|
||||
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
|
||||
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
|
||||
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
|
||||
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||
});
|
||||
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
|
||||
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
|
||||
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
|
||||
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
|
||||
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
|
||||
});
|
||||
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
|
||||
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
|
||||
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
|
||||
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
|
||||
});
|
||||
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
|
||||
for(const at of ['reserve','start']){
|
||||
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||
}
|
||||
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
|
||||
});
|
||||
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
|
||||
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
|
||||
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
|
||||
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
|
||||
});
|
||||
}
|
||||
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);
|
||||
@@ -1,4 +1,5 @@
|
||||
// Execute selected core HUD/AFK functions with engine-value shims.
|
||||
// Execute selected core HUD functions with engine-value shims.
|
||||
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
|
||||
// These checks are not a GSC VM, compiler, linker, or live layout test.
|
||||
// Common visibility helpers are stubbed here and executed directly by check_hud.
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -99,8 +100,6 @@ function engine(game, source, afk, hidden = false) {
|
||||
int: Math.trunc, max: Math.max });
|
||||
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
|
||||
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
|
||||
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
|
||||
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
|
||||
return { context, self, level, queued, elems, actions, common,
|
||||
setTime(value) { now = value; },
|
||||
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
|
||||
@@ -126,9 +125,8 @@ for (const game of ['t5', 't6']) {
|
||||
const bind = body(source, 'match_timer_bind')[1];
|
||||
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
|
||||
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
|
||||
assert(afk.includes('make_core_overlay_text'));
|
||||
assert(afk.includes('level.hud_overlay_afk_y'));
|
||||
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
|
||||
assert(afk.includes('NewClientHudElem(self)'), 'protection countdown is an independent critical HUD');
|
||||
assert(!afk.includes('set_core_alpha'), 'hiding the optional HUD must not hide the protection deadline');
|
||||
});
|
||||
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
|
||||
const e = make(); e.context.hud_connect();
|
||||
@@ -266,41 +264,6 @@ for (const game of ['t5', 't6']) {
|
||||
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
|
||||
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
|
||||
});
|
||||
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
|
||||
const e = make(); e.context.hud_connect(); e.spawnAfk();
|
||||
const indicator = e.self.hud_afk_elem;
|
||||
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
|
||||
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
|
||||
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
|
||||
e.context.hud_toggle(); e.context.afk_enter();
|
||||
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
|
||||
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
|
||||
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
|
||||
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
|
||||
const actions = e.actions.length;
|
||||
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
|
||||
e.context.hud_toggle(); const exit = e.context.afk_exit();
|
||||
assert.equal(exit.next().value, 2);
|
||||
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
|
||||
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
|
||||
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
|
||||
exit.next(); assert.equal(e.self.ignoreme, false);
|
||||
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
|
||||
assert.equal(e.elems.length, 11);
|
||||
});
|
||||
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
|
||||
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
|
||||
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
|
||||
watcher.next();
|
||||
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
|
||||
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
|
||||
assert.equal(e.self.ignoreme, true);
|
||||
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
|
||||
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
|
||||
e.self.active = true; watcher.next();
|
||||
assert.equal(e.self.tws_afk_controls_locked, false);
|
||||
assert(e.actions.some(([name]) => name === 'enableWeapons'));
|
||||
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
|
||||
});
|
||||
|
||||
}
|
||||
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
|
||||
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
|
||||
|
||||
+10
-10
@@ -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);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
|
||||
|
||||
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
|
||||
pointing to the single header at the revision in the plugin's upstream.json.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
|
||||
args = parser.parse_args()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
|
||||
directory = Path(temp)
|
||||
for game in ("t5", "t6"):
|
||||
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
|
||||
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
|
||||
if not header.is_file():
|
||||
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
|
||||
shutil.copyfile(header, directory / "json.hpp")
|
||||
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
|
||||
executable = directory / game
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
|
||||
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
|
||||
"-o", str(executable)], check=True)
|
||||
work = directory / f"{game}-data"
|
||||
subprocess.run([str(executable), str(work)], check=True)
|
||||
# Separate OS processes prove snapshots/identities don't depend on VM or
|
||||
# DLL memory. A newly booted producer recovers before its own start.
|
||||
subprocess.run([str(executable), str(work), "produce"], check=True)
|
||||
subprocess.run([str(executable), str(work), "recover"], check=True)
|
||||
subprocess.run([str(executable), str(work), "empty"], check=True)
|
||||
|
||||
# Compile the actual registration adapter as well, substituting only
|
||||
# engine interfaces. This does not validate the Windows ABI/VM linker.
|
||||
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
|
||||
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
|
||||
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
|
||||
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
|
||||
template <typename F> void add(const char* name, F callback)
|
||||
{
|
||||
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
|
||||
{
|
||||
if constexpr (std::is_invocable_v<F>) return callback();
|
||||
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
|
||||
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
|
||||
};
|
||||
}
|
||||
""")
|
||||
(directory / "component_stub.hpp").write_text(stub)
|
||||
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
|
||||
source = directory / "component.cpp"
|
||||
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
|
||||
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
|
||||
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
|
||||
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
|
||||
lines = result.stdout.splitlines()
|
||||
for operation in ("load", "write", "remove"):
|
||||
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
|
||||
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
|
||||
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
|
||||
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "afk.hpp"
|
||||
#include <json.hpp>
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
using utils::afk::journal;
|
||||
using utils::afk::parse_acknowledgement;
|
||||
using namespace std::chrono_literals;
|
||||
namespace fs = std::filesystem;
|
||||
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
|
||||
std::string reserve(const std::string& id)
|
||||
{
|
||||
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
|
||||
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
|
||||
}
|
||||
std::string request(const std::string& id, const std::string& action)
|
||||
{
|
||||
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
|
||||
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
|
||||
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
|
||||
}
|
||||
template <typename F> void must_fail(F fn)
|
||||
{
|
||||
bool failed = false;
|
||||
try { fn(); } catch (const std::exception&) { failed = true; }
|
||||
assert(failed);
|
||||
}
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
assert(argc >= 2);
|
||||
const fs::path root(argv[1]);
|
||||
const journal queue(root,server,endpoint);
|
||||
if (argc == 3)
|
||||
{
|
||||
const std::string mode(argv[2]);
|
||||
if (mode == "produce")
|
||||
{
|
||||
queue.prepare(reserve("pending"));
|
||||
queue.store(request("pending-start","start"));
|
||||
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
|
||||
queue.prepare(reserve("active")); queue.authorize("active",120);
|
||||
const auto now = journal::clock::now();
|
||||
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
|
||||
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
|
||||
}
|
||||
else if (mode == "recover")
|
||||
{
|
||||
// Separate process: the same match ID cannot recreate a live clock.
|
||||
assert(queue.remaining("active") == -1);
|
||||
must_fail([&]{queue.begin("active");});
|
||||
queue.recover("match");
|
||||
const auto values = queue.load();
|
||||
assert(values.size() == 5);
|
||||
int recovered = 0;
|
||||
for (const auto& value : values)
|
||||
{
|
||||
if (value.action != "settle") continue;
|
||||
auto body = nlohmann::json::parse(value.body);
|
||||
assert(value.id.size() == 25);
|
||||
if (body.at("session_id") == "active")
|
||||
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
|
||||
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
|
||||
++recovered;
|
||||
}
|
||||
assert(recovered == 3);
|
||||
queue.recover("new-match"); // Stable requests are unchanged across restarts.
|
||||
assert(queue.load().size() == values.size());
|
||||
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
|
||||
for (const auto& value : values) queue.erase(value.id);
|
||||
}
|
||||
else assert(mode == "empty" && queue.load().empty());
|
||||
return 0;
|
||||
}
|
||||
fs::create_directories(root);
|
||||
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
|
||||
assert(queue.load().size() == 1);
|
||||
must_fail([&]{queue.store(request("one","event"));});
|
||||
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
|
||||
must_fail([&]{queue.store(changed.dump());});
|
||||
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
|
||||
assert(other_server.load().empty() && other_endpoint.load().empty());
|
||||
queue.erase("one");
|
||||
queue.store(request("first-event","event"));
|
||||
auto snapshot = nlohmann::json::parse(request("second-event","event"));
|
||||
snapshot["seq"] = 2; snapshot["revives"] = 2;
|
||||
queue.store(snapshot.dump());
|
||||
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
|
||||
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
|
||||
assert(queue.load().size() == 1);
|
||||
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
|
||||
must_fail([&]{queue.store(regressed.dump());});
|
||||
queue.store(snapshot.dump());
|
||||
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
|
||||
queue.erase("second-event");
|
||||
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
|
||||
assert(queue.load().size() == 1);
|
||||
must_fail([&]{queue.begin("normal");});
|
||||
must_fail([&]{queue.authorize("normal",601);});
|
||||
queue.authorize("normal",60); queue.authorize("normal",60);
|
||||
must_fail([&]{queue.authorize("normal",120);});
|
||||
const auto now = journal::clock::now();
|
||||
queue.begin("normal",now); queue.begin("normal",now+20s);
|
||||
assert(queue.remaining("normal",now+1250ms) == 58750);
|
||||
assert(queue.remaining("normal",now+59999ms) == 1);
|
||||
assert(queue.remaining("normal",now+60s) == 0);
|
||||
assert(queue.remaining("normal",now+61s) == 0);
|
||||
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
|
||||
assert(queue.checkpoint("normal",now+59001ms) == 59);
|
||||
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
|
||||
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
|
||||
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
|
||||
auto settlement = nlohmann::json::parse(queue.load()[0].body);
|
||||
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
|
||||
queue.erase(queue.load()[0].id);
|
||||
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
|
||||
assert(queue.checkpoint("expire",now+60s) == 60);
|
||||
queue.stop("expire","expired",now+61s);
|
||||
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
|
||||
queue.erase(queue.load()[0].id);
|
||||
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
|
||||
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
|
||||
queue.erase(queue.load()[0].id);
|
||||
// Interrupted temp writes preserve the prior durable snapshot.
|
||||
const auto file = fs::directory_iterator(root)->path();
|
||||
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
|
||||
fs::create_directory(file.string()+".tmp");
|
||||
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
|
||||
assert(queue.remaining("failure",now+30s) == 0);
|
||||
must_fail([&]{queue.checkpoint("failure",now+30s);});
|
||||
must_fail([&]{queue.begin("failure",now+30s);});
|
||||
fs::remove(file.string()+".tmp");
|
||||
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
|
||||
settlement = nlohmann::json::parse(queue.load()[0].body);
|
||||
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
|
||||
queue.erase(queue.load()[0].id);
|
||||
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
|
||||
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
|
||||
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
|
||||
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
|
||||
assert(parse_acknowledgement(valid.dump()).valid);
|
||||
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
|
||||
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
|
||||
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
|
||||
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
|
||||
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
|
||||
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
|
||||
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
|
||||
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
|
||||
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
|
||||
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
|
||||
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
|
||||
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
|
||||
fs::remove(file);
|
||||
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Compile the actual GSC registration adapter against portable VM interfaces.
|
||||
#include <fstream>
|
||||
int main()
|
||||
{
|
||||
afk::component component; component.on_startup(nullptr);
|
||||
const auto call = [](const std::string& name, std::vector<std::string> args = {})
|
||||
{ return gsc::function::functions.at(name)(args); };
|
||||
assert(call("afkversion").as<std::string>() == "1.0.0");
|
||||
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
|
||||
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
|
||||
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
|
||||
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
|
||||
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
|
||||
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
|
||||
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
|
||||
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
|
||||
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
|
||||
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
|
||||
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
|
||||
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
|
||||
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
|
||||
std::filesystem::path file;
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
|
||||
if (entry.path().extension() == ".json") file=entry.path();
|
||||
assert(!file.empty());
|
||||
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
|
||||
for (int i=0;i<2;++i)
|
||||
{
|
||||
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
|
||||
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
|
||||
}
|
||||
std::filesystem::remove(file);
|
||||
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
|
||||
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
|
||||
}
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user