Compare commits

...
4 Commits
31 changed files with 4751 additions and 346 deletions
+162
View File
@@ -4,6 +4,168 @@ Changes are listed newest first, with an independent version for every individua
T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning).
## 2026-10-17
### Allow solo and last-active-player AFK
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Remove the teammate requirement before reservation and after start
authorization on both engines. Solo players, the last active player and
players whose teammates are already AFK or awaiting authorization can enter.
- Remove the AFK team watcher that ended matches when no non-AFK survivor
remained. All players may be AFK together, and teammate death/disconnect
does not cancel protection. Keep reserve accounting, the local deadline,
cooldown, player-state checks and normal stock match-end settlement.
- Compatibility: no new dependency requirement. Existing common `1.4.0+`,
telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and
host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same
policy change using their existing engine-specific state checks.
- Validation: 47 AFK script behavior checks passed, including solo entry,
unavailable/pending teammates, teammate loss during authorization, all-AFK
play, solo return/expiration and stock match-end cleanup. The changed T6
script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries.
Portable checks use engine shims; live T5/T6 validation remains required.
No Windows plugin build, live server test or deployment was performed.
- Deployment: copy the updated AFK script for each installed game and restart
the map. No full server-process restart or additional deployment action is
required for this script-only update. See the [AFK guide](docs/afk.md) for
the updated live validation cases.
### Increase map-record reminders as players approach the target
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` |
- Add matching T5/T6 round-start reminders using the latest usable cached map
record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the
final five. Show matching/overtake milestones, then stop reminders after the
first overtake for the rest of the match. Milestones distinguish reaching a round
from saving the result after match completion; `.leaders` remains available.
Listen to the stock `start_of_round` event so reminders follow the actual
start, including on T6 where the counter advances during the round break.
Ignore duplicate starts and counter rewinds within the match.
- Common renders a compact record header and one proximity/milestone line for
six seconds, using two nonarchived elements in the existing service area.
Keep full join/`.leaders` announcements, all tied holders and 12-second pages.
Skip pending or active help/records, unready players and recent welcome/manual
record requests; opening help discards a reminder. Release elements on idle,
interruption, disconnect and game end. Both games work without optional HUD.
- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or
expired records suppress reminders; skipped reminders are not queued.
- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`.
There is no new native-plugin, backend or upstream dependency requirement.
Unchanged HUD, AFK and other scripts retain their individual versions.
- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join,
23 telemetry and 39 AFK portable checks passed, as did both moderation
script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool
`8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked
after the event-timing fix. JSON uniqueness, all 18 script manifest entries,
version/changelog consistency, local documentation links and `git diff --check`
passed. Portable checks use engine shims; no live Plutonium validation or
backend integration was performed. T5 has no gsc-tool compiler backend.
- Deployment: copy the updated leaderboard and common scripts together for
each installed game, then restart the map. No full server-process restart is
required for this script-only update. No deployment performed. Live T5/T6
rendering, readiness and round-transition validation remain required; see
[record validation](README.md#map-record-announcements).
### 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 |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.4.2` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.2` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the separately positioned number and label with one center-aligned
`N ZOMBIES` readout at the health track's midpoint in both engines. Use a
uniform font scale of `1.25` and the existing count color; digit changes and
the initial `-- ZOMBIES` placeholder share the same center.
- Update text only when the count changes using `SetTextUnlimited`, avoiding
a new config string for each distinct count. Keep counting, `.hud` visibility,
game-over termination and disconnect cleanup behavior. Core HUD allocation
decreases from 11 to 10 archived elements; AFK still adds one.
- Compatibility: HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334), which provides
`SetTextUnlimited` for T5 and T6. No new plugin or backend requirement.
Common and AFK need no source change for this fix; both HUD scripts change.
- Validation: 73 HUD checks passed, including digit transitions, a non-default
track midpoint, unchanged-count polling, hidden updates and service cleanup.
Leaderboard (143), bank (42), join (17) and telemetry (23) portable checks
passed. These use engine shims; actual EC integration and live Plutonium
validation were not run. GSC compilation was unavailable because gsc-tool
and PowerShell are not installed. Verify rendering on Windows/Plutonium.
- Deployment: copy each updated HUD script and restart the map on r5334+.
Older installations must first update Plutonium on clients and servers and
fully restart those processes. No full server-process restart is required
for the script update alone. No deployment performed.
### Use ASCII separators in HUD branding and map records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.1` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.1` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the middle dot with an ASCII vertical bar in the top-center branding
and map-record heading after the separator displayed as two dots on T5.
Apply the same text change to T6 for a consistent appearance. Keep existing
spacing, positioning and HUD/service behavior.
- Compatibility: no new dependency requirement. No native-plugin, backend or
other script changes are required.
- Validation: 71 HUD, 143 leaderboard, 42 bank, 17 join and 23 telemetry portable
checks passed. These use engine shims; actual EC bank integration, GSC
compilation and live Windows/Plutonium validation were not performed.
- Deployment: copy the updated common and HUD scripts for each installed game,
then restart the map. No full server-process restart is required. No deployment
performed; live Windows/Plutonium rendering validation remains outstanding.
## 2026-10-15 — Player moderation runtime
- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional
+95 -27
View File
@@ -362,11 +362,14 @@ settles legacy and public journals without duplicate ledger entries or lost poin
## Core HUD and service window (T5 and T6)
The core uses a light, unboxed layout: match time above round time at the upper
left, `TWS ZOMBIES · discord.tws.lol` centered along the top, and the zombie
count centered near the bottom above a slim health bar. 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 uses an outline for
contrast; there are no panel backgrounds or timer labels.
left, `TWS ZOMBIES | discord.tws.lol` centered along the top, and the zombie
count centered near the bottom above a slim health bar. The complete
`N ZOMBIES` text uses one centered element at the health track's midpoint,
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 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
widths use the normalized `640 × 480` viewport directly. Text uses a minimum
@@ -383,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
@@ -391,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
@@ -401,35 +404,45 @@ count appears only when more than one page is needed. Each page lasts
command restarts page one. Both engines position rows explicitly, avoiding
T5's newline-rendering problem.
Records use the same service area, right aligned: `Map record · Round N`
Records use the same service area, right aligned: `Map record | Round N`
followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. Help pauses a visible record and resumes its remaining
page time when help ends. `.leaders` during help replaces the pending record
with a fresh replay from page one. One service is visible at a time. Services
remain private and work when the HUD module is omitted.
with a fresh replay from page one. Round-start reminders use just the record
header and one proximity or milestone line for six seconds. They skip busy
services; opening help discards a reminder instead of resuming it later.
One service is visible at a time. Services 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 11 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
still occupy slots. Compact reminders use two nonarchived elements.
The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+` on both games.
Update common, HUD and AFK together where installed, then restart the map.
Leaderboard `1.1.0` remains compatible. There is no new Plutonium, native-plugin
or backend requirement, and this change does not require a full server-process
restart. 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.
HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) on both games.
The centered counter uses `SetTextUnlimited` only when the count changes,
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.
Leaderboard `1.2.0` requires matching common `1.5.0+` for round-start reminders.
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.
### HUD validation
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
request replacement and cleanup checks, and `node tools/check_leaderboard.mjs`
for record data handling. Run `node tools/check_bank.mjs`,
reminder rendering, request replacement and cleanup checks, and
`node tools/check_leaderboard.mjs` for record data handling and reminder cadence.
Run `node tools/check_bank.mjs`,
`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged
command/lifecycle regressions. These
checks use engine shims and do not prove GSC VM behavior. Follow the
@@ -445,14 +458,16 @@ Before rollout, validate both engines in Plutonium:
legible without panel backgrounds; services must clear the Plutonium tag.
Verify the health-bar fill stays within its track and damage-only HP clears
stock scores/ammo. Check text fit at other aspect ratios because horizontal
geometry scales with viewport width.
geometry scales with viewport width. Check `0`, `6`, `9`, `10`, `99` and
`100 ZOMBIES`: the entire text must stay centered over the health track,
including after `.hud` hides/shows it and after game over freezes the count.
2. Join before spawning, join mid-match, die/respawn and reconnect. Verify one
core per player, spectator placeholders, no early match-clock start, correct
health/Juggernog changes, and frozen timers at game over. HP numbers must
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
@@ -466,7 +481,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
@@ -479,7 +494,10 @@ Before rollout, validate both engines in Plutonium:
## Map record announcements
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`.
**along with common `1.5.0+`** for that game when using leaderboard `1.2.0`.
Copy both scripts for each installed game and restart the map. This update
adds no native-plugin or backend requirement and needs no full server-process
restart when existing leaderboard dependencies are already installed.
The module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers.
Type `.leaders` to replay the record privately from the latest usable cache.
@@ -509,7 +527,7 @@ when changing variants; restart the map after changing these settings.
The announcement starts five seconds after player readiness and waits for the
initial record fetch if necessary. It appears as right-aligned text in the
common-owned upper-right service area: `Map record · Round N`, followed by up
common-owned upper-right service area: `Map record | Round N`, followed by up
to three holder lines. There is no background or replay footer, and a page
count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining
page time until help closes. A `.leaders` request during help replaces any
@@ -521,6 +539,32 @@ the announcement finishes. Both games release their view elements when idle or
changing service kinds. Disconnect or game end cleans up any remaining service
elements.
At round start, reminders become more frequent as the current round approaches
the latest usable cached record. The cadence is measured back from that record:
| Rounds below the record | Reminder rounds |
| --- | --- |
| More than 20 | None |
| 11–20 | At gaps of 20 and 15 rounds |
| 6–10 | At gaps of 10, 8 and 6 rounds |
| 1–5 | Every round |
| Record reached | Matching-round milestone |
| First round above the record | Overtake milestone, then stop for the match |
For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and
45–49, with milestones at 50 and 51. Each uses the existing record header and
one short proximity or milestone line for six seconds, without holder pages.
Milestones describe the round reached; the new result is recorded only after
the match ends. The full join announcement and `.leaders` retain all holders
and their 12-second pages. `.leaders` remains available after the overtake.
Reminders require player readiness and skip players with a pending welcome,
help or record view, or a welcome/manual record request in the last 30 seconds.
Skipped reminders are not queued. Opening help discards an active reminder.
Empty, unavailable and expired snapshots produce no reminders. The existing
cache worker supplies all record data; round starts do not trigger HTTP requests.
Both games use the same behavior, including with the optional HUD omitted.
One level worker fetches the map list and pages of three players, stopping when
the highest-round tie ends. Successful snapshots refresh every five minutes.
Failures retry after 30 seconds and retain the last complete snapshot for up to
@@ -553,6 +597,19 @@ page: each should start one private announcement from the first page. Repeat
while help is open, then disconnect or end the game during a replay to check
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
For reminders, use a known record on each engine and advance through all cadence
boundaries, the matching round and the first higher round. Confirm later rounds
remain quiet while `.leaders` still works, including after a cache refresh.
Check a record not divisible by five to verify the cadence follows the record.
Keep help or a full record open at an eligible round, join immediately before
that round, and request `.leaders` within 30 seconds of it; no reminder should
replace those views or appear later as a queued message. Open help during a
reminder and confirm it does not resume. Verify six-second dismissal, two-element
cleanup on disconnect/game end, and the same behavior without HUD installed.
Repeat with an empty or expired cache and compare request logs: eligible rounds
must add no HTTP traffic. Live T5/T6 rendering and round-transition validation
remain required even when portable checks and T6 compilation pass.
## Telemetry authentication (T5 and T6)
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
@@ -600,3 +657,14 @@ 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.1.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.
Solo and last-active-player AFK are allowed, including all players being AFK
together. Upgrading only the AFK scripts from `2.0.0` requires a map restart,
with no new dependencies or full server-process restart.
+203
View File
@@ -0,0 +1,203 @@
# 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.
AFK `2.1.0` allows solo players and the last active player to enter; all players
may be AFK at the same time. Entry still rejects downed/dead/spectating players
and stock protection/control restrictions. 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. Losing the last non-AFK teammate does not end
protected breaks or force a match end. Authorized time continues to count down;
normal expiration and stock match-end cleanup still apply. 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.1.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.
Upgrading AFK scripts from `2.0.0` to `2.1.0` adds no dependency requirement:
copy the matching script for each game and restart the map. No full server-process
restart is required for this script-only update.
| 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-state 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.
- Enter as the only connected player and as the last active player with downed,
dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the
last active teammate, including while reserve/start replies are pending; verify
protection continues without a forced match end. Check solo return/expiration,
normal stock match-end settlement, afterlife/Grief recovery and 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 the initial `2.0.0` change: 39 AFK behavior checks, 14 core HUD and
56 service HUD checks passed. All nine T6 scripts compiled and assembled with
the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306)
using its T6 PC compiler/assembler library in a temporary local driver. No Windows
DLL build, production deployment or live T5/T6 validation is implied by these checks.
+8
View File
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t5",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,381 @@
#include <stdinc.hpp>
#include "afk.hpp"
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <map>
#include <set>
namespace utils::afk
{
namespace
{
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
constexpr int max_seconds = 604800;
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
std::map<std::string, active_clock> active;
struct stopped_clock { journal::clock::time_point time; std::string reason; };
std::map<std::string, stopped_clock> stopped;
std::string hash(const std::string& value)
{
uint64_t result = 14695981039346656037ull;
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
std::ostringstream out;
out << std::hex << std::setfill('0') << std::setw(16) << result;
return out.str();
}
bool identifier(const nlohmann::json& value)
{
if (!value.is_string()) return false;
const auto& text = value.get_ref<const std::string&>();
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
}
bool integer(const nlohmann::json& value, int maximum = 1000000000)
{
return value.is_number_integer() && value >= 0 && value <= maximum;
}
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
void append(std::vector<request>& requests, const request& incoming)
{
for (const auto& value : requests)
if (value.id == incoming.id)
{
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
throw std::runtime_error("AFK immutable request conflict");
return;
}
requests.push_back(incoming);
}
void erase_session_requests(std::vector<request>& requests, const std::string& id)
{
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
{
const auto data = nlohmann::json::parse(value.body);
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
}), requests.end());
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
if (response.empty()) return invalid("empty_body");
if (response.size() >= max_body_size) return invalid("body_limit");
const auto data = nlohmann::json::parse(response, nullptr, false);
if (data.is_discarded()) return invalid("invalid_json");
if (!data.is_object()) return invalid("schema");
try
{
acknowledgement value;
if (!data.at("ok").is_boolean()) return invalid("schema");
value.state = data.at("state").get<std::string>();
value.code = data.at("code").get<std::string>();
value.id = data.at("request_id").get<std::string>();
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
if (!states.count(value.state)) return invalid("state");
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
const std::pair<const char*, int acknowledgement::*> fields[] = {
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
for (const auto& [name, member] : fields)
{
if (!integer(data.at(name))) return invalid("amount_range");
value.*member = data.at(name).get<int>();
}
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
if (data.contains("awarded_seconds"))
{
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
value.awarded_seconds = data.at("awarded_seconds").get<int>();
}
if (data.contains("session_id") && !data.at("session_id").is_null())
{
value.session_id = data.at("session_id").get<std::string>();
if (value.session_id.size() > 64) return invalid("field_limit");
}
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
{
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
value.consumed_seconds = data.at("consumed_seconds").get<int>();
}
if ((value.state == "reserved" || value.state == "started") &&
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
value.valid = true;
value.reason = "ok";
return value;
}
catch (...) { return invalid("schema"); }
}
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
{ file_ = root / (hash(scope_) + ".json"); }
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
request journal::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
throw std::runtime_error("invalid AFK session identity");
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
throw std::runtime_error("invalid AFK reservation size");
if (value.action == "event")
{
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
throw std::runtime_error("invalid AFK activity sequence");
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
}
return value;
}
journal::snapshot journal::read() const
{
if (!std::filesystem::exists(file_)) return {};
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream) throw std::runtime_error("cannot read AFK journal");
const auto data = nlohmann::json::parse(stream);
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
data.at("requests").size() + data.at("sessions").size() > max_entries)
throw std::runtime_error("invalid AFK journal; preserve for recovery");
snapshot result;
std::set<std::string> requests, sessions;
for (const auto& body : data.at("requests"))
{
auto value = decode(body.get<std::string>());
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
result.requests.push_back(std::move(value));
}
for (const auto& entry : data.at("sessions"))
{
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
const auto request = decode(value.body);
const auto body = nlohmann::json::parse(value.body);
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
throw std::runtime_error("invalid AFK session");
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
(value.phase != "active" && value.consumed_seconds != 0))
throw std::runtime_error("invalid AFK session accounting");
result.sessions.push_back(std::move(value));
}
return result;
}
void journal::save(const snapshot& value) const
{
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
for (const auto& request : value.requests) data["requests"].push_back(request.body);
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
const auto encoded = data.dump();
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
std::filesystem::create_directories(file_.parent_path());
auto temporary = file_; temporary += ".tmp";
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
stream.flush();
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
stream.close();
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
#ifdef _WIN32
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("cannot replace AFK journal; old file retained");
#else
std::filesystem::rename(temporary,file_);
#endif
}
std::vector<request> journal::load() const { return read().requests; }
void journal::store(const std::string& body) const
{
auto value = read();
const auto incoming = decode(body);
// Snapshot replacement is atomic with the new snapshot. Cumulative
// counters retain every reward during an outage without an unbounded
// request per kill. An in-flight older HTTP request remains idempotent;
// removing its old ID cannot remove this newer request.
if (incoming.action == "event")
{
const auto next = nlohmann::json::parse(body);
for (const auto& entry : value.requests)
{
if (entry.action != "event" || entry.id == incoming.id) continue;
const auto prior = nlohmann::json::parse(entry.body);
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
}
// Check reused identities before removing any superseded request.
append(value.requests,incoming);
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
{
if (entry.action != "event" || entry.id == incoming.id) return false;
const auto prior = nlohmann::json::parse(entry.body);
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
}),value.requests.end());
}
else append(value.requests,incoming);
save(value);
}
void journal::erase(const std::string& id) const
{
auto value = read();
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
save(value);
}
void journal::prepare(const std::string& body) const
{
const auto request = decode(body);
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
auto value = read();
for (const auto& existing : value.sessions)
if (session_id(existing) == id)
{
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
return;
}
append(value.requests,request);
value.sessions.push_back({body,"prepared",0,0});
save(value);
}
void journal::authorize(const std::string& id, int seconds) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
return;
}
throw std::runtime_error("missing AFK session");
}
void journal::begin(const std::string& id, clock::time_point now) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
if (session.phase == "active" && active.count(key(id))) return;
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
session.phase = "active";
save(value); // Failure prevents protection; never reset an existing clock.
// Production clocks start after the durable write; slow storage
// must not charge time before the script can apply protection.
if (now == clock::time_point{}) now = clock::now();
active.emplace(key(id),active_clock{now,session.authorized_seconds});
return;
}
throw std::runtime_error("missing AFK session");
}
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
{
const auto found = active.find(key(session_id(value)));
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
}
int journal::remaining(const std::string& id, clock::time_point now) const
{
const auto found = active.find(key(id));
if (found == active.end()) return -1;
if (stopped.count(key(id))) return 0;
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
}
int journal::checkpoint(const std::string& id, clock::time_point now) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
const auto seconds = elapsed(session,now);
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
return seconds;
}
throw std::runtime_error("missing AFK session");
}
request journal::settlement(const session& value, const std::string& reason, int consumed) const
{
auto data = nlohmann::json::parse(value.body);
const auto id = session_id(value);
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
else body["consumed_seconds"] = consumed;
return decode(body.dump());
}
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
{
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
// The caller has already removed protection. Freeze the first stop even
// if disk read/write fails, so retries cannot charge time after return.
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
auto value = read();
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
if (session_id(*it) == id)
{
const auto end = stopped.find(key(id));
const auto stop_time = end == stopped.end() ? now : end->second.time;
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
if ((stop_reason == "never_started" && it->phase == "active") ||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
throw std::runtime_error("AFK stop reason conflicts with usage");
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
erase_session_requests(value.requests,id);
value.sessions.erase(it);
save(value);
active.erase(key(id));
stopped.erase(key(id));
return;
}
// Duplicate stop is safe after a successful durable transition.
}
void journal::recover(const std::string& current_match) const
{
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
auto value = read();
for (const auto& session : value.sessions)
{
const auto id = session_id(session);
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
throw std::runtime_error("cannot recover live AFK protection");
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
erase_session_requests(value.requests,id);
}
const auto sessions = value.sessions;
value.sessions.clear();
save(value);
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
}
}
@@ -0,0 +1,58 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <string>
#include <vector>
namespace utils::afk
{
struct request { std::string id, action, body; };
struct session
{
std::string body, phase;
int authorized_seconds = 0, consumed_seconds = 0;
};
struct acknowledgement
{
bool valid = false;
std::string reason = "schema", state, code, id, session_id;
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
};
acknowledgement parse_acknowledgement(const std::string& response);
// Single game-thread writer. Active clocks are process-local, so recovery
// can never accidentally replay protection after a process restart.
class journal
{
public:
using clock = std::chrono::steady_clock;
journal(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<request> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
void prepare(const std::string& reserve_body) const;
void authorize(const std::string& id, int seconds) const;
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
// Call only at map initialization, with its new persistent match ID.
// Active current-map sessions are rejected. Unknown crash intervals
// remain unresolved at EC, rather than refunding spendable authority.
void recover(const std::string& current_match) const;
private:
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
std::filesystem::path file_;
std::string server_, endpoint_, scope_;
request decode(const std::string& body) const;
snapshot read() const;
void save(const snapshot& value) const;
std::string key(const std::string& id) const;
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
request settlement(const session& value, const std::string& reason, int consumed) const;
};
}
+24 -13
View File
@@ -32,12 +32,15 @@ before copying the renamed scripts.
- **`SetTimerUp(N)` only ticks when `N == 0`** on T5 SP. Non-zero seeds
render "0:00" but don't advance. Late joiners see 0, not real elapsed;
authoritative elapsed lives in `level.match_start_time`.
- **Dynamic numbers use `SetValue`.** Repeatedly generating unique strings
risks exhausting the text config-string pool; identical strings can be reused
by the engine's [string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46).
Composite HP uses numeric elements around a static slash and `HP` suffix;
the group appears only while injured. Help reuses up to nine explicitly
positioned command/description pairs, and record rows skip unchanged text.
- **Dynamic counter text uses `SetTextUnlimited`; HP uses `SetValue`.**
Repeatedly generating unique strings through `SetText` risks exhausting the
text config-string pool; identical strings can be reused by the engine's
[string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46).
The centered `N ZOMBIES` element uses Plutonium r5334+'s `SetTextUnlimited`
only when the count changes. Composite HP retains numeric elements around a
static slash and `HP` suffix; the group appears only while injured. Help
reuses up to nine explicitly positioned command/description pairs, and
record rows skip unchanged text.
- **`NewClientHudElem(player)`, not `NewHudElem()`.** `NewHudElem` is
globally owned; in co-op every player's HUD draws on top of every other
player's. Fall back to `NewHudElem` only if elements don't render at all
@@ -77,7 +80,7 @@ before copying the renamed scripts.
from page one. Keep both services independent of the HUD module. Services
never reposition the core, and AFK remains centered above the zombie count.
- **Bound HUD element counts, including hidden elements.** Core + AFK use
11 + 1 archived elements. Common allocates only the active service view:
10 + 1 archived elements. Common allocates only the active service view:
19 nonarchived elements for nine-command help (20 with a multi-page counter),
or four for records (five with a multi-page counter), and none while idle.
Switching help/record views destroys the previous view first; pages reuse
@@ -99,12 +102,16 @@ before copying the renamed scripts.
polling `self.health` misses the zero-crossing (health is rewritten during
the game-over sequence).
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`. Update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
`SetTextUnlimited`. On r5334+, update the HUD script and restart the map;
older Plutonium clients and servers need updates and full restarts first.
AFK `1.2.0` and leaderboard `1.1.0` remain compatible; AFK still requires
common `1.4.0+`.
The full help order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`: one page of nine commands, with full `<amount|all>` bank
syntax. `.hud` is absent when HUD is omitted. Records use right-aligned
`Map record · Round N` and up to three bounded holder lines, with no footer
`Map record | Round N` and up to three bounded holder lines, with no footer
and no single-page counter. Both views retain 12-second page timing.
Fullscreen X/Y overlay anchors use direct normalized `640 × 480` coordinates.
Dynamic health fills use `make_overlay_width()` like their tracks. Apply
@@ -112,10 +119,14 @@ Dynamic health fills use `make_overlay_width()` like their tracks. Apply
`SetShader` requires an integer width. Legacy panel helpers and their `0.75`
horizontal conversion remain available for existing consumers. Font scale is
at least `1.0`. Numeric HP appears only while injured; the core and services
have no panel backgrounds. There is no new Plutonium/native-plugin/backend
dependency or full server-process restart requirement.
have no panel backgrounds. The counter is one `N ZOMBIES` element centered
at the health track's midpoint with uniform scale `1.25`, starting as
`-- ZOMBIES`. There is no new native-plugin/backend dependency or full
server-process restart requirement when already using Plutonium r5334+.
Run the [shared HUD checks](../../README.md#hud-validation); portable fixtures
do not replace T5 live loading, layout and lifecycle validation.
do not replace T5 live loading, layout and lifecycle validation. Check the
counter's centering at `0`, `6`, `9`, `10`, `99` and `100`, after `.hud`
hides/shows it, and after game over freezes the count.
## Module map
+64 -47
View File
@@ -12,11 +12,11 @@ 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 |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
@@ -60,10 +60,12 @@ invalid identities fail privately without changing points. Follow the shared
## Core HUD and service window
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. 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.
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
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 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.
@@ -74,33 +76,39 @@ 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
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
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
counter). Pages reuse elements; switching views or closing the service releases
(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter), or two for compact round-start reminders. 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 11 + 1 elements.
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
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. Leaderboard `1.2.0` requires common `1.5.0+` for its
round-start reminders. 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
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a
`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.2.0` requires
common `1.5.0+`; this script update adds no native-plugin or backend dependency
and requires no full server-process restart. Leaderboard requires a
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
requires a full server-process restart. To omit leaderboard, remove its script;
@@ -112,6 +120,16 @@ the same private announcement as playable joins. T5 matches its engine map to
the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
apply to both games.
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
6 rounds, then every round for the final five, followed by matching/overtake
milestones. After the first overtake, reminders stop for that match; `.leaders`
still works. A reminder shows the record and one short status line for six
seconds. Join and manual announcements keep all holders and 12-second pages.
Reminders skip busy help/record views, unready players, recent welcome/manual
requests and empty/unavailable/expired records. Help discards a reminder instead
of pausing it. No extra HTTP requests are made. Follow the shared live checks
for cadence, interruption, cleanup and operation without the optional HUD.
## Player commands
Type `.help` or `.commands` in chat to see available player commands and a short description.
@@ -127,8 +145,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) |
@@ -263,6 +281,9 @@ connect listener anywhere else crashes the T5 SP handshake.
Check that branding, the health bar, damage-only HP, zombie count and timers
remain correct during each view and after it closes.
Include other installed HUD mods; the live T5 SP element limit is unverified.
6. Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` at different resolutions:
the complete text must stay centered above the health track. Repeat after
toggling `.hud` and after game over freezes the count.
---
@@ -273,9 +294,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
- Top-center `TWS ZOMBIES · discord.tws.lol` branding
- Bottom-center zombies-remaining counter, with the AFK indicator above it
- `.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, 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
@@ -301,6 +322,7 @@ without seeding clocks; `hud_start` calls it as a fallback if needed.
`destroy_on_disconnect`, `format_mmss`, `register_help_command`,
`register_spawn_handler`, `register_connect_handler`
- Every `level.hud_*` config value from `tws_t5_common`
- Plutonium r5334+ for the count's `SetTextUnlimited` updates
- Stock BO1 state: `level.round_number`, `level.zombie_total`,
`level.players`, `self.health`, `self.maxhealth`, AI entities' `.animname`
and `.ignore_enemy_count`
@@ -309,32 +331,20 @@ 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. AFK `2.1.0` permits solo and last-active-player entry, including all players
being AFK together; teammate loss does not force a match end. Upgrade from `2.0.0`
by copying the script and restarting the map; no new dependencies or full
server-process restart are required. Spawn handler `::afk_spawn` binds contribution
and lifecycle watchers.
---
@@ -604,3 +614,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.
+950 -63
View File
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -170,6 +170,9 @@ show_help()
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
// Round-start notices are timely hints, not requests to resume after help.
if (isDefined(state.record) && state.record.kind == "reminder")
state.record = undefined;
state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
@@ -192,6 +195,29 @@ show_record(snapshot)
service_render(state);
}
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows)
{
return int((max(count, 1) + rows - 1) / rows);
@@ -205,6 +231,8 @@ service_request(kind, pages, generation)
request.page = 0;
request.pages = pages;
request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request;
}
@@ -351,7 +379,10 @@ service_view_create(state, request)
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++)
rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
@@ -457,7 +488,7 @@ service_render(state)
{
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record · Round " + snapshot.round;
header = "Map record | Round " + snapshot.round;
}
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
@@ -467,6 +498,12 @@ service_render(state)
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++)
{
index = page * 3 + i;
+7 -6
View File
@@ -52,12 +52,12 @@ hud_connect()
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// Center the entire readout on the track, regardless of the digit count.
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
self.tws_hud_count = self hud_text(count_x,
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
// Independent numeric elements avoid a new config string on every tick.
// The readout appears only after a valid damaged-health sample.
@@ -233,7 +233,8 @@ hud_zombie_counter()
if (remaining != last)
{
count SetValue(remaining);
// Plutonium r5334+: reuse text storage as the count changes.
count SetTextUnlimited(int(remaining) + " ZOMBIES");
last = remaining;
}
+86 -1
View File
@@ -1,4 +1,4 @@
/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */
/* tws_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\_utility;
#include common_scripts\utility;
@@ -24,10 +24,12 @@ init()
level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker();
level thread lb_round_watcher();
}
lb_worker()
@@ -64,6 +66,88 @@ lb_worker()
}
}
lb_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = 0;
for (;;)
{
// T6 advances its counter before the between-round pause is over.
// Both engines notify the actual start; never synthesize a late start.
level waittill("start_of_round");
if (!isDefined(level.round_number) || level.round_number <= last_round)
continue;
last_round = level.round_number;
lb_round_start(last_round);
}
}
lb_round_start(round)
{
if (isDefined(level.tws_lb_chase_finished))
return;
snapshot = lb_cached_record();
if (!isDefined(snapshot) || snapshot.round < 2)
return;
message = lb_round_message(round, snapshot.round);
if (message == "")
return;
// Celebrate at most once, even if a later refresh raises the fleet record.
// Busy clients skip the milestone too; no delayed mid-round announcements.
if (round > snapshot.round)
level.tws_lb_chase_finished = true;
players = getPlayers();
for (i = 0; i < players.size; i++)
players[i] lb_remind_player(snapshot, message);
}
lb_round_message(round, record)
{
if (round < 1 || record < 2)
return "";
gap = record - round;
if (gap < 0)
return "Recorded high round passed!";
if (gap == 0)
return "Record round reached!";
if (gap > 20)
return "";
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
if (gap > 10 && gap % 5 != 0)
return "";
if (gap > 5 && gap <= 10 && gap % 2 != 0)
return "";
if (gap == 1)
return "1 round away from matching it";
return gap + " rounds away from matching it";
}
lb_player_ready(player)
{
// A welcome attempt (even with no record) or manual request releases this
// gate. Give that full announcement at least 30 seconds before a reminder.
if (!isDefined(player.tws_lb_last_request) ||
getTime() - player.tws_lb_last_request < 30000)
return false;
if (!isDefined(player.sessionstate) ||
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
return false;
if (isDefined(player.hasSpawned) && !player.hasSpawned)
return false;
return true;
}
lb_remind_player(snapshot, message)
{
if (!lb_player_ready(self))
return;
self scripts\sp\tws_t5_common::show_record_reminder(snapshot, message);
}
lb_welcome()
{
self endon("disconnect");
@@ -128,6 +212,7 @@ lb_request(manual)
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
+8
View File
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t6",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,381 @@
#include <stdinc.hpp>
#include "afk.hpp"
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <map>
#include <set>
namespace utils::afk
{
namespace
{
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
constexpr int max_seconds = 604800;
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
std::map<std::string, active_clock> active;
struct stopped_clock { journal::clock::time_point time; std::string reason; };
std::map<std::string, stopped_clock> stopped;
std::string hash(const std::string& value)
{
uint64_t result = 14695981039346656037ull;
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
std::ostringstream out;
out << std::hex << std::setfill('0') << std::setw(16) << result;
return out.str();
}
bool identifier(const nlohmann::json& value)
{
if (!value.is_string()) return false;
const auto& text = value.get_ref<const std::string&>();
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
}
bool integer(const nlohmann::json& value, int maximum = 1000000000)
{
return value.is_number_integer() && value >= 0 && value <= maximum;
}
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
void append(std::vector<request>& requests, const request& incoming)
{
for (const auto& value : requests)
if (value.id == incoming.id)
{
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
throw std::runtime_error("AFK immutable request conflict");
return;
}
requests.push_back(incoming);
}
void erase_session_requests(std::vector<request>& requests, const std::string& id)
{
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
{
const auto data = nlohmann::json::parse(value.body);
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
}), requests.end());
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
if (response.empty()) return invalid("empty_body");
if (response.size() >= max_body_size) return invalid("body_limit");
const auto data = nlohmann::json::parse(response, nullptr, false);
if (data.is_discarded()) return invalid("invalid_json");
if (!data.is_object()) return invalid("schema");
try
{
acknowledgement value;
if (!data.at("ok").is_boolean()) return invalid("schema");
value.state = data.at("state").get<std::string>();
value.code = data.at("code").get<std::string>();
value.id = data.at("request_id").get<std::string>();
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
if (!states.count(value.state)) return invalid("state");
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
const std::pair<const char*, int acknowledgement::*> fields[] = {
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
for (const auto& [name, member] : fields)
{
if (!integer(data.at(name))) return invalid("amount_range");
value.*member = data.at(name).get<int>();
}
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
if (data.contains("awarded_seconds"))
{
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
value.awarded_seconds = data.at("awarded_seconds").get<int>();
}
if (data.contains("session_id") && !data.at("session_id").is_null())
{
value.session_id = data.at("session_id").get<std::string>();
if (value.session_id.size() > 64) return invalid("field_limit");
}
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
{
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
value.consumed_seconds = data.at("consumed_seconds").get<int>();
}
if ((value.state == "reserved" || value.state == "started") &&
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
value.valid = true;
value.reason = "ok";
return value;
}
catch (...) { return invalid("schema"); }
}
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
{ file_ = root / (hash(scope_) + ".json"); }
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
request journal::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
throw std::runtime_error("invalid AFK session identity");
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
throw std::runtime_error("invalid AFK reservation size");
if (value.action == "event")
{
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
throw std::runtime_error("invalid AFK activity sequence");
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
}
return value;
}
journal::snapshot journal::read() const
{
if (!std::filesystem::exists(file_)) return {};
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream) throw std::runtime_error("cannot read AFK journal");
const auto data = nlohmann::json::parse(stream);
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
data.at("requests").size() + data.at("sessions").size() > max_entries)
throw std::runtime_error("invalid AFK journal; preserve for recovery");
snapshot result;
std::set<std::string> requests, sessions;
for (const auto& body : data.at("requests"))
{
auto value = decode(body.get<std::string>());
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
result.requests.push_back(std::move(value));
}
for (const auto& entry : data.at("sessions"))
{
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
const auto request = decode(value.body);
const auto body = nlohmann::json::parse(value.body);
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
throw std::runtime_error("invalid AFK session");
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
(value.phase != "active" && value.consumed_seconds != 0))
throw std::runtime_error("invalid AFK session accounting");
result.sessions.push_back(std::move(value));
}
return result;
}
void journal::save(const snapshot& value) const
{
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
for (const auto& request : value.requests) data["requests"].push_back(request.body);
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
const auto encoded = data.dump();
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
std::filesystem::create_directories(file_.parent_path());
auto temporary = file_; temporary += ".tmp";
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
stream.flush();
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
stream.close();
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
#ifdef _WIN32
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("cannot replace AFK journal; old file retained");
#else
std::filesystem::rename(temporary,file_);
#endif
}
std::vector<request> journal::load() const { return read().requests; }
void journal::store(const std::string& body) const
{
auto value = read();
const auto incoming = decode(body);
// Snapshot replacement is atomic with the new snapshot. Cumulative
// counters retain every reward during an outage without an unbounded
// request per kill. An in-flight older HTTP request remains idempotent;
// removing its old ID cannot remove this newer request.
if (incoming.action == "event")
{
const auto next = nlohmann::json::parse(body);
for (const auto& entry : value.requests)
{
if (entry.action != "event" || entry.id == incoming.id) continue;
const auto prior = nlohmann::json::parse(entry.body);
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
}
// Check reused identities before removing any superseded request.
append(value.requests,incoming);
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
{
if (entry.action != "event" || entry.id == incoming.id) return false;
const auto prior = nlohmann::json::parse(entry.body);
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
}),value.requests.end());
}
else append(value.requests,incoming);
save(value);
}
void journal::erase(const std::string& id) const
{
auto value = read();
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
save(value);
}
void journal::prepare(const std::string& body) const
{
const auto request = decode(body);
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
auto value = read();
for (const auto& existing : value.sessions)
if (session_id(existing) == id)
{
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
return;
}
append(value.requests,request);
value.sessions.push_back({body,"prepared",0,0});
save(value);
}
void journal::authorize(const std::string& id, int seconds) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
return;
}
throw std::runtime_error("missing AFK session");
}
void journal::begin(const std::string& id, clock::time_point now) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
if (session.phase == "active" && active.count(key(id))) return;
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
session.phase = "active";
save(value); // Failure prevents protection; never reset an existing clock.
// Production clocks start after the durable write; slow storage
// must not charge time before the script can apply protection.
if (now == clock::time_point{}) now = clock::now();
active.emplace(key(id),active_clock{now,session.authorized_seconds});
return;
}
throw std::runtime_error("missing AFK session");
}
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
{
const auto found = active.find(key(session_id(value)));
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
}
int journal::remaining(const std::string& id, clock::time_point now) const
{
const auto found = active.find(key(id));
if (found == active.end()) return -1;
if (stopped.count(key(id))) return 0;
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
}
int journal::checkpoint(const std::string& id, clock::time_point now) const
{
auto value = read();
for (auto& session : value.sessions)
if (session_id(session) == id)
{
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
const auto seconds = elapsed(session,now);
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
return seconds;
}
throw std::runtime_error("missing AFK session");
}
request journal::settlement(const session& value, const std::string& reason, int consumed) const
{
auto data = nlohmann::json::parse(value.body);
const auto id = session_id(value);
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
else body["consumed_seconds"] = consumed;
return decode(body.dump());
}
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
{
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
// The caller has already removed protection. Freeze the first stop even
// if disk read/write fails, so retries cannot charge time after return.
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
auto value = read();
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
if (session_id(*it) == id)
{
const auto end = stopped.find(key(id));
const auto stop_time = end == stopped.end() ? now : end->second.time;
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
if ((stop_reason == "never_started" && it->phase == "active") ||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
throw std::runtime_error("AFK stop reason conflicts with usage");
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
erase_session_requests(value.requests,id);
value.sessions.erase(it);
save(value);
active.erase(key(id));
stopped.erase(key(id));
return;
}
// Duplicate stop is safe after a successful durable transition.
}
void journal::recover(const std::string& current_match) const
{
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
auto value = read();
for (const auto& session : value.sessions)
{
const auto id = session_id(session);
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
throw std::runtime_error("cannot recover live AFK protection");
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
erase_session_requests(value.requests,id);
}
const auto sessions = value.sessions;
value.sessions.clear();
save(value);
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
}
}
@@ -0,0 +1,58 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <string>
#include <vector>
namespace utils::afk
{
struct request { std::string id, action, body; };
struct session
{
std::string body, phase;
int authorized_seconds = 0, consumed_seconds = 0;
};
struct acknowledgement
{
bool valid = false;
std::string reason = "schema", state, code, id, session_id;
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
};
acknowledgement parse_acknowledgement(const std::string& response);
// Single game-thread writer. Active clocks are process-local, so recovery
// can never accidentally replay protection after a process restart.
class journal
{
public:
using clock = std::chrono::steady_clock;
journal(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<request> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
void prepare(const std::string& reserve_body) const;
void authorize(const std::string& id, int seconds) const;
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
// Call only at map initialization, with its new persistent match ID.
// Active current-map sessions are rejected. Unknown crash intervals
// remain unresolved at EC, rather than refunding spendable authority.
void recover(const std::string& current_match) const;
private:
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
std::filesystem::path file_;
std::string server_, endpoint_, scope_;
request decode(const std::string& body) const;
snapshot read() const;
void save(const snapshot& value) const;
std::string key(const std::string& id) const;
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
request settlement(const session& value, const std::string& reason, int consumed) const;
};
}
+15 -7
View File
@@ -43,7 +43,7 @@ view: 19 nonarchived elements for nine-command help (20 with a multi-page
counter), or four for records (five with a multi-page counter). Closing the
service or switching kinds destroys the view; idle holds no elements. Pages
reuse the active elements, separately from controller and pending-request state.
Core + AFK use 11 + 1 archived elements. Hidden elements still occupy slots.
Core + AFK use 10 + 1 archived elements. Hidden elements still occupy slots.
The [T6 player HUD structure](https://github.com/skiff/t6-gsc-helper/blob/50ff6cb7397c9376894a195f48118d1743812d1d/src/game/structs.hpp#L184)
has 31 slots in each bucket; it does not establish current Plutonium stock HUD
usage. Keep the live overlap/element-budget checks alongside the portable checks.
@@ -54,18 +54,24 @@ creation must not seed the match clock. The unboxed layout has timers at the
upper left, branding at top center and zombies/health at bottom center. HP
numbers appear to the right of the bar only while injured; AFK sits above the
zombie count. Services never reposition the core. Records are right aligned,
with `Map record · Round N` followed by up to three bounded holder lines, no
with `Map record | Round N` followed by up to three bounded holder lines, no
footer and no single-page counter. Their 12-second page timing is unchanged.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`; update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
`SetTextUnlimited`. The `N ZOMBIES` counter is one element centered at the
health track's midpoint with uniform scale `1.25`, starting as `-- ZOMBIES`.
Only changed counts update its text; numeric HP retains `SetValue`. On r5334+,
update the HUD script and restart the map. Older Plutonium clients and servers
need updates and full restarts first. AFK `1.2.0` and leaderboard `1.1.0` remain
compatible; AFK still requires common `1.4.0+`.
Overlay helpers use direct normalized `640 × 480` coordinates. Dynamic health
fills use `make_overlay_width()` like their tracks. Apply `int()` after `max()`
in that helper: native `max()` returns a float, but `SetShader` requires an
integer width. Legacy panel helpers retain their `0.75` horizontal conversion
for existing consumers. Font scale is at least `1.0`. This introduces no
Plutonium/native-plugin/backend dependency or full server-process restart
requirement.
for existing consumers. Font scale is at least `1.0`. There is no new
native-plugin/backend dependency or full server-process restart requirement
when already using Plutonium r5334+.
HUD owns the exact, case-insensitive `.hud` chat command and registers it in
help only while installed. Common's core wrappers preserve each element's
@@ -195,6 +201,8 @@ Live checks after deploying:
spectator placeholders without an early clock start. Check damage-only HP,
health/Juggernog changes, AFK placement and timers frozen at game over.
Test safe-area settings and different resolutions against stock HUD elements.
Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` remain centered above the
health track, including after `.hud` toggles and game over freezes the count.
2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid
toggles, a round transition, death/respawn and game over.
3. Lock fires at the threshold and clears on game over/restart. HUD must be installed.
+50 -19
View File
@@ -8,12 +8,12 @@ private server token.
| File | Role | Dependencies |
| --- | --- | --- |
| `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+ |
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.4.0+ |
| `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` | 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 |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils |
| `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
@@ -73,10 +73,12 @@ populates that field before the `connected` notification. Follow the shared
## Core HUD and service window
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. 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.
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
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 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.
@@ -87,28 +89,32 @@ 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
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
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
counter). Pages reuse elements; switching views or closing the service releases
(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter), or two for compact round-start reminders. 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 11 + 1 elements.
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
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. Leaderboard `1.2.0` requires common `1.5.0+` for its
round-start reminders. 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
@@ -116,6 +122,21 @@ full server-process restart.
It works independently of telemetry and HUD, and privately announces records
once per connection, including newcomers waiting in spectator mode.
Leaderboard `1.2.0` requires common `1.5.0+`. Copy both scripts to
`Plutonium/storage/t6/scripts/zm/` and restart the map. This script update adds
no native-plugin or backend dependency and requires no full server-process
restart when the existing plugin is installed.
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
6 rounds, then every round for the final five, followed by matching/overtake
milestones. After the first overtake, reminders stop for that match; `.leaders`
still works. A reminder shows the record and one short status line for six
seconds. Join and manual announcements keep all holders and 12-second pages.
Reminders skip busy help/record views, unready players, recent welcome/manual
requests and empty/unavailable/expired records. Help discards a reminder instead
of pausing it. No extra HTTP requests are made. Follow the shared live checks
for cadence, interruption, cleanup and operation without the optional HUD.
Map selection uses stock `level.scr_zm_ui_gametype` and
`level.scr_zm_map_start_location`, falling back to the corresponding
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
@@ -143,8 +164,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) |
@@ -276,3 +297,13 @@ 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.1.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.
Solo and last-active-player entry are allowed, including all players being AFK
together; teammate loss does not force a match end. Upgrading from `2.0.0` adds
no dependencies and requires only copying the script and restarting the map.
+882 -67
View File
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -166,6 +166,9 @@ show_help()
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
// Round-start notices are timely hints, not requests to resume after help.
if (isDefined(state.record) && state.record.kind == "reminder")
state.record = undefined;
state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
@@ -188,6 +191,29 @@ show_record(snapshot)
service_render(state);
}
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows)
{
return int((max(count, 1) + rows - 1) / rows);
@@ -201,6 +227,8 @@ service_request(kind, pages, generation)
request.page = 0;
request.pages = pages;
request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request;
}
@@ -347,7 +375,10 @@ service_view_create(state, request)
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++)
rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
@@ -453,7 +484,7 @@ service_render(state)
{
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record · Round " + snapshot.round;
header = "Map record | Round " + snapshot.round;
}
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
@@ -463,6 +494,12 @@ service_render(state)
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++)
{
index = page * 3 + i;
+7 -6
View File
@@ -54,12 +54,12 @@ hud_connect()
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// Center the entire readout on the track, regardless of the digit count.
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
self.tws_hud_count = self hud_text(count_x,
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
// Independent numeric elements avoid a new config string on every tick.
// The readout appears only after a valid damaged-health sample.
@@ -239,7 +239,8 @@ hud_zombie_counter()
if (remaining != last)
{
count SetValue(remaining);
// Plutonium r5334+: reuse text storage as the count changes.
count SetTextUnlimited(int(remaining) + " ZOMBIES");
last = remaining;
}
+86 -1
View File
@@ -1,4 +1,4 @@
/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */
/* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\mp\_utility;
#include common_scripts\utility;
@@ -26,10 +26,12 @@ init()
level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker();
level thread lb_round_watcher();
}
lb_worker()
@@ -66,6 +68,88 @@ lb_worker()
}
}
lb_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = 0;
for (;;)
{
// T6 advances its counter before the between-round pause is over.
// Both engines notify the actual start; never synthesize a late start.
level waittill("start_of_round");
if (!isDefined(level.round_number) || level.round_number <= last_round)
continue;
last_round = level.round_number;
lb_round_start(last_round);
}
}
lb_round_start(round)
{
if (isDefined(level.tws_lb_chase_finished))
return;
snapshot = lb_cached_record();
if (!isDefined(snapshot) || snapshot.round < 2)
return;
message = lb_round_message(round, snapshot.round);
if (message == "")
return;
// Celebrate at most once, even if a later refresh raises the fleet record.
// Busy clients skip the milestone too; no delayed mid-round announcements.
if (round > snapshot.round)
level.tws_lb_chase_finished = true;
players = getPlayers();
for (i = 0; i < players.size; i++)
players[i] lb_remind_player(snapshot, message);
}
lb_round_message(round, record)
{
if (round < 1 || record < 2)
return "";
gap = record - round;
if (gap < 0)
return "Recorded high round passed!";
if (gap == 0)
return "Record round reached!";
if (gap > 20)
return "";
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
if (gap > 10 && gap % 5 != 0)
return "";
if (gap > 5 && gap <= 10 && gap % 2 != 0)
return "";
if (gap == 1)
return "1 round away from matching it";
return gap + " rounds away from matching it";
}
lb_player_ready(player)
{
// A welcome attempt (even with no record) or manual request releases this
// gate. Give that full announcement at least 30 seconds before a reminder.
if (!isDefined(player.tws_lb_last_request) ||
getTime() - player.tws_lb_last_request < 30000)
return false;
if (!isDefined(player.sessionstate) ||
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
return false;
if (isDefined(level.passed_introscreen) && !level.passed_introscreen)
return false;
return true;
}
lb_remind_player(snapshot, message)
{
if (!lb_player_ready(self))
return;
self scripts\zm\tws_t6_common::show_record_reminder(snapshot, message);
}
lb_welcome()
{
self endon("disconnect");
@@ -131,6 +215,7 @@ lb_request(manual)
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
+271
View File
@@ -0,0 +1,271 @@
// 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('solo and last active players can enter with unavailable or pending teammates',()=>{
for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){
const e=engine(game);
if(flags===null)e.players.splice(1,1);
else if(flags.pending)e.other.tws_afk_state.pending=true;
else Object.assign(e.other,flags);
e.enter();e.advance(1000);e.tick();
assert(e.p.hud_afk_on);assert.equal(e.p.afk_remaining(),1799000);
assert.equal(e.stops.length,0);assert.equal(e.p.sessionstate,'playing');
assert(!e.log.some(x=>x[0]==='notify'));
}
});
test('teammate death or disconnect during authorization does not cancel entry',()=>{
for(const at of ['reserve','start'])for(const change of ['death','disconnect']){
const e=engine(game,{during:request=>{
if(request.action!==at)return;
if(change==='death')e.other.alive=false;
else e.players.splice(1,1);
}});
e.enter();e.tick();assert(e.p.hud_afk_on);assert.equal(e.stops.length,0);
}
});
test('all players can be AFK and teammate loss does not force a match end',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
e.advance(1000);e.tick();e.other.afk_tick().next();
assert(e.p.hud_afk_on);assert(e.other.hud_afk_on);assert.equal(e.stops.length,0);
e.other.alive=false;e.other.afk_tick().next();e.tick();
assert(e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'death');
e.players.splice(1,1);e.advance(1000);e.tick();assert(e.p.hud_afk_on);
assert(!e.log.some(x=>x[0]==='notify'));
assert(!e.source.includes('afk_team_watcher'));
assert(!/level\s+notify\("end_game"\)/.test(e.source));
});
test('solo protection expires normally and manual return preserves bounded grace',()=>{
for(const manual of [false,true]){
const e=engine(game,{authorized:3});e.players.splice(1,1);e.enter();
e.advance(1000);assert.equal(e.p.afk_damage(null,null,10),0);
if(manual){e.p.afk_exit();assert.equal(e.stops.at(-1).reason,'manual');}
e.advance(2000);e.tick();
assert(!e.p.hud_afk_on);assert(!e.p.tws_afk_protected);assert(!e.p.ignoreme);
assert(!e.p.tws_afk_controls_locked);assert.equal(e.p.afk_damage(null,null,10),-1);
if(!manual)assert.equal(e.stops.at(-1).reason,'expired');
}
});
test('stock match end still releases and settles every AFK player',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
const watcher=e.c.afk_end_watcher();watcher.next();watcher.next();
assert(e.level.afk_ended);assert.equal(e.stops.length,2);
for(const p of e.players){assert(!p.hud_afk_on);assert(!p.tws_afk_protected);assert(p.tws_afk_state.cancelled);}
assert(e.stops.every(s=>s.reason==='match_end'));
});
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).`);
+52 -57
View File
@@ -1,4 +1,5 @@
// Execute selected core HUD/AFK functions with engine-value shims.
// Execute selected core HUD functions with engine-value shims.
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
@@ -26,11 +27,12 @@ function install(context, source, names) {
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+|--)/g)].map(match => match[1]))];
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
.replace(/maps\\mp\\zombies\\_zm_utility::get_current_zombie_count/g, 'get_enemy_count')
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
.replace(/self (\w+)\(/g, 'self.$1(')
.replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/(\w+) (SetText|SetTextUnlimited|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
.replace(/level waittill\(([^;]*)\);/g, 'yield { event: $1 };')
.replace(/\.size\b/g, '.length')
@@ -51,6 +53,7 @@ function engine(game, source, afk, hidden = false) {
function element(args, alpha = 1) {
const result = { args, alpha, writes: [],
SetText(value) { this.text = value; this.writes.push(['text', value]); },
SetTextUnlimited(value) { this.text = value; this.writes.push(['unlimitedText', value]); },
SetValue(value) { this.value = value; this.writes.push(['value', value]); },
SetShader(...args) { this.shader = args; },
SetTimerUp(value) { this.timer = value; this.writes.push(['timer', value]); },
@@ -79,7 +82,7 @@ function engine(game, source, afk, hidden = false) {
player_active: player => player.active,
format_mmss: ms => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`,
};
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2,
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2, zombie_team: 'axis',
hud_overlay_timer_x: 6, hud_overlay_match_y: 8, hud_overlay_round_y: 30,
hud_overlay_brand_x: 320, hud_overlay_brand_y: 8,
hud_overlay_count_x: 300, hud_overlay_count_y: 440,
@@ -92,12 +95,11 @@ function engine(game, source, afk, hidden = false) {
hud_c_count: 'white', hud_c_dim: 'dim', hud_c_afk: 'yellow',
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
const context = vm.createContext({ self, level, common, queued, getTime: () => now,
get_enemy_count: () => level.fixture_enemies ?? 0,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
@@ -118,22 +120,21 @@ for (const game of ['t5', 't6']) {
assert(chat.includes('player thread hud_toggle()'), 'disconnect scope stays in the player worker');
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
assert(!source.includes('tws_hud_hidden ='), 'spawn/construction must not reset the preference');
for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind'])
for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind', 'hud_zombie_counter'])
assert(body(source, name)[1].includes('level endon("hud_game_over")'));
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 11 box-free core elements at the screen edges without seeding clocks`, () => {
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
assert.equal(e.elems.length, 11);
assert.equal(e.self.tws_core_hud_elems.length, 11);
e.context.hud_connect(); assert.equal(e.elems.length, 11);
assert.equal(e.elems.length, 10);
assert.equal(e.self.tws_core_hud_elems.length, 10);
e.context.hud_connect(); assert.equal(e.elems.length, 10);
assert.equal(e.queued.length, 1); assert.equal(e.queued[0][0], 'common.destroy_on_disconnect');
assert.equal(e.queued[0][1].length, e.elems.length);
const textAt = (text, x, y, align, scale, colour) => {
@@ -142,9 +143,9 @@ for (const game of ['t5', 't6']) {
};
assert.deepEqual(e.self.tws_hud_match.args.slice(1), [6, 8, 'left', 1.4, 'white']);
assert.deepEqual(e.self.tws_hud_round.args.slice(1), [6, 30, 'left', 1.25, 'dim']);
textAt('TWS ZOMBIES · discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
assert.deepEqual(e.self.tws_hud_count.args.slice(1), [300, 440, 'right', 1.5, 'white']);
textAt('ZOMBIES', 306, 446, 'left', 1.05, 'dim');
textAt('TWS ZOMBIES | discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
textAt('-- ZOMBIES', 320, 440, 'center', 1.25, 'white');
assert(!e.elems.some(elem => elem.text === 'ZOMBIES'), 'count and label share one alignment');
textAt('HP', 449, 460, 'left', 1, 'white');
for (const [key, x, align] of [['health', 414, 'right'], ['slash', 418, 'left'], ['maxhealth', 445, 'right']]) {
const elem = e.self[`tws_hud_${key}`];
@@ -165,13 +166,42 @@ for (const game of ['t5', 't6']) {
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.queued.length, 1, 'toggle only queues disconnect cleanup');
e.context.hud_start(); e.context.hud_start();
assert.equal(e.elems.length, 11); assert.equal(e.self.tws_hud_hidden, true);
assert.equal(e.elems.length, 10); assert.equal(e.self.tws_hud_hidden, true);
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
e.context.hud_timer_row();
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
const threads = e.queued.length;
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 11);
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 10);
});
check(`${game}: complete zombie text stays centered through digit changes and hidden updates without config-string churn`, () => {
const e = make();
// Center on the configured track, including non-default and fractional midpoints.
e.level.hud_overlay_bar_x = 210;
e.level.hud_overlay_bar_width = 155;
e.context.hud_connect();
const count = e.self.tws_hud_count;
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
const run = e.context.hud_zombie_counter();
for (const value of [0, 6, 9, 10, 99, 100, 999, 1000, 6]) {
e.level.fixture_enemies = value;
run.next();
assert.equal(count.text, `${value} ZOMBIES`);
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
const writes = count.writes.length;
run.next(); assert.equal(count.writes.length, writes, 'unchanged counts do not rewrite text');
}
e.level.zombie_total = 94; run.next();
assert.equal(count.text, '100 ZOMBIES', 'remaining spawn count remains included');
e.context.hud_toggle();
e.level.zombie_total = -4; run.next();
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 0);
e.context.hud_toggle();
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 1);
assert.equal(count.writes.filter(([kind]) => kind === 'text').length, 1,
'only the fixed placeholder uses a normal config string');
assert(count.writes.slice(1).every(([kind]) => kind === 'unlimitedText'));
assert.equal(e.elems.length, 10, 'updates and toggles never allocate replacement elements');
});
check(`${game}: health numbers appear only while damaged, follow the bar colour, and avoid config-string churn`, () => {
const e = make(); e.context.hud_connect(); const { self } = e;
@@ -216,7 +246,7 @@ for (const game of ['t5', 't6']) {
e.context.hud_toggle();
assert.equal(e.self.tws_hud_bar.alpha, 1); assert.equal(e.self.tws_hud_health.value, 50);
assert.equal(other.self.tws_hud_hidden, undefined); assert.equal(other.self.tws_hud_health.alpha, 0);
assert.equal(e.elems.length, 11); assert.equal(e.queued.length, 1);
assert.equal(e.elems.length, 10); assert.equal(e.queued.length, 1);
e.context.hud_toggle(); e.self.health = 100; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.alpha, 0, 'healing while hidden conceals the readout when shown');
assert.equal(e.self.tws_hud_hp.alpha, 0); assert.equal(e.self.tws_hud_bar.alpha, 1);
@@ -234,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, 12);
});
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).`);
+126 -8
View File
@@ -24,7 +24,7 @@ function translate(source) {
const result = [];
for (let match; (match = headers.exec(protectedSource));) {
const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', 'destroy_on_disconnect'].includes(name)) continue;
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'show_record_reminder', '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) {
@@ -45,7 +45,7 @@ function translate(source) {
.replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);')
.replace(/self (\w+)\(/g, '$1(')
.replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };')
.replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetValue|SetShader|Destroy)\(/g, '$1.$2(')
.replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetTextUnlimited|SetValue|SetShader|Destroy)\(/g, '$1.$2(')
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
@@ -99,6 +99,7 @@ function engine(game, count = 8, withHud = false) {
owner,
alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0,
SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
SetTextUnlimited(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; },
SetShader(shader, width, height) {
assert(!this.destroyCalls);
@@ -128,10 +129,11 @@ 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),
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
get live() { return elements.filter(e => !e.destroyCalls); },
@@ -321,6 +323,120 @@ for (const game of ['t5', 't6']) {
assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts);
});
check(`${game} automatic reminders use two elements and expire after six seconds`, () => {
const e = engine(game);
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true);
const request = e.state.record;
assert.equal(request.kind, 'reminder');
assert.equal(request.pages, 1);
assert.equal(request.remaining, 6000);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.rows.length, 1);
assert.equal(e.state.rows[0].text, '5 rounds to tie');
assert.equal(e.live.length, 2);
assert.equal(e.visible.length, 2);
assertCounter(e, 1, 1);
assertTextOnly(e);
const calls = e.textCalls, allocated = e.elements.length;
e.advance(5900);
assert.equal(e.state.record, request);
assert.equal(request.remaining, 100);
assert.equal(e.reminder({ round: 83 }, 'Newer target'), false);
assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint');
assert.equal(request.remaining, 100);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, allocated);
e.advance(100);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true);
assert.equal(e.state.record.remaining, 6000);
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 2);
});
check(`${game} automatic reminders never replace or queue behind help and full records`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(1000);
const record = e.state.record;
const generation = e.state.generation, allocations = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000);
assert.equal(e.state.generation, generation);
assert.equal(e.elements.length, allocations);
e.help();
e.advance(1000);
const help = e.state.help, withHelp = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.help, help);
assert.equal(e.state.record, record);
assert.equal(e.elements.length, withHelp);
e.advance(11000);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time');
e.advance(23000);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0, 'skipped hints do not appear later');
e.help();
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, undefined, 'help alone must not queue a hint');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
const reminderElements = [...e.live];
e.advance(2000);
e.help();
assert.equal(e.state.record, undefined);
assert(reminderElements.every(elem => elem.destroyCalls === 1));
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help');
e.reminder({ round: 82 }, '4 rounds to tie');
const replaced = [...e.live];
e.advance(5900);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
assert.equal(e.state.record.kind, 'record');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.record.pages, 2);
assert.equal(e.live.length, 5);
assert(replaced.every(elem => elem.destroyCalls === 1));
e.advance(100);
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
assert.equal(e.state.record.remaining, 11900);
e.advance(11900);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.rows[0].text, 'Four');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
const e = engine(game);
for (const bad of [undefined, {}, { round: 0 }, { round: -1 }])
assert.equal(e.reminder(bad, '5 rounds to tie'), false);
for (const message of [undefined, ''])
assert.equal(e.reminder({ round: 82 }, message), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect'])
check(`${game} ${event} cleans a reminder and blocks reopening`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
e.advance(1000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert.equal(e.state.closed, true);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false);
e.advance(12000);
assert.equal(e.elements.length, 2);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game);
e.record(undefined);
@@ -335,7 +451,7 @@ for (const game of ['t5', 't6']) {
assertCounter(e, 1, 1);
e.record(snapshot(['A', 'B', 'C', 'D']));
assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first');
assert.equal(e.state.record_title.text, 'Map record · Round 82');
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.live.length, 5);
const paged = [...e.live];
assertCounter(e, 1, 2);
@@ -371,6 +487,8 @@ for (const game of ['t5', 't6']) {
check(`${game} ending before first service blocks lazy creation`, () => {
const e = engine(game);
e.end('end_game'); e.help(); e.record(snapshot());
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
@@ -380,7 +498,7 @@ for (const game of ['t5', 't6']) {
e.advance(11000);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, count);
assert.equal(e.state.record_title.text, 'Map record · Round 82');
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.commands[3].command, '.join');
assert.match(e.c.help_description(e.state.commands[3]), /newcomer/);
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
@@ -522,9 +640,9 @@ 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, 12, 'eleven core elements plus one optional AFK indicator');
assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetValue(6);
e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES');
e.self.tws_hud_health.SetValue(100);
e.self.tws_hud_maxhealth.SetValue(100);
e.c.set_core_alpha(e.self, e.self.tws_hud_bar, 1);
@@ -546,7 +664,7 @@ check('T5 service transitions preserve actual core element identities, styles, d
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, 31, '12 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));
+233 -7
View File
@@ -1,4 +1,4 @@
// Execute the actual data-only GSC functions with small engine-value shims.
// Execute actual leaderboard GSC functions with small data, player and clock shims.
// This is a fixture check, not a GSC VM, linker or gameplay test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -34,9 +34,13 @@ function functions(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
// Each GSC function has its own implicit locals, including recursion.
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length').replace(/\bwait\s+[\d.]+;/g, ';');
const coroutine = match[1] === 'lb_round_watcher';
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
if (coroutine)
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
found.set(match[1], `function${coroutine ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
@@ -44,15 +48,17 @@ function functions(source) {
function engine(game) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener',
'lb_request', 'lb_round_watcher', 'lb_round_start', 'lb_remind_player']);
const table = [];
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
const logs = [], queries = [], responses = [];
let now = 1000000;
const context = vm.createContext({
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
GetDvar: (key) => dvars[key] ?? '',
getTime: () => 1000000,
getTime: () => now,
spawnStruct: () => ({}),
isDefined: (value) => value !== undefined && value !== null,
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
@@ -69,7 +75,76 @@ function engine(game) {
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
});
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
return { context, all, source, dvars, responses, queries, logs };
return { context, all, source, dvars, responses, queries, logs,
get now() { return now; }, setTime(value) { now = value; } };
}
function roundRuntime(game) {
const e = engine(game), c = e.context;
const players = [], attempts = [], shown = [], records = [], notifications = [], endEvents = [];
const current = (player, run) => {
const prior = c.self;
c.self = player;
try { return run(); } finally { c.self = prior; }
};
c.getPlayers = () => players;
c.endon = (entity, event) => {
assert.equal(entity, c.level);
endEvents.push(event);
};
c.playerReminder = (player, snapshot, message) => current(player, () => c.lb_remind_player(snapshot, message));
c.reminderService = (player, snapshot, message) => {
const notice = { player, snapshot, message, round: c.level.round_number };
attempts.push(notice);
if (player.busy) return false;
shown.push(notice);
return true;
};
c.recordService = (player, snapshot) => records.push({ player, snapshot });
c.playerNotify = (player, event) => notifications.push({ player, event });
for (const name of ['lb_round_start', 'lb_remind_player', 'lb_request', 'lb_round_watcher']) {
const code = e.all.get(name)
.replace(/\b(?:self|level) endon\([^;]*\);/g, declaration =>
name === 'lb_round_watcher' ? declaration.replace('level endon(', 'endon(level, ') : '')
.replace(/players\[i\] lb_remind_player\(/g, 'playerReminder(players[i], ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record_reminder\(/g, 'reminderService(self, ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(self, ')
.replace(/self notify\(/g, 'playerNotify(self, ');
vm.runInContext(code, c);
}
let watcher, stopped = false;
const resumeWatcher = () => {
const step = watcher.next();
assert.equal(step.done, false);
assert.equal(step.value.entity, c.level);
assert.equal(step.value.event, 'start_of_round');
};
const startWatcher = () => {
if (watcher) return;
watcher = c.lb_round_watcher();
resumeWatcher();
};
return { ...e, players, attempts, shown, records, notifications, endEvents,
get now() { return e.now; },
readyPlayer(fields = {}) {
const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
players.push(player);
return player;
},
cache(round, saved_at = e.now) {
return (c.level.tws_lb_cache = { round, saved_at, lines: ['Holder'] });
},
round(round) { c.level.round_number = round; c.lb_round_start(round); },
request(player, manual) { return current(player, () => c.lb_request(manual)); },
startWatcher,
startRoundEvent(round) {
c.level.round_number = round;
if (stopped) return;
startWatcher();
resumeWatcher();
},
end(event) { if (endEvents.includes(event)) { stopped = true; watcher.return(); } },
};
}
function mapOption(game) {
@@ -147,6 +222,157 @@ for (const game of ['t5', 't6']) {
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
c.level.tws_lb_cache = undefined;
});
for (const [record, expected] of [
[50, [30, 35, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51]],
[53, [33, 38, 43, 45, 47, 48, 49, 50, 51, 52, 53, 54]],
]) check(`${game} record ${record} reminders accelerate relative to the target`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 1; round <= record + 10; round++) e.round(round);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
assert.equal(e.shown[0].message, '20 rounds away from matching it');
assert.equal(e.shown.at(-3).message, '1 round away from matching it');
assert.equal(e.shown.at(-2).message, 'Record round reached!');
assert.equal(e.shown.at(-1).message, 'Recorded high round passed!');
assert.equal(e.context.level.tws_lb_chase_finished, true);
assert.equal(e.queries.length, 0);
});
check(`${game} low records avoid round-one history and handle short approaches`, () => {
for (const record of [0, 1, 2, 6]) {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 0; round <= record + 4; round++) e.round(round);
const expected = record < 2 ? [] : Array.from({ length: record + 1 }, (_, i) => i + 1);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
}
});
check(`${game} absent, expired and empty caches skip reminders without fetching`, () => {
const e = roundRuntime(game);
e.readyPlayer();
for (const cached of [undefined, { round: 0, saved_at: e.now }, { round: 1, saved_at: e.now },
{ round: 50, saved_at: e.now - 900001 }]) {
e.context.level.tws_lb_cache = cached;
e.round(30); e.round(51);
assert.equal(e.context.level.tws_lb_chase_finished, undefined);
}
assert.equal(e.attempts.length, 0);
const stillUsable = e.cache(50, e.now - 900000);
e.round(30);
assert.equal(e.shown.length, 1);
assert.equal(e.shown[0].snapshot, stillUsable);
assert.equal(e.queries.length, 0);
});
check(`${game} the next round uses the latest cache without a mid-round announcement`, () => {
const e = roundRuntime(game);
e.readyPlayer();
const first = e.cache(50);
e.round(35);
const raised = e.cache(60);
assert.equal(e.shown.length, 1);
e.round(40);
const corrected = e.cache(55);
e.round(45);
assert.deepEqual(e.shown.map(notice => notice.snapshot), [first, raised, corrected]);
assert.deepEqual(e.shown.map(notice => notice.message), [
'15 rounds away from matching it', '20 rounds away from matching it', '10 rounds away from matching it',
]);
assert.equal(e.queries.length, 0);
});
check(`${game} overtaking finishes this match's chase even after cache changes or a busy client`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.round(50); e.round(51); e.round(52);
e.cache(70);
e.round(55); e.round(70); e.round(71);
assert.deepEqual(e.shown.map(notice => notice.round), [50, 51]);
const busy = roundRuntime(game);
const player = busy.readyPlayer({ busy: true });
busy.cache(50); busy.round(51);
assert.equal(busy.attempts.length, 1);
assert.equal(busy.shown.length, 0);
player.busy = false;
busy.round(52);
assert.equal(busy.attempts.length, 1, 'a skipped milestone cannot become a delayed celebration');
});
check(`${game} each client must be ready and thirty seconds past welcome or manual replay`, () => {
const e = roundRuntime(game);
const playing = e.readyPlayer();
const spectator = e.readyPlayer({ sessionstate: 'spectator' });
e.readyPlayer({ tws_lb_last_request: undefined });
e.readyPlayer({ tws_lb_last_request: e.now - 29999 });
for (const sessionstate of [undefined, 'connecting', 'dead'])
e.readyPlayer({ sessionstate });
e.cache(50); e.round(45);
assert.deepEqual(e.shown.map(notice => notice.player), [playing, spectator]);
assert.equal(e.context.lb_player_ready({ ...playing, tws_lb_last_request: e.now - 30000 }), true);
});
check(`${game} welcome attempts and manual requests reset the reminder cooldown`, () => {
const e = roundRuntime(game);
const player = e.readyPlayer({ tws_lb_last_request: undefined });
e.request(player, false);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 0, 'missing initial cache still releases the welcome readiness gate');
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1);
assert.equal(e.context.lb_player_ready(player), true);
e.cache(50); e.round(45);
e.request(player, true);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 1);
assert.equal(e.notifications[0].event, 'tws_lb_requested');
e.round(46);
assert.equal(e.shown.length, 1);
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1); e.round(47);
assert.deepEqual(e.shown.map(notice => notice.round), [45, 47]);
});
check(`${game} reminder readiness follows the engine's spectator/introscreen rules`, () => {
const e = roundRuntime(game), c = e.context;
const player = e.readyPlayer({ sessionstate: 'spectator', hasSpawned: false });
if (game === 't5') {
assert.equal(c.lb_player_ready(player), false);
player.hasSpawned = true;
assert.equal(c.lb_player_ready(player), true);
delete player.hasSpawned;
assert.equal(c.lb_player_ready(player), true);
} else {
assert.equal(c.lb_player_ready(player), true, 'T6 spectator newcomers keep hasSpawned false');
c.level.passed_introscreen = false;
assert.equal(c.lb_player_ready(player), false);
c.level.passed_introscreen = true;
assert.equal(c.lb_player_ready(player), true);
}
});
check(`${game} watcher waits for real starts and suppresses repeats, decreases and skipped starts`, () => {
const e = roundRuntime(game), observed = [];
e.context.lb_round_start = round => observed.push(round);
e.context.level.round_number = 30;
e.startWatcher();
assert.deepEqual(observed, [], 'loading mid-round must not synthesize an announcement');
e.context.level.round_number = 31;
assert.deepEqual(observed, [], 'a counter change before the actual start must not announce');
for (const round of [undefined, -1, 31, 31, 29, 30, 31, 34, 34]) e.startRoundEvent(round);
assert.deepEqual(observed, [31, 34], 'a skipped round emits only the newly observed start');
const fresh = roundRuntime(game), initial = [];
fresh.context.lb_round_start = round => initial.push(round);
fresh.startRoundEvent(0); fresh.startRoundEvent(1); fresh.startRoundEvent(1);
assert.deepEqual(initial, [1]);
assert(source.includes('level thread lb_round_watcher();'));
assert(!all.get('lb_round_watcher').includes('hud_round_changed'));
assert(!all.get('lb_round_watcher').includes('tws_t5_hud'));
assert(!all.get('lb_round_watcher').includes('tws_t6_hud'));
});
for (const event of ['end_game', 'game_ended', 'intermission'])
check(`${game} ${event} stops the independent round watcher`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.context.level.round_number = 44; e.startWatcher(); e.startRoundEvent(45);
assert.deepEqual(e.endEvents, ['end_game', 'game_ended', 'intermission']);
e.end(event); e.startRoundEvent(46);
assert.deepEqual(e.shown.map(notice => notice.round), [45]);
});
check(`${game} unicode and URL encoding`, () => {
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
@@ -288,7 +514,7 @@ check('paired data helpers stay identical', () => {
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
for (const [name, fn] of t5) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
assert.equal(normalized, fn, name);
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
template <typename F> void add(const char* name, F callback)
{
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
{
if constexpr (std::is_invocable_v<F>) return callback();
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
};
}
""")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
lines = result.stdout.splitlines()
for operation in ("load", "write", "remove"):
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+158
View File
@@ -0,0 +1,158 @@
#include "afk.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
using utils::afk::journal;
using utils::afk::parse_acknowledgement;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
std::string reserve(const std::string& id)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
}
std::string request(const std::string& id, const std::string& action)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
}
template <typename F> void must_fail(F fn)
{
bool failed = false;
try { fn(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const journal queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.prepare(reserve("pending"));
queue.store(request("pending-start","start"));
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
queue.prepare(reserve("active")); queue.authorize("active",120);
const auto now = journal::clock::now();
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
}
else if (mode == "recover")
{
// Separate process: the same match ID cannot recreate a live clock.
assert(queue.remaining("active") == -1);
must_fail([&]{queue.begin("active");});
queue.recover("match");
const auto values = queue.load();
assert(values.size() == 5);
int recovered = 0;
for (const auto& value : values)
{
if (value.action != "settle") continue;
auto body = nlohmann::json::parse(value.body);
assert(value.id.size() == 25);
if (body.at("session_id") == "active")
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
++recovered;
}
assert(recovered == 3);
queue.recover("new-match"); // Stable requests are unchanged across restarts.
assert(queue.load().size() == values.size());
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
for (const auto& value : values) queue.erase(value.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
assert(queue.load().size() == 1);
must_fail([&]{queue.store(request("one","event"));});
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
must_fail([&]{queue.store(changed.dump());});
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
assert(other_server.load().empty() && other_endpoint.load().empty());
queue.erase("one");
queue.store(request("first-event","event"));
auto snapshot = nlohmann::json::parse(request("second-event","event"));
snapshot["seq"] = 2; snapshot["revives"] = 2;
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
assert(queue.load().size() == 1);
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
must_fail([&]{queue.store(regressed.dump());});
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
queue.erase("second-event");
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
assert(queue.load().size() == 1);
must_fail([&]{queue.begin("normal");});
must_fail([&]{queue.authorize("normal",601);});
queue.authorize("normal",60); queue.authorize("normal",60);
must_fail([&]{queue.authorize("normal",120);});
const auto now = journal::clock::now();
queue.begin("normal",now); queue.begin("normal",now+20s);
assert(queue.remaining("normal",now+1250ms) == 58750);
assert(queue.remaining("normal",now+59999ms) == 1);
assert(queue.remaining("normal",now+60s) == 0);
assert(queue.remaining("normal",now+61s) == 0);
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
assert(queue.checkpoint("normal",now+59001ms) == 59);
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
auto settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
assert(queue.checkpoint("expire",now+60s) == 60);
queue.stop("expire","expired",now+61s);
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
queue.erase(queue.load()[0].id);
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
queue.erase(queue.load()[0].id);
// Interrupted temp writes preserve the prior durable snapshot.
const auto file = fs::directory_iterator(root)->path();
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
assert(queue.remaining("failure",now+30s) == 0);
must_fail([&]{queue.checkpoint("failure",now+30s);});
must_fail([&]{queue.begin("failure",now+30s);});
fs::remove(file.string()+".tmp");
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
assert(parse_acknowledgement(valid.dump()).valid);
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
fs::remove(file);
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
}
+35
View File
@@ -0,0 +1,35 @@
// Compile the actual GSC registration adapter against portable VM interfaces.
#include <fstream>
int main()
{
afk::component component; component.on_startup(nullptr);
const auto call = [](const std::string& name, std::vector<std::string> args = {})
{ return gsc::function::functions.at(name)(args); };
assert(call("afkversion").as<std::string>() == "1.0.0");
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
std::filesystem::path file;
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
if (entry.path().extension() == ".json") file=entry.path();
assert(!file.empty());
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
for (int i=0;i<2;++i)
{
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
}
std::filesystem::remove(file);
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
}
+10 -10
View File
@@ -1,22 +1,22 @@
{
"tws_t5_afk.gsc": "1.2.0",
"tws_t5_afk.gsc": "2.1.0",
"tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.4.0",
"tws_t5_hud.gsc": "1.4.0",
"tws_t5_common.gsc": "1.5.0",
"tws_t5_hud.gsc": "1.4.2",
"tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.1.0",
"tws_t5_leaderboard.gsc": "1.2.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.1.0",
"tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.4.0",
"tws_t6_hud.gsc": "1.4.0",
"tws_t6_common.gsc": "1.5.0",
"tws_t6_hud.gsc": "1.4.2",
"tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.1.0",
"tws_t6_leaderboard.gsc": "1.2.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"
}