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