Compare commits
5
Commits
d7bf16e9c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef5932d36 | ||
|
|
caaaa102a4 | ||
|
|
c19bef6dc6 | ||
|
|
6781a4ed5a | ||
|
|
7b95f604b4 |
+201
@@ -4,6 +4,207 @@ 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-18
|
||||||
|
|
||||||
|
### Add majority votes to lock and unlock the lobby
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_lock.gsc` | `1.1.0` | `t5/scripts/tws_t5_lock.gsc` |
|
||||||
|
| `tws_t6_lock.gsc` | `1.1.0` | `t6/scripts/tws_t6_lock.gsc` |
|
||||||
|
|
||||||
|
- Add exact, case-insensitive `.lock` (round 6 onward) and `.unlock` commands
|
||||||
|
with help registration. Both need a strict majority of all connected players:
|
||||||
|
1/1, 2/2, 2/3, 3/4, or `floor(players / 2) + 1` in larger lobbies. Solo
|
||||||
|
requests apply immediately; dead, spectating and AFK connections count.
|
||||||
|
- Use a 60-second ballot with one vote per connection, no extension on repeats,
|
||||||
|
and fresh votes after expiry or state changes. Recount joins/disconnects;
|
||||||
|
disconnected votes do not count, and reconnects do not inherit votes.
|
||||||
|
- Retain the configurable automatic lock at round 20, consumed once per match.
|
||||||
|
A successful unlock overrides it for the rest of the match, including after
|
||||||
|
an early manual lock. Players can still vote to relock. Automatic locking
|
||||||
|
clears any incomplete manual lock ballot. Generate a fresh password for each
|
||||||
|
lock and clear it and pending votes at startup and HUD/stock game over.
|
||||||
|
- Keep early locking optional: no round-6 prompt. Use ordinary message text
|
||||||
|
for vote progress, expiry and state changes, with private duplicate/unavailable
|
||||||
|
replies. Update help pagination coverage and both games' documentation.
|
||||||
|
- Compatibility: no new dependency requirement. Keep the matching common
|
||||||
|
`1.0.0+` and HUD `1.0.0+` modules installed; HUD still supplies round events.
|
||||||
|
T5 and T6 receive matching behavior using their existing namespaces and
|
||||||
|
initialization conventions. No common, HUD, native-plugin or upstream-pin
|
||||||
|
source changes are needed.
|
||||||
|
- Validation: 76 lock vote/lifecycle checks, 74 service HUD checks and 14 core
|
||||||
|
HUD checks passed with engine shims. The T6 lock script compiled and assembled
|
||||||
|
with upstream gsc-tool `8f1d6bd` libraries. T5 has no gsc-tool backend; live
|
||||||
|
Windows/Plutonium loading, message rendering and connection blocking still
|
||||||
|
need validation on both engines. No native-plugin build or deployment was
|
||||||
|
performed. See [lock voting and live checks](README.md#lobby-lock-voting-t5-and-t6).
|
||||||
|
- Deployment: copy the updated lock script for each installed game to its
|
||||||
|
existing script directory and restart the map. No full server-process restart
|
||||||
|
or additional deployment action is required for this script-only update.
|
||||||
|
|
||||||
|
## 2026-10-17
|
||||||
|
|
||||||
|
### Allow solo and last-active-player AFK
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` |
|
||||||
|
| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` |
|
||||||
|
|
||||||
|
- Remove the teammate requirement before reservation and after start
|
||||||
|
authorization on both engines. Solo players, the last active player and
|
||||||
|
players whose teammates are already AFK or awaiting authorization can enter.
|
||||||
|
- Remove the AFK team watcher that ended matches when no non-AFK survivor
|
||||||
|
remained. All players may be AFK together, and teammate death/disconnect
|
||||||
|
does not cancel protection. Keep reserve accounting, the local deadline,
|
||||||
|
cooldown, player-state checks and normal stock match-end settlement.
|
||||||
|
- Compatibility: no new dependency requirement. Existing common `1.4.0+`,
|
||||||
|
telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and
|
||||||
|
host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same
|
||||||
|
policy change using their existing engine-specific state checks.
|
||||||
|
- Validation: 47 AFK script behavior checks passed, including solo entry,
|
||||||
|
unavailable/pending teammates, teammate loss during authorization, all-AFK
|
||||||
|
play, solo return/expiration and stock match-end cleanup. The changed T6
|
||||||
|
script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries.
|
||||||
|
Portable checks use engine shims; live T5/T6 validation remains required.
|
||||||
|
No Windows plugin build, live server test or deployment was performed.
|
||||||
|
- Deployment: copy the updated AFK script for each installed game and restart
|
||||||
|
the map. No full server-process restart or additional deployment action is
|
||||||
|
required for this script-only update. See the [AFK guide](docs/afk.md) for
|
||||||
|
the updated live validation cases.
|
||||||
|
|
||||||
|
### Increase map-record reminders as players approach the target
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
|
||||||
|
| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
|
||||||
|
| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` |
|
||||||
|
| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` |
|
||||||
|
|
||||||
|
- Add matching T5/T6 round-start reminders using the latest usable cached map
|
||||||
|
record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the
|
||||||
|
final five. Show matching/overtake milestones, then stop reminders after the
|
||||||
|
first overtake for the rest of the match. Milestones distinguish reaching a round
|
||||||
|
from saving the result after match completion; `.leaders` remains available.
|
||||||
|
Listen to the stock `start_of_round` event so reminders follow the actual
|
||||||
|
start, including on T6 where the counter advances during the round break.
|
||||||
|
Ignore duplicate starts and counter rewinds within the match.
|
||||||
|
- Common renders a compact record header and one proximity/milestone line for
|
||||||
|
six seconds, using two nonarchived elements in the existing service area.
|
||||||
|
Keep full join/`.leaders` announcements, all tied holders and 12-second pages.
|
||||||
|
Skip pending or active help/records, unready players and recent welcome/manual
|
||||||
|
record requests; opening help discards a reminder. Release elements on idle,
|
||||||
|
interruption, disconnect and game end. Both games work without optional HUD.
|
||||||
|
- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or
|
||||||
|
expired records suppress reminders; skipped reminders are not queued.
|
||||||
|
- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`.
|
||||||
|
There is no new native-plugin, backend or upstream dependency requirement.
|
||||||
|
Unchanged HUD, AFK and other scripts retain their individual versions.
|
||||||
|
- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join,
|
||||||
|
23 telemetry and 39 AFK portable checks passed, as did both moderation
|
||||||
|
script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool
|
||||||
|
`8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked
|
||||||
|
after the event-timing fix. JSON uniqueness, all 18 script manifest entries,
|
||||||
|
version/changelog consistency, local documentation links and `git diff --check`
|
||||||
|
passed. Portable checks use engine shims; no live Plutonium validation or
|
||||||
|
backend integration was performed. T5 has no gsc-tool compiler backend.
|
||||||
|
- Deployment: copy the updated leaderboard and common scripts together for
|
||||||
|
each installed game, then restart the map. No full server-process restart is
|
||||||
|
required for this script-only update. No deployment performed. Live T5/T6
|
||||||
|
rendering, readiness and round-transition validation remain required; see
|
||||||
|
[record validation](README.md#map-record-announcements).
|
||||||
|
|
||||||
|
### Persistent AFK reserve and immediate expiration (Refs #12)
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_afk.gsc` | `2.0.0` | `t5/scripts/tws_t5_afk.gsc` |
|
||||||
|
| `tws_t6_afk.gsc` | `2.0.0` | `t6/scripts/tws_t6_afk.gsc` |
|
||||||
|
| `t5-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
|
||||||
|
| `t6-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
|
||||||
|
|
||||||
|
- Replace unlimited AFK with shared GUID reserves, authenticated authorization,
|
||||||
|
explicit bank purchase confirmation, real contribution rewards and durable
|
||||||
|
request/session journals. Both engines use matching contracts and behavior.
|
||||||
|
- Show an independent central countdown, final-minute urgency and audible warnings.
|
||||||
|
Zero immediately releases AFK protection and owned controls/weapons. Manual
|
||||||
|
`.back` retains its separate two-second grace; it cannot renew an AFK session.
|
||||||
|
Reject downed activation (including T5), stock restrictions and last-active-player
|
||||||
|
entry. End the match when only AFK players remain, respecting stock recovery.
|
||||||
|
- Award each successful revive, including repeated revives of one teammate. Count
|
||||||
|
kill/headshot events, round participation and active engagement without using
|
||||||
|
score changes. EC owns configurable rates, caps, persistent progress and atomic
|
||||||
|
bank conversion; no second points bank is introduced.
|
||||||
|
- Compatibility: AFK `2.0.0` requires matching TWS plugin `1.4.0+` (native AFK API
|
||||||
|
`1.0.0`), common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+ and the EC #151
|
||||||
|
endpoints. EC host agent `1.12.0+` must be deployed before activation. Existing
|
||||||
|
private `tws_bank_token` and `tws_server_id` authenticate requests. There is no
|
||||||
|
new upstream dependency pin or native loader `plugin_version()` requirement.
|
||||||
|
- Deployment: deploy EC and upgraded host agents first; build/install both plugin
|
||||||
|
DLLs and matching scripts, retain persistent `tws-afk` storage, then **fully
|
||||||
|
restart each game-server process**. No deployment, Windows DLL build or live
|
||||||
|
Plutonium validation is claimed. See [AFK operation and validation](docs/afk.md).
|
||||||
|
- Validation: both engines pass native AFK journal/adapter tests with ASan/UBSan,
|
||||||
|
39 AFK behavior checks, 14 core HUD and 56 service HUD checks. All nine T6 scripts
|
||||||
|
compiled/assembled with upstream gsc-tool `8f1d6bd` T6 PC compiler/assembler
|
||||||
|
libraries. Script and integration checks are documented in the AFK guide; final
|
||||||
|
in-game T5/T6 validation remains required. Unchanged common/HUD/bank/telemetry
|
||||||
|
scripts retain their individual versions.
|
||||||
|
|
||||||
|
### Center the complete zombie count above the health bar
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_hud.gsc` | `1.4.2` | `t5/scripts/tws_t5_hud.gsc` |
|
||||||
|
| `tws_t6_hud.gsc` | `1.4.2` | `t6/scripts/tws_t6_hud.gsc` |
|
||||||
|
|
||||||
|
- Replace the separately positioned number and label with one center-aligned
|
||||||
|
`N ZOMBIES` readout at the health track's midpoint in both engines. Use a
|
||||||
|
uniform font scale of `1.25` and the existing count color; digit changes and
|
||||||
|
the initial `-- ZOMBIES` placeholder share the same center.
|
||||||
|
- Update text only when the count changes using `SetTextUnlimited`, avoiding
|
||||||
|
a new config string for each distinct count. Keep counting, `.hud` visibility,
|
||||||
|
game-over termination and disconnect cleanup behavior. Core HUD allocation
|
||||||
|
decreases from 11 to 10 archived elements; AFK still adds one.
|
||||||
|
- Compatibility: HUD `1.4.2` requires matching common `1.4.0+` and
|
||||||
|
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334), which provides
|
||||||
|
`SetTextUnlimited` for T5 and T6. No new plugin or backend requirement.
|
||||||
|
Common and AFK need no source change for this fix; both HUD scripts change.
|
||||||
|
- Validation: 73 HUD checks passed, including digit transitions, a non-default
|
||||||
|
track midpoint, unchanged-count polling, hidden updates and service cleanup.
|
||||||
|
Leaderboard (143), bank (42), join (17) and telemetry (23) portable checks
|
||||||
|
passed. These use engine shims; actual EC integration and live Plutonium
|
||||||
|
validation were not run. GSC compilation was unavailable because gsc-tool
|
||||||
|
and PowerShell are not installed. Verify rendering on Windows/Plutonium.
|
||||||
|
- Deployment: copy each updated HUD script and restart the map on r5334+.
|
||||||
|
Older installations must first update Plutonium on clients and servers and
|
||||||
|
fully restart those processes. No full server-process restart is required
|
||||||
|
for the script update alone. No deployment performed.
|
||||||
|
|
||||||
|
### Use ASCII separators in HUD branding and map records
|
||||||
|
|
||||||
|
| Component | Version | Affected source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_t5_common.gsc` | `1.4.1` | `t5/scripts/tws_t5_common.gsc` |
|
||||||
|
| `tws_t6_common.gsc` | `1.4.1` | `t6/scripts/tws_t6_common.gsc` |
|
||||||
|
| `tws_t5_hud.gsc` | `1.4.1` | `t5/scripts/tws_t5_hud.gsc` |
|
||||||
|
| `tws_t6_hud.gsc` | `1.4.1` | `t6/scripts/tws_t6_hud.gsc` |
|
||||||
|
|
||||||
|
- Replace the middle dot with an ASCII vertical bar in the top-center branding
|
||||||
|
and map-record heading after the separator displayed as two dots on T5.
|
||||||
|
Apply the same text change to T6 for a consistent appearance. Keep existing
|
||||||
|
spacing, positioning and HUD/service behavior.
|
||||||
|
- Compatibility: no new dependency requirement. No native-plugin, backend or
|
||||||
|
other script changes are required.
|
||||||
|
- Validation: 71 HUD, 143 leaderboard, 42 bank, 17 join and 23 telemetry portable
|
||||||
|
checks passed. These use engine shims; actual EC bank integration, GSC
|
||||||
|
compilation and live Windows/Plutonium validation were not performed.
|
||||||
|
- Deployment: copy the updated common and HUD scripts for each installed game,
|
||||||
|
then restart the map. No full server-process restart is required. No deployment
|
||||||
|
performed; live Windows/Plutonium rendering validation remains outstanding.
|
||||||
|
|
||||||
## 2026-10-15 — Player moderation runtime
|
## 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
|
||||||
|
|||||||
@@ -173,6 +173,59 @@ Modules currently in each tree:
|
|||||||
| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank |
|
| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank |
|
||||||
| leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` |
|
| leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` |
|
||||||
|
|
||||||
|
## Lobby lock voting (T5 and T6)
|
||||||
|
|
||||||
|
Lock scripts `1.1.0` add exact, case-insensitive `.lock` and `.unlock` chat
|
||||||
|
commands, also listed in `.help` when the lock module is installed. `.lock`
|
||||||
|
is available **after round 5 (round 6 onward)**; `.unlock` is available whenever
|
||||||
|
the server is locked. Both require a strict majority of the current connected
|
||||||
|
players: 1 of 1, 2 of 2, 2 of 3, or 3 of 4. Dead, spectating and AFK players
|
||||||
|
count equally. Larger lobbies use `floor(players / 2) + 1`.
|
||||||
|
|
||||||
|
Typing the command starts a 60-second vote and casts your vote; teammates type
|
||||||
|
the same command to support it. Solo commands apply immediately. Each connection
|
||||||
|
gets one vote per ballot. Repeats neither add votes nor extend the deadline.
|
||||||
|
Joins and disconnects update the majority; disconnected votes are discarded,
|
||||||
|
and reconnecting does not restore a vote. A smaller roster can pass an ongoing
|
||||||
|
vote with the remaining votes. An expired vote needs fresh votes from everyone.
|
||||||
|
Typing the command for the current state only replies privately; it does not
|
||||||
|
withdraw a vote or cancel an ongoing ballot.
|
||||||
|
|
||||||
|
Automatic locking remains at `tws_lock_round` (default `20`) and happens at
|
||||||
|
most once per match. A successful `.unlock` vote keeps the lobby open for the
|
||||||
|
rest of that match, including when players lock and unlock before round 20.
|
||||||
|
Players can still vote `.lock` again. An `.unlock` request while already open
|
||||||
|
does not disable the automatic lock. Automatic locking clears any unfinished
|
||||||
|
manual lock ballot. Each activation generates a fresh `g_password`; existing
|
||||||
|
players remain connected. Startup and game over clear the password and votes.
|
||||||
|
|
||||||
|
There is no automatic prompt at round 6. Vote progress and state changes use
|
||||||
|
ordinary message text, with private replies for unavailable/repeated commands;
|
||||||
|
no center-screen popup or recurring reminder is added.
|
||||||
|
|
||||||
|
Compatibility: no new dependency requirement; keep the matching common and HUD
|
||||||
|
modules installed (common `1.0.0+`, HUD `1.0.0+`). HUD supplies round notifications.
|
||||||
|
Copy each updated lock script to its existing game script directory and restart
|
||||||
|
the map. No native plugin update, full server-process restart or other deployment
|
||||||
|
action is required.
|
||||||
|
|
||||||
|
Run `node tools/check_lock.mjs` and `node tools/check_hud.mjs` for portable vote,
|
||||||
|
lifecycle and help pagination checks. Compile the T6 script using the
|
||||||
|
[T6 compiler check](t6/scripts/README.md#validation). Before rollout, verify on
|
||||||
|
live T5 and T6 servers:
|
||||||
|
|
||||||
|
1. Try `.lock` in rounds 5 and 6, then both commands with 1–4 players. Confirm
|
||||||
|
the exact majority, instant solo behavior, private duplicate replies and
|
||||||
|
ordinary vote text. Check both commands in `.help` and `.commands`.
|
||||||
|
2. Let a vote expire, then retry; disconnect a voter, disconnect a non-voter,
|
||||||
|
reconnect and join mid-vote. Verify the current roster and fresh votes count.
|
||||||
|
3. Allow round-20 auto-lock, unlock, advance another round and relock. Also
|
||||||
|
lock/unlock early and cross round 20. Confirm a new client is blocked when
|
||||||
|
locked and can join when unlocked; connected players stay in the game.
|
||||||
|
4. End the match with a lock or pending vote, then restart the map. Confirm
|
||||||
|
the next lobby is open and old votes cannot change it. Check a custom
|
||||||
|
`tws_lock_round` and confirm it does not change the round-6 manual gate.
|
||||||
|
|
||||||
## Player bank (T5 and T6)
|
## Player bank (T5 and T6)
|
||||||
|
|
||||||
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and
|
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and
|
||||||
@@ -362,11 +415,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 +439,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,9 +447,10 @@ 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`, plus registered commands such as `.lock` and `.unlock`.
|
||||||
|
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
|
||||||
restriction. Additional registered commands use pages of nine rows; a page
|
restriction. Additional registered commands use pages of nine rows; a page
|
||||||
count appears only when more than one page is needed. Each page lasts
|
count appears only when more than one page is needed. Each page lasts
|
||||||
@@ -401,35 +458,45 @@ 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
|
||||||
page time when help ends. `.leaders` during help replaces the pending record
|
page time when help ends. `.leaders` during help replaces the pending record
|
||||||
with a fresh replay from page one. One service is visible at a time. Services
|
with a fresh replay from page one. Round-start reminders use just the record
|
||||||
remain private and work when the HUD module is omitted.
|
header and one proximity or milestone line for six seconds. They skip busy
|
||||||
|
services; opening help discards a reminder instead of resuming it later.
|
||||||
|
One service is visible at a time. Services remain private and work when the
|
||||||
|
HUD module is omitted.
|
||||||
|
|
||||||
Both games allocate only the active service view and release it when idle or
|
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. Compact reminders use two nonarchived elements.
|
||||||
|
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.2.0` requires matching common `1.5.0+` for round-start reminders.
|
||||||
|
AFK `2.0.0` requires plugin `1.4.0+`,
|
||||||
|
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
|
||||||
|
leaderboard plugin requirements still apply. Common retains its older panel
|
||||||
|
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
|
||||||
|
with the same integer shader-width handling.
|
||||||
|
|
||||||
### HUD validation
|
### HUD validation
|
||||||
|
|
||||||
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
|
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
|
||||||
request replacement and cleanup checks, and `node tools/check_leaderboard.mjs`
|
reminder rendering, request replacement and cleanup checks, and
|
||||||
for record data handling. Run `node tools/check_bank.mjs`,
|
`node tools/check_leaderboard.mjs` for record data handling and reminder cadence.
|
||||||
|
Run `node tools/check_bank.mjs`,
|
||||||
`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged
|
`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged
|
||||||
command/lifecycle regressions. These
|
command/lifecycle regressions. These
|
||||||
checks use engine shims and do not prove GSC VM behavior. Follow the
|
checks use engine shims and do not prove GSC VM behavior. Follow the
|
||||||
@@ -445,14 +512,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 +535,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
|
||||||
@@ -479,7 +548,10 @@ Before rollout, validate both engines in Plutonium:
|
|||||||
## Map record announcements
|
## Map record announcements
|
||||||
|
|
||||||
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
|
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
|
||||||
**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`.
|
**along with common `1.5.0+`** for that game when using leaderboard `1.2.0`.
|
||||||
|
Copy both scripts for each installed game and restart the map. This update
|
||||||
|
adds no native-plugin or backend requirement and needs no full server-process
|
||||||
|
restart when existing leaderboard dependencies are already installed.
|
||||||
The module privately announces the current map's fleet-wide highest recorded
|
The module privately announces the current map's fleet-wide highest recorded
|
||||||
round and every tied holder once per connection, including spectator newcomers.
|
round and every tied holder once per connection, including spectator newcomers.
|
||||||
Type `.leaders` to replay the record privately from the latest usable cache.
|
Type `.leaders` to replay the record privately from the latest usable cache.
|
||||||
@@ -509,7 +581,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
|
||||||
@@ -521,6 +593,32 @@ the announcement finishes. Both games release their view elements when idle or
|
|||||||
changing service kinds. Disconnect or game end cleans up any remaining service
|
changing service kinds. Disconnect or game end cleans up any remaining service
|
||||||
elements.
|
elements.
|
||||||
|
|
||||||
|
At round start, reminders become more frequent as the current round approaches
|
||||||
|
the latest usable cached record. The cadence is measured back from that record:
|
||||||
|
|
||||||
|
| Rounds below the record | Reminder rounds |
|
||||||
|
| --- | --- |
|
||||||
|
| More than 20 | None |
|
||||||
|
| 11–20 | At gaps of 20 and 15 rounds |
|
||||||
|
| 6–10 | At gaps of 10, 8 and 6 rounds |
|
||||||
|
| 1–5 | Every round |
|
||||||
|
| Record reached | Matching-round milestone |
|
||||||
|
| First round above the record | Overtake milestone, then stop for the match |
|
||||||
|
|
||||||
|
For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and
|
||||||
|
45–49, with milestones at 50 and 51. Each uses the existing record header and
|
||||||
|
one short proximity or milestone line for six seconds, without holder pages.
|
||||||
|
Milestones describe the round reached; the new result is recorded only after
|
||||||
|
the match ends. The full join announcement and `.leaders` retain all holders
|
||||||
|
and their 12-second pages. `.leaders` remains available after the overtake.
|
||||||
|
|
||||||
|
Reminders require player readiness and skip players with a pending welcome,
|
||||||
|
help or record view, or a welcome/manual record request in the last 30 seconds.
|
||||||
|
Skipped reminders are not queued. Opening help discards an active reminder.
|
||||||
|
Empty, unavailable and expired snapshots produce no reminders. The existing
|
||||||
|
cache worker supplies all record data; round starts do not trigger HTTP requests.
|
||||||
|
Both games use the same behavior, including with the optional HUD omitted.
|
||||||
|
|
||||||
One level worker fetches the map list and pages of three players, stopping when
|
One level worker fetches the map list and pages of three players, stopping when
|
||||||
the highest-round tie ends. Successful snapshots refresh every five minutes.
|
the highest-round tie ends. Successful snapshots refresh every five minutes.
|
||||||
Failures retry after 30 seconds and retain the last complete snapshot for up to
|
Failures retry after 30 seconds and retain the last complete snapshot for up to
|
||||||
@@ -553,6 +651,19 @@ page: each should start one private announcement from the first page. Repeat
|
|||||||
while help is open, then disconnect or end the game during a replay to check
|
while help is open, then disconnect or end the game during a replay to check
|
||||||
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
|
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
|
||||||
|
|
||||||
|
For reminders, use a known record on each engine and advance through all cadence
|
||||||
|
boundaries, the matching round and the first higher round. Confirm later rounds
|
||||||
|
remain quiet while `.leaders` still works, including after a cache refresh.
|
||||||
|
Check a record not divisible by five to verify the cadence follows the record.
|
||||||
|
Keep help or a full record open at an eligible round, join immediately before
|
||||||
|
that round, and request `.leaders` within 30 seconds of it; no reminder should
|
||||||
|
replace those views or appear later as a queued message. Open help during a
|
||||||
|
reminder and confirm it does not resume. Verify six-second dismissal, two-element
|
||||||
|
cleanup on disconnect/game end, and the same behavior without HUD installed.
|
||||||
|
Repeat with an empty or expired cache and compare request logs: eligible rounds
|
||||||
|
must add no HTTP traffic. Live T5/T6 rendering and round-transition validation
|
||||||
|
remain required even when portable checks and T6 compilation pass.
|
||||||
|
|
||||||
## Telemetry authentication (T5 and T6)
|
## Telemetry authentication (T5 and T6)
|
||||||
|
|
||||||
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
|
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
|
||||||
@@ -600,3 +711,14 @@ policy persistence, command-result limits and deployment validation. New
|
|||||||
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
|
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.1.0`
|
||||||
|
requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`,
|
||||||
|
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
|
||||||
|
is required when installing the updated plugins.
|
||||||
|
Solo and last-active-player AFK are allowed, including all players being AFK
|
||||||
|
together. Upgrading only the AFK scripts from `2.0.0` requires a map restart,
|
||||||
|
with no new dependencies or full server-process restart.
|
||||||
|
|||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
# Persistent AFK reserve
|
||||||
|
|
||||||
|
AFK `2.0.0` replaces unlimited protected breaks on both T5 and T6. A GUID owns
|
||||||
|
one free allowance and saved reserve across servers, games, maps and reconnects.
|
||||||
|
Electric Cherry owns balances, policy, purchase transactions and the time ledger.
|
||||||
|
The game owns verified contribution events, player commands, countdown rendering,
|
||||||
|
protection and accepted stop decisions. This uses the existing bank account and
|
||||||
|
private instance credential, not a second points bank.
|
||||||
|
|
||||||
|
## Commands and behavior
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `.afk` | Reserve available time, acknowledge its start, then enter protected AFK |
|
||||||
|
| `.back` | Stop time accounting and return, preserving the existing two-second manual grace |
|
||||||
|
| `.afk balance` | Show free, saved and currently reserved seconds as a clock |
|
||||||
|
| `.afk buy 10` | Quote ten minutes and its bank-point price |
|
||||||
|
| `.afk buy 10 confirm` | Accept that quote within 30 seconds |
|
||||||
|
|
||||||
|
Return with `.back` before purchasing; an existing reservation never extends
|
||||||
|
automatically. Purchases require whole policy packs; the initial pack is 10 minutes for 10,000
|
||||||
|
points. There is no automatic purchase. EC atomically rejects insufficient funds,
|
||||||
|
pending bank transfers, changed quotes and over-capacity purchases without a
|
||||||
|
point debit. A submitted purchase retains its ID until EC acknowledges it.
|
||||||
|
|
||||||
|
The central countdown and “Protection ends when this timer reaches zero.” remain
|
||||||
|
visible even with `.hud` hidden. The final minute is larger and red; warnings
|
||||||
|
sound at 60, 30, and each of the final ten seconds. At zero, AFK protection ends
|
||||||
|
immediately, including in the damage callback; the script restores its own controls,
|
||||||
|
weapons and zombie-targeting state. Expiration has no spectator transition, purchase
|
||||||
|
or extra grace. Repeating `.afk` does not restart the timer or reserve twice.
|
||||||
|
|
||||||
|
AFK `2.1.0` allows solo players and the last active player to enter; all players
|
||||||
|
may be AFK at the same time. Entry still rejects downed/dead/spectating players
|
||||||
|
and stock protection/control restrictions. A default 15-second reentry cooldown prevents repeated
|
||||||
|
combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage
|
||||||
|
callback passes through when it does not own protection, preserving stock game
|
||||||
|
invulnerability. Other mods that restrict controls should set
|
||||||
|
`player.tws_afk_external_controls` while their restriction applies.
|
||||||
|
|
||||||
|
AFK leaves player session state unchanged, so stock player-count zombie scaling
|
||||||
|
continues to include AFK players. Losing the last non-AFK teammate does not end
|
||||||
|
protected breaks or force a match end. Authorized time continues to count down;
|
||||||
|
normal expiration and stock match-end cleanup still apply. Verify these rules
|
||||||
|
on each deployed map/mode.
|
||||||
|
|
||||||
|
## Rewards
|
||||||
|
|
||||||
|
Initial EC policy:
|
||||||
|
|
||||||
|
| Source | Time |
|
||||||
|
| --- | --- |
|
||||||
|
| Free allowance | 30 minutes per rolling 24 hours |
|
||||||
|
| Five minutes of active play | 1 minute |
|
||||||
|
| Completed round with participation | 15 seconds |
|
||||||
|
| 100 kills | 30 seconds |
|
||||||
|
| 25 headshot kills | 15 seconds |
|
||||||
|
| Each successful teammate revive | 30 seconds |
|
||||||
|
|
||||||
|
Saved earned and purchased time has a combined six-hour capacity and no expiry.
|
||||||
|
Free time is separate, non-accumulating and consumed first. EC retains fractional
|
||||||
|
reward progress across reconnects. Round/kill/headshot rewards share eight minutes
|
||||||
|
per rolling active hour; headshots stack with kills. Active-play rewards and
|
||||||
|
**every successful revive** are outside that cap. There is no per-teammate,
|
||||||
|
per-match or hourly revive limit. Retrying a report cannot award it again.
|
||||||
|
|
||||||
|
Both engines consume stock `zom_kill`, `player_revived` and `end_of_round`
|
||||||
|
notifications. T5 also attaches to stock hellhound spawn/death callbacks because
|
||||||
|
those enemies omit `zom_kill`; T6 hellhounds already emit the stock zombie death
|
||||||
|
event. Completed round numbers are deduplicated locally. Headshots require a lethal head/helmet hit with a qualifying damage
|
||||||
|
type. No reward uses spendable score or `score_total`. A round requires a kill,
|
||||||
|
revive, or at least 30 seconds of active engagement and eligibility at completion.
|
||||||
|
|
||||||
|
The one-second activity sampler uses movement over four units, a view change over
|
||||||
|
two degrees or a changed input button to refresh a 15-second engagement interval.
|
||||||
|
The stock `is_reviving` state counts support; holding a zombie that targets
|
||||||
|
the player within 512 units counts while deliberate input occurred in the last
|
||||||
|
60 seconds. Kill/revive events also refresh engagement. AFK, dead and spectator
|
||||||
|
time do not earn rewards. Contributions after manual `.back` count immediately,
|
||||||
|
including during its separate two-second grace. This permits support and zombie
|
||||||
|
holding without a minimum kill rate, while passive connection alone earns nothing.
|
||||||
|
|
||||||
|
Stock integration was checked against
|
||||||
|
[T5 scripts 70689b8](https://github.com/plutoniummod/t5-scripts/tree/70689b8288e60079d48926657e670c41aab4abdf)
|
||||||
|
and [T6 scripts 2d84403](https://github.com/plutoniummod/t6-scripts/tree/2d8440321602f22f63f5335817110d60ddf223d5).
|
||||||
|
Recheck callback contracts if the stock runtime changes.
|
||||||
|
|
||||||
|
## Configuration and coordinated rollout
|
||||||
|
|
||||||
|
1. Back up EC and the game runtime journals. Deploy EC #151 and restart its service.
|
||||||
|
2. Update all EC host agents to **1.12.0+** before enabling these scripts. This
|
||||||
|
adds AFK settlement before deleting/rebuilding stopped runtime storage.
|
||||||
|
3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK
|
||||||
|
**2.1.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and
|
||||||
|
Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes.
|
||||||
|
4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must
|
||||||
|
establish the current match before AFK commands can be authorized.
|
||||||
|
5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory,
|
||||||
|
on persistent writable storage with one process per server/endpoint scope.
|
||||||
|
|
||||||
|
Upgrading AFK scripts from `2.0.0` to `2.1.0` adds no dependency requirement:
|
||||||
|
copy the matching script for each game and restart the map. No full server-process
|
||||||
|
restart is required for this script-only update.
|
||||||
|
|
||||||
|
| Game dvar | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tws_afk_enabled` | enabled | `0` disables new AFK service startup |
|
||||||
|
| `tws_afk_cooldown_seconds` | `15` | Reentry cooldown, clamped to 2–300 seconds |
|
||||||
|
| `tws_afk_warning_sound` | `zmb_perks_packa_ready` | Local warning sound alias; verify on deployed maps |
|
||||||
|
|
||||||
|
Economic configuration lives in EC's `EC_AFK_*` environment settings. See the
|
||||||
|
[EC AFK guide](https://gitea.thewrightserver.net/josh/electric-cherry/src/branch/main/docs/guides/afk.md)
|
||||||
|
for exact rates, rolling-window accounting and JSON schemas. The fixed game
|
||||||
|
endpoint is `https://ec.thewrightserver.net/api/afk`; requests reuse the private
|
||||||
|
`ec_bank_` credential. Shared telemetry tokens cannot spend or reserve time.
|
||||||
|
|
||||||
|
## API and recovery ownership
|
||||||
|
|
||||||
|
The matching native API `1.0.0` exposes `afkVersion`, `afkResponse`, `afkStore`,
|
||||||
|
`afkLoad`, `afkRemove`, `afkPrepare`, `afkAuthorize`, `afkBegin`, `afkRemaining`,
|
||||||
|
`afkCheckpoint`, `afkStop`, and `afkRecover`. Responses must validate their complete
|
||||||
|
schema and request/session identity before authorization takes effect. Requests
|
||||||
|
are immutable; journal writes precede network transmission and native activation.
|
||||||
|
|
||||||
|
EC actions are `balance`, `event`, `purchase`, `reserve`, `start` and `settle`.
|
||||||
|
Events carry cumulative counters scoped to GUID, instance and match, with a
|
||||||
|
monotonic sequence. The native journal coalesces newer cumulative event snapshots
|
||||||
|
to bound outage queues; EC prevents retries and reordered reports awarding twice.
|
||||||
|
Revive counts still increase for every distinct successful revive. Kill/active
|
||||||
|
progress is flushed at most every 30 seconds and on normal disconnect/end; a hard
|
||||||
|
process crash can lose progress that had not yet reached the local journal.
|
||||||
|
Already accepted rewards and journaled snapshots persist.
|
||||||
|
|
||||||
|
Protection starts only after reservation and start acknowledgments, a final
|
||||||
|
player-state recheck, and a durable native clock start. Once authorized, an EC
|
||||||
|
outage cannot shorten protection; a local monotonic deadline still ends it at zero.
|
||||||
|
Manual return, disconnect, death and match end stop accounting immediately. A
|
||||||
|
failed stop-journal write freezes the original stop time for retry rather than
|
||||||
|
charging during the delay. Never replay a reserve/start from a background sender.
|
||||||
|
|
||||||
|
Before activation, an interrupted session recovers as `never_started` with zero
|
||||||
|
consumption. Its tombstone also blocks delayed reserve/start delivery. After
|
||||||
|
activation, a crash with no durable stop becomes `unresolved`, preserving its last
|
||||||
|
confirmed checkpoint and full reservation. Absence of a heartbeat never releases
|
||||||
|
spendable time. An administrator must reconcile such a stopped session using EC's
|
||||||
|
audited correction endpoint; corrected charges remain distinct from exact durations.
|
||||||
|
Do not delete journals or change IDs to unblock a hold.
|
||||||
|
|
||||||
|
The EC agent independently proves the old process is stopped before recovering
|
||||||
|
journals during remove/rebuild/cancel-create. It sends requests only to its enrolled
|
||||||
|
controller using the assigned teardown operation and host credential. Lost replies,
|
||||||
|
unrecognized endpoints or malformed journals preserve runtime storage for retry.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Portable checks (engine shims are not live Plutonium):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tools/check_afk.mjs
|
||||||
|
python3 tools/test_plugin_afk.py --json-header /path/to/pinned/json.hpp
|
||||||
|
node tools/check_bank.mjs
|
||||||
|
node tools/check_hud.mjs
|
||||||
|
node tools/check_telemetry.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Use each plugin's pinned JSON header; the native harness also accepts a path with
|
||||||
|
`{game}` for different T5/T6 dependency revisions. It compiles native utilities
|
||||||
|
and actual registration adapters with ASan/UBSan, then exercises separate-process
|
||||||
|
journal recovery. T6 source compilation follows its [script instructions](../t6/scripts/README.md#validation).
|
||||||
|
T5 linking and behavior require a live server.
|
||||||
|
|
||||||
|
Before production activation, validate **both engines**:
|
||||||
|
|
||||||
|
- Enter/return/expire, reconnect, die, change maps and restart the process; compare
|
||||||
|
the displayed countdown and EC balances/ledger. At expiry attempt moving, firing,
|
||||||
|
throwing equipment and receiving zombie damage immediately, including `.hud` off.
|
||||||
|
- Try T5 entry while downed, then after a successful revive. Check T6 laststand,
|
||||||
|
afterlife, intermission and stock control restrictions; no stock protection should
|
||||||
|
be removed. Repeat rapid `.afk`/`.back` and concurrent commands during slow replies.
|
||||||
|
- Earn each reward; revive the same teammate repeatedly beyond the performance cap.
|
||||||
|
Verify passive, spectating, AFK and bank point changes earn nothing. Test support
|
||||||
|
and zombie holding. Carry partial progress across maps/servers/T5/T6.
|
||||||
|
- Confirm the displayed purchase price; try expired/changed quotes, insufficient
|
||||||
|
bank funds, pending bank transfers and capacity overflow. Drop purchase replies
|
||||||
|
and retry without a second debit. Attempt simultaneous reservations on two servers.
|
||||||
|
- Disconnect EC after authorization; the countdown must continue and expire locally.
|
||||||
|
Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and
|
||||||
|
restart. Verify never-started refunds, durable exact stops and unconfirmed crash
|
||||||
|
holds. Correct only a proven stopped session and confirm the ledger audit.
|
||||||
|
- Enter as the only connected player and as the last active player with downed,
|
||||||
|
dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the
|
||||||
|
last active teammate, including while reserve/start replies are pending; verify
|
||||||
|
protection continues without a forced match end. Check solo return/expiration,
|
||||||
|
normal stock match-end settlement, afterlife/Grief recovery and zombie scaling.
|
||||||
|
- Rebuild/remove with journals from both games, retire the instance credential,
|
||||||
|
interrupt the agent after a lost reply, and retry. Runtime deletion must wait for
|
||||||
|
acknowledged recovery and cannot authorize a new purchase.
|
||||||
|
|
||||||
|
Validation performed for the initial `2.0.0` change: 39 AFK behavior checks, 14 core HUD and
|
||||||
|
56 service HUD checks passed. All nine T6 scripts compiled and assembled with
|
||||||
|
the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306)
|
||||||
|
using its T6 PC compiler/assembler library in a temporary local driver. No Windows
|
||||||
|
DLL build, production deployment or live T5/T6 validation is implied by these checks.
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
+25
-14
@@ -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
|
||||||
|
|
||||||
@@ -125,6 +136,6 @@ do not replace T5 live loading, layout and lifecycle validation.
|
|||||||
| `tws_t5_hud` | Unboxed core overlays and private `.hud` visibility toggle | `hud_round_changed`, `hud_match_started`, `hud_game_over` | `say`, internal notifications |
|
| `tws_t5_hud` | Unboxed core overlays and private `.hud` visibility toggle | `hud_round_changed`, `hud_match_started`, `hud_game_over` | `say`, internal notifications |
|
||||||
| `tws_t5_afk` | `.afk` / `.back` chat toggle, owns its [AFK] indicator | — | — |
|
| `tws_t5_afk` | `.afk` / `.back` chat toggle, owns its [AFK] indicator | — | — |
|
||||||
| `tws_t5_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` |
|
| `tws_t5_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` |
|
||||||
| `tws_t5_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` |
|
| `tws_t5_lock` | Automatic `g_password` lock (`tws_lock_round`, default 20) plus majority `.lock` / `.unlock` votes | `tws_lock_game_over` (internal) | `say`, `hud_round_changed`, `hud_game_over`, `end_game`, `game_ended`, `intermission` |
|
||||||
| `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` |
|
| `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` |
|
||||||
| `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
|
| `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
|
||||||
|
|||||||
+91
-60
@@ -12,11 +12,11 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, paginated `.help`, service window, connect/spawn registries, single `connected` listener + dispatcher |
|
| [tws_t5_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) | Automatic lobby lock plus majority `.lock` / `.unlock` votes |
|
||||||
| [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 |
|
||||||
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
|
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
|
||||||
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
|
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
|
||||||
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
|
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
|
||||||
|
|
||||||
@@ -60,10 +60,12 @@ invalid identities fail privately without changing points. Follow the shared
|
|||||||
## Core HUD and service window
|
## 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,33 +76,39 @@ clock. It persists across death and respawn. Clocks, health and zombie-count
|
|||||||
displays freeze at game over. Health fallbacks only affect the display and
|
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), four for records (five with a multi-page
|
||||||
counter). Pages reuse elements; switching views or closing the service releases
|
counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
|
||||||
them. Idle services hold no HUD elements, while the controller retains pending
|
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.2.0` requires common `1.5.0+` for its
|
||||||
|
round-start reminders. AFK `2.0.0` requires the matching
|
||||||
|
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
|
||||||
|
|
||||||
## Map record announcements
|
## Map record announcements
|
||||||
|
|
||||||
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
|
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
|
||||||
`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a
|
`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.2.0` requires
|
||||||
|
common `1.5.0+`; this script update adds no native-plugin or backend dependency
|
||||||
|
and requires no full server-process restart. Leaderboard requires a
|
||||||
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
|
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
|
||||||
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
|
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
|
||||||
requires a full server-process restart. To omit leaderboard, remove its script;
|
requires a full server-process restart. To omit leaderboard, remove its script;
|
||||||
@@ -112,14 +120,24 @@ the same private announcement as playable joins. T5 matches its engine map to
|
|||||||
the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
|
the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
|
||||||
apply to both games.
|
apply to both games.
|
||||||
|
|
||||||
|
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
|
||||||
|
6 rounds, then every round for the final five, followed by matching/overtake
|
||||||
|
milestones. After the first overtake, reminders stop for that match; `.leaders`
|
||||||
|
still works. A reminder shows the record and one short status line for six
|
||||||
|
seconds. Join and manual announcements keep all holders and 12-second pages.
|
||||||
|
Reminders skip busy help/record views, unready players, recent welcome/manual
|
||||||
|
requests and empty/unavailable/expired records. Help discards a reminder instead
|
||||||
|
of pausing it. No extra HTTP requests are made. Follow the shared live checks
|
||||||
|
for cadence, interruption, cleanup and operation without the optional HUD.
|
||||||
|
|
||||||
## Player commands
|
## Player commands
|
||||||
|
|
||||||
Type `.help` or `.commands` in chat to see available player commands and a short description.
|
Type `.help` or `.commands` in chat to see available player commands and a short description.
|
||||||
The response is visible only to the requesting player and works while spectating,
|
The response is visible only to the requesting player and works while spectating,
|
||||||
before the first spawn. Commands are case-insensitive.
|
before the first spawn. Commands are case-insensitive.
|
||||||
Help uses the unboxed upper-right service area. All nine installed commands
|
Help uses the unboxed upper-right service area with command and description
|
||||||
fit in one list with command and description columns. Additional registered
|
columns. Registered commands use pages of nine rows, with a counter only for
|
||||||
commands use pages of nine rows, with a counter only for multi-page lists.
|
multi-page lists.
|
||||||
Pages advance every 12 seconds and close after the last page. Typing either
|
Pages advance every 12 seconds and close after the last page. Typing either
|
||||||
command again returns to page one with a fresh timer. Both engines use
|
command again returns to page one with a fresh timer. Both engines use
|
||||||
explicit row positions so T5 does not concatenate newline-separated text.
|
explicit row positions so T5 does not concatenate newline-separated text.
|
||||||
@@ -127,21 +145,22 @@ 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) |
|
||||||
| `.b` | Check bank balance (bank module required) |
|
| `.b` | Check bank balance (bank module required) |
|
||||||
| `.d <amount\|all>` | Deposit points (bank module required) |
|
| `.d <amount\|all>` | Deposit points (bank module required) |
|
||||||
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
||||||
|
| `.lock` | Vote to lock the lobby from round 6 (lock module required) |
|
||||||
|
| `.unlock` | Vote to unlock the lobby (lock module required) |
|
||||||
|
|
||||||
Each module registers its own help entries. Commands follow the order above,
|
Each module registers its own help entries. The first nine commands follow
|
||||||
independent of initialization order; removing a module also removes its entries
|
the order above; additional entries follow registration order. Removing a module
|
||||||
from `.help` after a map restart.
|
also removes its entries from `.help` after a map restart. Lock and extended AFK
|
||||||
With all command modules installed, help shows one page of nine commands,
|
commands appear on additional pages. Bank entries retain the full
|
||||||
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
|
`.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud` from the list.
|
||||||
removes `.hud` from the list.
|
|
||||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||||
cached map record privately. Repeating it restarts the record from the first page;
|
cached map record privately. Repeating it restarts the record from the first page;
|
||||||
help pauses the record's remaining page time and resumes it after the final
|
help pauses the record's remaining page time and resumes it after the final
|
||||||
@@ -263,6 +282,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 +295,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 +323,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 +332,20 @@ 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. AFK `2.1.0` permits solo and last-active-player entry, including all players
|
||||||
- On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through
|
being AFK together; teammate loss does not force a match end. Upgrade from `2.0.0`
|
||||||
the target-reacquisition window
|
by copying the script and restarting the map; no new dependencies or full
|
||||||
- `[AFK]` HUD element created per-player via spawn handler; alpha-toggled
|
server-process restart are required. Spawn handler `::afk_spawn` binds contribution
|
||||||
by enter / exit through common's core visibility helpers, respecting `.hud`
|
and lifecycle watchers.
|
||||||
|
|
||||||
**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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -383,9 +394,16 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/
|
|||||||
|
|
||||||
**Features**
|
**Features**
|
||||||
- Sets `g_password` to a random per-lobby value when the round crosses a
|
- Sets `g_password` to a random per-lobby value when the round crosses a
|
||||||
milestone (default: round 20)
|
milestone (default: round 20), once per match
|
||||||
|
- Adds `.lock` from round 6 and `.unlock` whenever locked, using strict-majority
|
||||||
|
votes (1/1, 2/2, 2/3, 3/4) of all connected players
|
||||||
|
- Votes expire after 60 seconds; duplicates do not count, and roster changes
|
||||||
|
update the tally without retaining disconnected votes
|
||||||
|
- A successful unlock disables automatic locking for the rest of that match;
|
||||||
|
players can vote to relock at any time from round 6
|
||||||
|
- No automatic round-6 prompt; progress/results use ordinary message text
|
||||||
- Existing players are unaffected; `g_password` is only checked at connect
|
- Existing players are unaffected; `g_password` is only checked at connect
|
||||||
- Clears `g_password` on `hud_game_over` so the next lobby opens unlocked
|
- Clears `g_password` and pending votes at startup and on HUD/stock game-over events
|
||||||
- Trigger round is dvar-driven — retune without redeploying
|
- Trigger round is dvar-driven — retune without redeploying
|
||||||
|
|
||||||
**Provides**
|
**Provides**
|
||||||
@@ -393,13 +411,19 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `level.lock_round` | Round at which the lobby locks (read at init) |
|
| `level.lock_round` | Round at which the lobby locks (read at init) |
|
||||||
| `level.lock_active` | `true` after the password has been set |
|
| `level.lock_active` | `true` after the password has been set |
|
||||||
|
| `level.lock_auto_done` | Automatic locking consumed or overridden by a successful unlock |
|
||||||
|
| `level.lock_vote` | Current ballot, or undefined when idle |
|
||||||
|
|
||||||
**Depends on**
|
**Depends on**
|
||||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or`
|
- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or`, `register_help_command`
|
||||||
- `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over`
|
- `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over`
|
||||||
- Stock BO1: `level.round_number`
|
- Stock BO1: `level.round_number`
|
||||||
- Dvar: `tws_lock_round` (int, default `20`)
|
- Dvar: `tws_lock_round` (int, default `20`)
|
||||||
|
|
||||||
|
Lock `1.1.0` has no new dependency requirement. Copy the updated script and
|
||||||
|
restart the map; no full server-process restart is required. See the shared
|
||||||
|
[voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dvars
|
## Dvars
|
||||||
@@ -604,3 +628,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.
|
||||||
|
|||||||
+951
-64
File diff suppressed because it is too large
Load Diff
@@ -170,6 +170,9 @@ show_help()
|
|||||||
self service_ensure();
|
self service_ensure();
|
||||||
state = self.tws_service;
|
state = self.tws_service;
|
||||||
service_advance(state, getTime());
|
service_advance(state, getTime());
|
||||||
|
// Round-start notices are timely hints, not requests to resume after help.
|
||||||
|
if (isDefined(state.record) && state.record.kind == "reminder")
|
||||||
|
state.record = undefined;
|
||||||
state.generation++;
|
state.generation++;
|
||||||
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
||||||
service_render(state);
|
service_render(state);
|
||||||
@@ -192,6 +195,29 @@ show_record(snapshot)
|
|||||||
service_render(state);
|
service_render(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic round-start hints yield to any existing service and never queue.
|
||||||
|
show_record_reminder(snapshot, message)
|
||||||
|
{
|
||||||
|
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
|
||||||
|
!isDefined(snapshot.round) || snapshot.round <= 0 ||
|
||||||
|
!isDefined(message) || message == "")
|
||||||
|
return false;
|
||||||
|
self service_ensure();
|
||||||
|
state = self.tws_service;
|
||||||
|
if (state.closed)
|
||||||
|
return false;
|
||||||
|
service_advance(state, getTime());
|
||||||
|
if (isDefined(state.help) || isDefined(state.record))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
state.generation++;
|
||||||
|
state.record = service_request("reminder", 1, state.generation);
|
||||||
|
state.record.snapshot = snapshot;
|
||||||
|
state.record.message = message;
|
||||||
|
service_render(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
service_page_count(count, rows)
|
service_page_count(count, rows)
|
||||||
{
|
{
|
||||||
return int((max(count, 1) + rows - 1) / rows);
|
return int((max(count, 1) + rows - 1) / rows);
|
||||||
@@ -205,6 +231,8 @@ service_request(kind, pages, generation)
|
|||||||
request.page = 0;
|
request.page = 0;
|
||||||
request.pages = pages;
|
request.pages = pages;
|
||||||
request.remaining = 12000;
|
request.remaining = 12000;
|
||||||
|
if (kind == "reminder")
|
||||||
|
request.remaining = 6000;
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +379,10 @@ service_view_create(state, request)
|
|||||||
|
|
||||||
state.record_title = service_text(self, state, level.hud_service_right,
|
state.record_title = service_text(self, state, level.hud_service_right,
|
||||||
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
||||||
for (i = 0; i < 3; i++)
|
rows = 3;
|
||||||
|
if (request.kind == "reminder")
|
||||||
|
rows = 1;
|
||||||
|
for (i = 0; i < rows; i++)
|
||||||
state.rows[i] = service_text(self, state, level.hud_service_right,
|
state.rows[i] = service_text(self, state, level.hud_service_right,
|
||||||
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
||||||
"right", 1.05, "", level.hud_c_label);
|
"right", 1.05, "", level.hud_c_label);
|
||||||
@@ -457,7 +488,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)
|
||||||
{
|
{
|
||||||
@@ -467,6 +498,12 @@ service_render(state)
|
|||||||
state.record_title.alpha = 1;
|
state.record_title.alpha = 1;
|
||||||
if (!isDefined(snapshot) || snapshot.round == 0)
|
if (!isDefined(snapshot) || snapshot.round == 0)
|
||||||
return;
|
return;
|
||||||
|
if (request.kind == "reminder")
|
||||||
|
{
|
||||||
|
state.rows[0] SetText(request.message);
|
||||||
|
state.rows[0].alpha = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (i = 0; i < 3; i++)
|
for (i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
index = page * 3 + i;
|
index = page * 3 + i;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */
|
/* tws_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */
|
||||||
|
|
||||||
#include maps\_utility;
|
#include maps\_utility;
|
||||||
#include common_scripts\utility;
|
#include common_scripts\utility;
|
||||||
@@ -24,10 +24,12 @@ init()
|
|||||||
level.tws_lb_pending = undefined;
|
level.tws_lb_pending = undefined;
|
||||||
level.tws_lb_cache = undefined;
|
level.tws_lb_cache = undefined;
|
||||||
level.tws_lb_attempt = 0;
|
level.tws_lb_attempt = 0;
|
||||||
|
level.tws_lb_chase_finished = undefined;
|
||||||
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
|
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
|
||||||
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
|
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
|
||||||
level thread lb_chat_listener();
|
level thread lb_chat_listener();
|
||||||
level thread lb_worker();
|
level thread lb_worker();
|
||||||
|
level thread lb_round_watcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
lb_worker()
|
lb_worker()
|
||||||
@@ -64,6 +66,88 @@ lb_worker()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lb_round_watcher()
|
||||||
|
{
|
||||||
|
level endon("end_game");
|
||||||
|
level endon("game_ended");
|
||||||
|
level endon("intermission");
|
||||||
|
|
||||||
|
last_round = 0;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
// T6 advances its counter before the between-round pause is over.
|
||||||
|
// Both engines notify the actual start; never synthesize a late start.
|
||||||
|
level waittill("start_of_round");
|
||||||
|
if (!isDefined(level.round_number) || level.round_number <= last_round)
|
||||||
|
continue;
|
||||||
|
last_round = level.round_number;
|
||||||
|
lb_round_start(last_round);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_round_start(round)
|
||||||
|
{
|
||||||
|
if (isDefined(level.tws_lb_chase_finished))
|
||||||
|
return;
|
||||||
|
snapshot = lb_cached_record();
|
||||||
|
if (!isDefined(snapshot) || snapshot.round < 2)
|
||||||
|
return;
|
||||||
|
message = lb_round_message(round, snapshot.round);
|
||||||
|
if (message == "")
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Celebrate at most once, even if a later refresh raises the fleet record.
|
||||||
|
// Busy clients skip the milestone too; no delayed mid-round announcements.
|
||||||
|
if (round > snapshot.round)
|
||||||
|
level.tws_lb_chase_finished = true;
|
||||||
|
players = getPlayers();
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
players[i] lb_remind_player(snapshot, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_round_message(round, record)
|
||||||
|
{
|
||||||
|
if (round < 1 || record < 2)
|
||||||
|
return "";
|
||||||
|
gap = record - round;
|
||||||
|
if (gap < 0)
|
||||||
|
return "Recorded high round passed!";
|
||||||
|
if (gap == 0)
|
||||||
|
return "Record round reached!";
|
||||||
|
if (gap > 20)
|
||||||
|
return "";
|
||||||
|
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
|
||||||
|
if (gap > 10 && gap % 5 != 0)
|
||||||
|
return "";
|
||||||
|
if (gap > 5 && gap <= 10 && gap % 2 != 0)
|
||||||
|
return "";
|
||||||
|
if (gap == 1)
|
||||||
|
return "1 round away from matching it";
|
||||||
|
return gap + " rounds away from matching it";
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_player_ready(player)
|
||||||
|
{
|
||||||
|
// A welcome attempt (even with no record) or manual request releases this
|
||||||
|
// gate. Give that full announcement at least 30 seconds before a reminder.
|
||||||
|
if (!isDefined(player.tws_lb_last_request) ||
|
||||||
|
getTime() - player.tws_lb_last_request < 30000)
|
||||||
|
return false;
|
||||||
|
if (!isDefined(player.sessionstate) ||
|
||||||
|
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
|
||||||
|
return false;
|
||||||
|
if (isDefined(player.hasSpawned) && !player.hasSpawned)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_remind_player(snapshot, message)
|
||||||
|
{
|
||||||
|
if (!lb_player_ready(self))
|
||||||
|
return;
|
||||||
|
self scripts\sp\tws_t5_common::show_record_reminder(snapshot, message);
|
||||||
|
}
|
||||||
|
|
||||||
lb_welcome()
|
lb_welcome()
|
||||||
{
|
{
|
||||||
self endon("disconnect");
|
self endon("disconnect");
|
||||||
@@ -128,6 +212,7 @@ lb_request(manual)
|
|||||||
// This also applies when the cache is unavailable.
|
// This also applies when the cache is unavailable.
|
||||||
if (manual)
|
if (manual)
|
||||||
self notify("tws_lb_requested");
|
self notify("tws_lb_requested");
|
||||||
|
self.tws_lb_last_request = getTime();
|
||||||
snapshot = lb_cached_record();
|
snapshot = lb_cached_record();
|
||||||
if (!isDefined(snapshot) && !manual)
|
if (!isDefined(snapshot) && !manual)
|
||||||
return;
|
return;
|
||||||
|
|||||||
+194
-24
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
tws_t5_lock.gsc — lobby g_password lock at milestone round. Existing players
|
tws_t5_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
|
||||||
are unaffected; g_password is only checked at connect. Cleared on
|
Existing players are unaffected; g_password is only checked at connect.
|
||||||
hud_game_over so the next lobby opens unlocked.
|
A successful unlock overrides automatic locking for the rest of the match.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include maps\_utility;
|
#include maps\_utility;
|
||||||
@@ -13,6 +13,8 @@ init()
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
scripts\sp\tws_t5_common::ensure_config();
|
scripts\sp\tws_t5_common::ensure_config();
|
||||||
|
scripts\sp\tws_t5_common::register_help_command(".lock", "Vote to lock (6+)");
|
||||||
|
scripts\sp\tws_t5_common::register_help_command(".unlock", "Vote to unlock");
|
||||||
|
|
||||||
lock_init();
|
lock_init();
|
||||||
}
|
}
|
||||||
@@ -21,47 +23,215 @@ lock_init()
|
|||||||
{
|
{
|
||||||
level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20);
|
level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20);
|
||||||
level.lock_active = false;
|
level.lock_active = false;
|
||||||
|
level.lock_auto_done = false;
|
||||||
|
level.lock_ended = false;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
level.lock_vote_serial = 0;
|
||||||
|
|
||||||
// recover from a prior lobby that crashed before clearing
|
// recover from a prior lobby that crashed before clearing
|
||||||
setDvar("g_password", "");
|
setDvar("g_password", "");
|
||||||
|
|
||||||
level thread lock_round_watcher();
|
level thread lock_round_watcher();
|
||||||
level thread lock_cleanup_watcher();
|
level thread lock_chat_listener();
|
||||||
|
level thread lock_vote_watcher();
|
||||||
|
level thread lock_cleanup_watcher("hud_game_over");
|
||||||
|
level thread lock_cleanup_watcher("end_game");
|
||||||
|
level thread lock_cleanup_watcher("game_ended");
|
||||||
|
level thread lock_cleanup_watcher("intermission");
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_round_watcher()
|
lock_round_watcher()
|
||||||
{
|
{
|
||||||
level endon("hud_game_over");
|
level endon("tws_lock_game_over");
|
||||||
|
|
||||||
for (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
level waittill("hud_round_changed");
|
level waittill("hud_round_changed");
|
||||||
|
lock_check_round();
|
||||||
if (level.lock_active)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// random per activation so the password can't be inferred from source
|
|
||||||
password = "lock_" + getTime() + "_" + randomInt(1000000);
|
|
||||||
setDvar("g_password", password);
|
|
||||||
level.lock_active = true;
|
|
||||||
|
|
||||||
lock_announce();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_cleanup_watcher()
|
lock_check_round()
|
||||||
{
|
{
|
||||||
level waittill("hud_game_over");
|
if (lock_game_ended() || level.lock_auto_done)
|
||||||
setDvar("g_password", "");
|
return;
|
||||||
level.lock_active = false;
|
|
||||||
|
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Consume the milestone once, even if players already locked early.
|
||||||
|
level.lock_auto_done = true;
|
||||||
|
if (!level.lock_active)
|
||||||
|
lock_set_state(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_announce()
|
lock_chat_listener()
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
level waittill("say", message, player);
|
||||||
|
if (!isDefined(player) || !isDefined(message))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
command = toLower(message);
|
||||||
|
if (command == ".lock" || command == ".unlock")
|
||||||
|
lock_request(player, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_game_ended()
|
||||||
|
{
|
||||||
|
return level.lock_ended || is_true(level.intermission) ||
|
||||||
|
is_true(level.gameended) || is_true(level.tws_ended);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This path never yields: each chat message casts at most one vote, and a
|
||||||
|
// successful vote changes the password before another request can run.
|
||||||
|
lock_request(player, command)
|
||||||
|
{
|
||||||
|
if (lock_game_ended())
|
||||||
|
return;
|
||||||
|
|
||||||
|
players = getPlayers();
|
||||||
|
present = false;
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
{
|
||||||
|
if (players[i] == player)
|
||||||
|
present = true;
|
||||||
|
}
|
||||||
|
if (!present)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock_update_vote();
|
||||||
|
target = command == ".lock";
|
||||||
|
if (target == level.lock_active)
|
||||||
|
{
|
||||||
|
if (target)
|
||||||
|
player iPrintLn("^3Server is already locked");
|
||||||
|
else
|
||||||
|
player iPrintLn("^3Server is already unlocked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target && (!isDefined(level.round_number) || level.round_number <= 5))
|
||||||
|
{
|
||||||
|
player iPrintLn("^3.lock is available from round 6");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDefined(level.lock_vote))
|
||||||
|
{
|
||||||
|
level.lock_vote_serial++;
|
||||||
|
vote = spawnStruct();
|
||||||
|
vote.id = level.lock_vote_serial;
|
||||||
|
vote.command = command;
|
||||||
|
vote.target = target;
|
||||||
|
vote.deadline = getTime() + 60000;
|
||||||
|
level.lock_vote = vote;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id)
|
||||||
|
{
|
||||||
|
player iPrintLn("^3You already voted " + command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-local fields cannot carry a vote across a reconnect.
|
||||||
|
player.tws_lock_vote_id = level.lock_vote.id;
|
||||||
|
if (lock_update_vote())
|
||||||
|
{
|
||||||
|
tally = lock_vote_tally();
|
||||||
|
lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required +
|
||||||
|
" votes. Type " + command + " to vote (60s window)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_vote_tally()
|
||||||
|
{
|
||||||
|
tally = spawnStruct();
|
||||||
|
tally.yes = 0;
|
||||||
|
players = getPlayers();
|
||||||
|
tally.required = int(players.size / 2) + 1;
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
{
|
||||||
|
if (isDefined(players[i].tws_lock_vote_id) &&
|
||||||
|
players[i].tws_lock_vote_id == level.lock_vote.id)
|
||||||
|
tally.yes++;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recount the live roster so disconnected votes are dropped and newcomers
|
||||||
|
// count toward the majority. Expiry is checked before accepting any result.
|
||||||
|
lock_update_vote()
|
||||||
|
{
|
||||||
|
if (lock_game_ended() || !isDefined(level.lock_vote))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (getTime() >= level.lock_vote.deadline)
|
||||||
|
{
|
||||||
|
command = level.lock_vote.command;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
lock_announce("^3" + command + " vote expired");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tally = lock_vote_tally();
|
||||||
|
if (tally.yes >= tally.required)
|
||||||
|
{
|
||||||
|
lock_set_state(level.lock_vote.target);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_vote_watcher()
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
wait (0.5);
|
||||||
|
lock_update_vote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_set_state(locked)
|
||||||
|
{
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
if (locked)
|
||||||
|
{
|
||||||
|
// Random per activation so the password cannot be inferred from source.
|
||||||
|
password = "lock_" + getTime() + "_" + randomInt(1000000);
|
||||||
|
setDvar("g_password", password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setDvar("g_password", "");
|
||||||
|
level.lock_auto_done = true;
|
||||||
|
}
|
||||||
|
level.lock_active = locked;
|
||||||
|
if (locked)
|
||||||
|
lock_announce("^3Server locked");
|
||||||
|
else
|
||||||
|
lock_announce("^3Server unlocked");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_cleanup_watcher(event)
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
level waittill(event);
|
||||||
|
level.lock_ended = true;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
setDvar("g_password", "");
|
||||||
|
level.lock_active = false;
|
||||||
|
level notify("tws_lock_game_over");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_announce(message)
|
||||||
{
|
{
|
||||||
players = getPlayers();
|
players = getPlayers();
|
||||||
for (i = 0; i < players.size; i++)
|
for (i = 0; i < players.size; i++)
|
||||||
players[i] iPrintLnBold("^3Server locked");
|
players[i] iPrintLn(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+69
-31
@@ -8,12 +8,12 @@ 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` | Auto-lock at round 20 plus majority `.lock` / `.unlock` votes; clear 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 |
|
||||||
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils |
|
| `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
|
||||||
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
|
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
|
||||||
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
|
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
|
||||||
|
|
||||||
@@ -73,10 +73,12 @@ populates that field before the `connected` notification. Follow the shared
|
|||||||
## Core HUD and service window
|
## 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,32 @@ clock. It persists across death and respawn. Clocks, health and zombie-count
|
|||||||
displays freeze at game over. Health fallbacks only affect the display and
|
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), four for records (five with a multi-page
|
||||||
counter). Pages reuse elements; switching views or closing the service releases
|
counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
|
||||||
them. Idle services hold no HUD elements, while the controller retains pending
|
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.2.0` requires common `1.5.0+` for its
|
||||||
|
round-start reminders. AFK `2.0.0` requires the matching
|
||||||
|
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
|
||||||
|
|
||||||
## Map record announcements
|
## Map record announcements
|
||||||
|
|
||||||
@@ -116,6 +122,21 @@ full server-process restart.
|
|||||||
It works independently of telemetry and HUD, and privately announces records
|
It works independently of telemetry and HUD, and privately announces records
|
||||||
once per connection, including newcomers waiting in spectator mode.
|
once per connection, including newcomers waiting in spectator mode.
|
||||||
|
|
||||||
|
Leaderboard `1.2.0` requires common `1.5.0+`. Copy both scripts to
|
||||||
|
`Plutonium/storage/t6/scripts/zm/` and restart the map. This script update adds
|
||||||
|
no native-plugin or backend dependency and requires no full server-process
|
||||||
|
restart when the existing plugin is installed.
|
||||||
|
|
||||||
|
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
|
||||||
|
6 rounds, then every round for the final five, followed by matching/overtake
|
||||||
|
milestones. After the first overtake, reminders stop for that match; `.leaders`
|
||||||
|
still works. A reminder shows the record and one short status line for six
|
||||||
|
seconds. Join and manual announcements keep all holders and 12-second pages.
|
||||||
|
Reminders skip busy help/record views, unready players, recent welcome/manual
|
||||||
|
requests and empty/unavailable/expired records. Help discards a reminder instead
|
||||||
|
of pausing it. No extra HTTP requests are made. Follow the shared live checks
|
||||||
|
for cadence, interruption, cleanup and operation without the optional HUD.
|
||||||
|
|
||||||
Map selection uses stock `level.scr_zm_ui_gametype` and
|
Map selection uses stock `level.scr_zm_ui_gametype` and
|
||||||
`level.scr_zm_map_start_location`, falling back to the corresponding
|
`level.scr_zm_map_start_location`, falling back to the corresponding
|
||||||
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
|
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
|
||||||
@@ -133,9 +154,9 @@ apply to both versions. All T6 script filenames use the `tws_t6_` prefix.
|
|||||||
Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and
|
Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and
|
||||||
a short description of each. The list is shown only to the requesting player,
|
a short description of each. The list is shown only to the requesting player,
|
||||||
including spectators who have not spawned. Commands are case-insensitive.
|
including spectators who have not spawned. Commands are case-insensitive.
|
||||||
Help uses the unboxed upper-right service area. All nine installed commands
|
Help uses the unboxed upper-right service area with command and description
|
||||||
fit in one list with command and description columns. Additional registered
|
columns. Registered commands use pages of nine rows, with a counter only for
|
||||||
commands use pages of nine rows, with a counter only for multi-page lists.
|
multi-page lists.
|
||||||
Pages advance every 12 seconds and close after the last page. Typing either
|
Pages advance every 12 seconds and close after the last page. Typing either
|
||||||
command again returns to page one with a fresh timer. Both engines use
|
command again returns to page one with a fresh timer. Both engines use
|
||||||
explicit row positions so T5 does not concatenate newline-separated text.
|
explicit row positions so T5 does not concatenate newline-separated text.
|
||||||
@@ -143,23 +164,25 @@ 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) |
|
||||||
| `.b` | Check bank balance (bank module required) |
|
| `.b` | Check bank balance (bank module required) |
|
||||||
| `.d <amount\|all>` | Deposit points (bank module required) |
|
| `.d <amount\|all>` | Deposit points (bank module required) |
|
||||||
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
||||||
|
| `.lock` | Vote to lock the lobby from round 6 (lock module required) |
|
||||||
|
| `.unlock` | Vote to unlock the lobby (lock module required) |
|
||||||
|
|
||||||
Command modules register their commands during `init()` through
|
Command modules register their commands during `init()` through
|
||||||
`tws_t6_common::register_help_command(command, description)`. If a module is
|
`tws_t6_common::register_help_command(command, description)`. If a module is
|
||||||
omitted, its commands are omitted from `.help`. Commands follow the order above,
|
omitted, its commands are omitted from `.help`. The first nine commands follow
|
||||||
independent of initialization order. Register new commands alongside their
|
the order above; additional entries follow registration order. Register new
|
||||||
module's initialization so the list stays current.
|
commands alongside their module's initialization so the list stays current.
|
||||||
With all command modules installed, help shows one page of nine commands,
|
Lock and extended AFK commands appear on additional pages. Bank entries retain
|
||||||
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
|
the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud`
|
||||||
removes `.hud` from the list.
|
from the list.
|
||||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||||
cached map record privately. Repeating it restarts the record from the first page;
|
cached map record privately. Repeating it restarts the record from the first page;
|
||||||
help pauses the record's remaining page time and resumes it after the final
|
help pauses the record's remaining page time and resumes it after the final
|
||||||
@@ -174,8 +197,13 @@ help page. `.leaders` during help replaces the pending record replay from page o
|
|||||||
- AFK uses T5's native `level waittill("say", message, player)` listener.
|
- AFK uses T5's native `level waittill("say", message, player)` listener.
|
||||||
`.back` restores movement and weapons immediately, then ends protection after
|
`.back` restores movement and weapons immediately, then ends protection after
|
||||||
two seconds. The T6 control/weapon fix remains in place.
|
two seconds. The T6 control/weapon fix remains in place.
|
||||||
- Lock uses T5's HUD round/game-over notifications. Default threshold is 20.
|
- Lock uses T5's HUD round notifications with a default automatic threshold of
|
||||||
As in T5, it clears `g_password` at startup and game over.
|
20. `.lock` is available from round 6; `.unlock` whenever locked. Both use a
|
||||||
|
60-second strict-majority vote (1/1, 2/2, 2/3, 3/4). A successful unlock
|
||||||
|
overrides auto-lock for the rest of the match. There is no round-6 prompt.
|
||||||
|
As in T5, it clears `g_password` and votes at startup and HUD/stock game over.
|
||||||
|
Lock `1.1.0` adds no dependency; copy the script and restart the map, without
|
||||||
|
a full server-process restart. See [voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6).
|
||||||
- Telemetry is enabled by default and posts to
|
- Telemetry is enabled by default and posts to
|
||||||
`https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact,
|
`https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact,
|
||||||
case-sensitive Electric Cherry registry name; missing or overlong names
|
case-sensitive Electric Cherry registry name; missing or overlong names
|
||||||
@@ -276,3 +304,13 @@ research sources and live checks are in [NOTES.md](NOTES.md).
|
|||||||
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
|
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.1.0` uses EC authorization and a local central countdown. See the
|
||||||
|
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
|
||||||
|
and live checks. Install plugin `1.4.0+` and fully restart the server process.
|
||||||
|
The countdown remains visible with `.hud` hidden; expiration has no grace period.
|
||||||
|
Solo and last-active-player entry are allowed, including all players being AFK
|
||||||
|
together; teammate loss does not force a match end. Upgrading from `2.0.0` adds
|
||||||
|
no dependencies and requires only copying the script and restarting the map.
|
||||||
|
|||||||
+884
-69
File diff suppressed because it is too large
Load Diff
@@ -166,6 +166,9 @@ show_help()
|
|||||||
self service_ensure();
|
self service_ensure();
|
||||||
state = self.tws_service;
|
state = self.tws_service;
|
||||||
service_advance(state, getTime());
|
service_advance(state, getTime());
|
||||||
|
// Round-start notices are timely hints, not requests to resume after help.
|
||||||
|
if (isDefined(state.record) && state.record.kind == "reminder")
|
||||||
|
state.record = undefined;
|
||||||
state.generation++;
|
state.generation++;
|
||||||
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
||||||
service_render(state);
|
service_render(state);
|
||||||
@@ -188,6 +191,29 @@ show_record(snapshot)
|
|||||||
service_render(state);
|
service_render(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic round-start hints yield to any existing service and never queue.
|
||||||
|
show_record_reminder(snapshot, message)
|
||||||
|
{
|
||||||
|
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
|
||||||
|
!isDefined(snapshot.round) || snapshot.round <= 0 ||
|
||||||
|
!isDefined(message) || message == "")
|
||||||
|
return false;
|
||||||
|
self service_ensure();
|
||||||
|
state = self.tws_service;
|
||||||
|
if (state.closed)
|
||||||
|
return false;
|
||||||
|
service_advance(state, getTime());
|
||||||
|
if (isDefined(state.help) || isDefined(state.record))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
state.generation++;
|
||||||
|
state.record = service_request("reminder", 1, state.generation);
|
||||||
|
state.record.snapshot = snapshot;
|
||||||
|
state.record.message = message;
|
||||||
|
service_render(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
service_page_count(count, rows)
|
service_page_count(count, rows)
|
||||||
{
|
{
|
||||||
return int((max(count, 1) + rows - 1) / rows);
|
return int((max(count, 1) + rows - 1) / rows);
|
||||||
@@ -201,6 +227,8 @@ service_request(kind, pages, generation)
|
|||||||
request.page = 0;
|
request.page = 0;
|
||||||
request.pages = pages;
|
request.pages = pages;
|
||||||
request.remaining = 12000;
|
request.remaining = 12000;
|
||||||
|
if (kind == "reminder")
|
||||||
|
request.remaining = 6000;
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +375,10 @@ service_view_create(state, request)
|
|||||||
|
|
||||||
state.record_title = service_text(self, state, level.hud_service_right,
|
state.record_title = service_text(self, state, level.hud_service_right,
|
||||||
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
||||||
for (i = 0; i < 3; i++)
|
rows = 3;
|
||||||
|
if (request.kind == "reminder")
|
||||||
|
rows = 1;
|
||||||
|
for (i = 0; i < rows; i++)
|
||||||
state.rows[i] = service_text(self, state, level.hud_service_right,
|
state.rows[i] = service_text(self, state, level.hud_service_right,
|
||||||
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
||||||
"right", 1.05, "", level.hud_c_label);
|
"right", 1.05, "", level.hud_c_label);
|
||||||
@@ -453,7 +484,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)
|
||||||
{
|
{
|
||||||
@@ -463,6 +494,12 @@ service_render(state)
|
|||||||
state.record_title.alpha = 1;
|
state.record_title.alpha = 1;
|
||||||
if (!isDefined(snapshot) || snapshot.round == 0)
|
if (!isDefined(snapshot) || snapshot.round == 0)
|
||||||
return;
|
return;
|
||||||
|
if (request.kind == "reminder")
|
||||||
|
{
|
||||||
|
state.rows[0] SetText(request.message);
|
||||||
|
state.rows[0].alpha = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (i = 0; i < 3; i++)
|
for (i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
index = page * 3 + i;
|
index = page * 3 + i;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */
|
/* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
|
||||||
|
|
||||||
#include maps\mp\_utility;
|
#include maps\mp\_utility;
|
||||||
#include common_scripts\utility;
|
#include common_scripts\utility;
|
||||||
@@ -26,10 +26,12 @@ init()
|
|||||||
level.tws_lb_pending = undefined;
|
level.tws_lb_pending = undefined;
|
||||||
level.tws_lb_cache = undefined;
|
level.tws_lb_cache = undefined;
|
||||||
level.tws_lb_attempt = 0;
|
level.tws_lb_attempt = 0;
|
||||||
|
level.tws_lb_chase_finished = undefined;
|
||||||
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
|
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
|
||||||
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
|
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
|
||||||
level thread lb_chat_listener();
|
level thread lb_chat_listener();
|
||||||
level thread lb_worker();
|
level thread lb_worker();
|
||||||
|
level thread lb_round_watcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
lb_worker()
|
lb_worker()
|
||||||
@@ -66,6 +68,88 @@ lb_worker()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lb_round_watcher()
|
||||||
|
{
|
||||||
|
level endon("end_game");
|
||||||
|
level endon("game_ended");
|
||||||
|
level endon("intermission");
|
||||||
|
|
||||||
|
last_round = 0;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
// T6 advances its counter before the between-round pause is over.
|
||||||
|
// Both engines notify the actual start; never synthesize a late start.
|
||||||
|
level waittill("start_of_round");
|
||||||
|
if (!isDefined(level.round_number) || level.round_number <= last_round)
|
||||||
|
continue;
|
||||||
|
last_round = level.round_number;
|
||||||
|
lb_round_start(last_round);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_round_start(round)
|
||||||
|
{
|
||||||
|
if (isDefined(level.tws_lb_chase_finished))
|
||||||
|
return;
|
||||||
|
snapshot = lb_cached_record();
|
||||||
|
if (!isDefined(snapshot) || snapshot.round < 2)
|
||||||
|
return;
|
||||||
|
message = lb_round_message(round, snapshot.round);
|
||||||
|
if (message == "")
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Celebrate at most once, even if a later refresh raises the fleet record.
|
||||||
|
// Busy clients skip the milestone too; no delayed mid-round announcements.
|
||||||
|
if (round > snapshot.round)
|
||||||
|
level.tws_lb_chase_finished = true;
|
||||||
|
players = getPlayers();
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
players[i] lb_remind_player(snapshot, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_round_message(round, record)
|
||||||
|
{
|
||||||
|
if (round < 1 || record < 2)
|
||||||
|
return "";
|
||||||
|
gap = record - round;
|
||||||
|
if (gap < 0)
|
||||||
|
return "Recorded high round passed!";
|
||||||
|
if (gap == 0)
|
||||||
|
return "Record round reached!";
|
||||||
|
if (gap > 20)
|
||||||
|
return "";
|
||||||
|
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
|
||||||
|
if (gap > 10 && gap % 5 != 0)
|
||||||
|
return "";
|
||||||
|
if (gap > 5 && gap <= 10 && gap % 2 != 0)
|
||||||
|
return "";
|
||||||
|
if (gap == 1)
|
||||||
|
return "1 round away from matching it";
|
||||||
|
return gap + " rounds away from matching it";
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_player_ready(player)
|
||||||
|
{
|
||||||
|
// A welcome attempt (even with no record) or manual request releases this
|
||||||
|
// gate. Give that full announcement at least 30 seconds before a reminder.
|
||||||
|
if (!isDefined(player.tws_lb_last_request) ||
|
||||||
|
getTime() - player.tws_lb_last_request < 30000)
|
||||||
|
return false;
|
||||||
|
if (!isDefined(player.sessionstate) ||
|
||||||
|
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
|
||||||
|
return false;
|
||||||
|
if (isDefined(level.passed_introscreen) && !level.passed_introscreen)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lb_remind_player(snapshot, message)
|
||||||
|
{
|
||||||
|
if (!lb_player_ready(self))
|
||||||
|
return;
|
||||||
|
self scripts\zm\tws_t6_common::show_record_reminder(snapshot, message);
|
||||||
|
}
|
||||||
|
|
||||||
lb_welcome()
|
lb_welcome()
|
||||||
{
|
{
|
||||||
self endon("disconnect");
|
self endon("disconnect");
|
||||||
@@ -131,6 +215,7 @@ lb_request(manual)
|
|||||||
// This also applies when the cache is unavailable.
|
// This also applies when the cache is unavailable.
|
||||||
if (manual)
|
if (manual)
|
||||||
self notify("tws_lb_requested");
|
self notify("tws_lb_requested");
|
||||||
|
self.tws_lb_last_request = getTime();
|
||||||
snapshot = lb_cached_record();
|
snapshot = lb_cached_record();
|
||||||
if (!isDefined(snapshot) && !manual)
|
if (!isDefined(snapshot) && !manual)
|
||||||
return;
|
return;
|
||||||
|
|||||||
+194
-24
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
tws_t6_lock.gsc — lobby g_password lock at milestone round. Existing players
|
tws_t6_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
|
||||||
are unaffected; g_password is only checked at connect. Cleared on
|
Existing players are unaffected; g_password is only checked at connect.
|
||||||
hud_game_over so the next lobby opens unlocked.
|
A successful unlock overrides automatic locking for the rest of the match.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include maps\mp\_utility;
|
#include maps\mp\_utility;
|
||||||
@@ -15,6 +15,8 @@ main()
|
|||||||
init()
|
init()
|
||||||
{
|
{
|
||||||
scripts\zm\tws_t6_common::ensure_config();
|
scripts\zm\tws_t6_common::ensure_config();
|
||||||
|
scripts\zm\tws_t6_common::register_help_command(".lock", "Vote to lock (6+)");
|
||||||
|
scripts\zm\tws_t6_common::register_help_command(".unlock", "Vote to unlock");
|
||||||
|
|
||||||
lock_init();
|
lock_init();
|
||||||
}
|
}
|
||||||
@@ -23,47 +25,215 @@ lock_init()
|
|||||||
{
|
{
|
||||||
level.lock_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20);
|
level.lock_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20);
|
||||||
level.lock_active = false;
|
level.lock_active = false;
|
||||||
|
level.lock_auto_done = false;
|
||||||
|
level.lock_ended = false;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
level.lock_vote_serial = 0;
|
||||||
|
|
||||||
// recover from a prior lobby that crashed before clearing
|
// recover from a prior lobby that crashed before clearing
|
||||||
setDvar("g_password", "");
|
setDvar("g_password", "");
|
||||||
|
|
||||||
level thread lock_round_watcher();
|
level thread lock_round_watcher();
|
||||||
level thread lock_cleanup_watcher();
|
level thread lock_chat_listener();
|
||||||
|
level thread lock_vote_watcher();
|
||||||
|
level thread lock_cleanup_watcher("hud_game_over");
|
||||||
|
level thread lock_cleanup_watcher("end_game");
|
||||||
|
level thread lock_cleanup_watcher("game_ended");
|
||||||
|
level thread lock_cleanup_watcher("intermission");
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_round_watcher()
|
lock_round_watcher()
|
||||||
{
|
{
|
||||||
level endon("hud_game_over");
|
level endon("tws_lock_game_over");
|
||||||
|
|
||||||
for (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
level waittill("hud_round_changed");
|
level waittill("hud_round_changed");
|
||||||
|
lock_check_round();
|
||||||
if (level.lock_active)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// random per activation so the password can't be inferred from source
|
|
||||||
password = "lock_" + getTime() + "_" + randomInt(1000000);
|
|
||||||
setDvar("g_password", password);
|
|
||||||
level.lock_active = true;
|
|
||||||
|
|
||||||
lock_announce();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_cleanup_watcher()
|
lock_check_round()
|
||||||
{
|
{
|
||||||
level waittill("hud_game_over");
|
if (lock_game_ended() || level.lock_auto_done)
|
||||||
setDvar("g_password", "");
|
return;
|
||||||
level.lock_active = false;
|
|
||||||
|
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Consume the milestone once, even if players already locked early.
|
||||||
|
level.lock_auto_done = true;
|
||||||
|
if (!level.lock_active)
|
||||||
|
lock_set_state(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_announce()
|
lock_chat_listener()
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
level waittill("say", message, player);
|
||||||
|
if (!isDefined(player) || !isDefined(message))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
command = toLower(message);
|
||||||
|
if (command == ".lock" || command == ".unlock")
|
||||||
|
lock_request(player, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_game_ended()
|
||||||
|
{
|
||||||
|
return level.lock_ended || is_true(level.intermission) ||
|
||||||
|
is_true(level.gameended) || is_true(level.tws_ended);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This path never yields: each chat message casts at most one vote, and a
|
||||||
|
// successful vote changes the password before another request can run.
|
||||||
|
lock_request(player, command)
|
||||||
|
{
|
||||||
|
if (lock_game_ended())
|
||||||
|
return;
|
||||||
|
|
||||||
|
players = getPlayers();
|
||||||
|
present = false;
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
{
|
||||||
|
if (players[i] == player)
|
||||||
|
present = true;
|
||||||
|
}
|
||||||
|
if (!present)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock_update_vote();
|
||||||
|
target = command == ".lock";
|
||||||
|
if (target == level.lock_active)
|
||||||
|
{
|
||||||
|
if (target)
|
||||||
|
player iPrintLn("^3Server is already locked");
|
||||||
|
else
|
||||||
|
player iPrintLn("^3Server is already unlocked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target && (!isDefined(level.round_number) || level.round_number <= 5))
|
||||||
|
{
|
||||||
|
player iPrintLn("^3.lock is available from round 6");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDefined(level.lock_vote))
|
||||||
|
{
|
||||||
|
level.lock_vote_serial++;
|
||||||
|
vote = spawnStruct();
|
||||||
|
vote.id = level.lock_vote_serial;
|
||||||
|
vote.command = command;
|
||||||
|
vote.target = target;
|
||||||
|
vote.deadline = getTime() + 60000;
|
||||||
|
level.lock_vote = vote;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id)
|
||||||
|
{
|
||||||
|
player iPrintLn("^3You already voted " + command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-local fields cannot carry a vote across a reconnect.
|
||||||
|
player.tws_lock_vote_id = level.lock_vote.id;
|
||||||
|
if (lock_update_vote())
|
||||||
|
{
|
||||||
|
tally = lock_vote_tally();
|
||||||
|
lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required +
|
||||||
|
" votes. Type " + command + " to vote (60s window)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_vote_tally()
|
||||||
|
{
|
||||||
|
tally = spawnStruct();
|
||||||
|
tally.yes = 0;
|
||||||
|
players = getPlayers();
|
||||||
|
tally.required = int(players.size / 2) + 1;
|
||||||
|
for (i = 0; i < players.size; i++)
|
||||||
|
{
|
||||||
|
if (isDefined(players[i].tws_lock_vote_id) &&
|
||||||
|
players[i].tws_lock_vote_id == level.lock_vote.id)
|
||||||
|
tally.yes++;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recount the live roster so disconnected votes are dropped and newcomers
|
||||||
|
// count toward the majority. Expiry is checked before accepting any result.
|
||||||
|
lock_update_vote()
|
||||||
|
{
|
||||||
|
if (lock_game_ended() || !isDefined(level.lock_vote))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (getTime() >= level.lock_vote.deadline)
|
||||||
|
{
|
||||||
|
command = level.lock_vote.command;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
lock_announce("^3" + command + " vote expired");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tally = lock_vote_tally();
|
||||||
|
if (tally.yes >= tally.required)
|
||||||
|
{
|
||||||
|
lock_set_state(level.lock_vote.target);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_vote_watcher()
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
wait (0.5);
|
||||||
|
lock_update_vote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_set_state(locked)
|
||||||
|
{
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
if (locked)
|
||||||
|
{
|
||||||
|
// Random per activation so the password cannot be inferred from source.
|
||||||
|
password = "lock_" + getTime() + "_" + randomInt(1000000);
|
||||||
|
setDvar("g_password", password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setDvar("g_password", "");
|
||||||
|
level.lock_auto_done = true;
|
||||||
|
}
|
||||||
|
level.lock_active = locked;
|
||||||
|
if (locked)
|
||||||
|
lock_announce("^3Server locked");
|
||||||
|
else
|
||||||
|
lock_announce("^3Server unlocked");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_cleanup_watcher(event)
|
||||||
|
{
|
||||||
|
level endon("tws_lock_game_over");
|
||||||
|
level waittill(event);
|
||||||
|
level.lock_ended = true;
|
||||||
|
level.lock_vote = undefined;
|
||||||
|
setDvar("g_password", "");
|
||||||
|
level.lock_active = false;
|
||||||
|
level notify("tws_lock_game_over");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_announce(message)
|
||||||
{
|
{
|
||||||
players = getPlayers();
|
players = getPlayers();
|
||||||
for (i = 0; i < players.size; i++)
|
for (i = 0; i < players.size; i++)
|
||||||
players[i] iPrintLnBold("^3Server locked");
|
players[i] iPrintLn(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
|
||||||
|
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
|
||||||
|
function compile(source) {
|
||||||
|
const strings=[];
|
||||||
|
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
|
||||||
|
if(token.startsWith('/')) return '';
|
||||||
|
strings.push(token); return `__str_${strings.length-1}__`;
|
||||||
|
});
|
||||||
|
const compiled = [];
|
||||||
|
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||||
|
for(let match; (match=headers.exec(source));) {
|
||||||
|
let end=headers.lastIndex, depth=1;
|
||||||
|
while(depth && end<source.length) {
|
||||||
|
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
|
||||||
|
}
|
||||||
|
assert.equal(depth,0);
|
||||||
|
let body=source.slice(headers.lastIndex,end-1);
|
||||||
|
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
|
||||||
|
body=body.replace(/#.?(?=__str_)/g,'');
|
||||||
|
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
|
||||||
|
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
|
||||||
|
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
|
||||||
|
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
|
||||||
|
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
|
||||||
|
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
|
||||||
|
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
|
||||||
|
body=body.replace(/player getGuid\(\)/g,'player.guid');
|
||||||
|
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
|
||||||
|
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
|
||||||
|
body=body.replace(/level notify\(/g,'level.notify(');
|
||||||
|
body=body.replace(/::(afk_\w+)/g,'$1');
|
||||||
|
body=body.replace(/http::request/g,'httpPost');
|
||||||
|
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
|
||||||
|
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
|
||||||
|
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
|
||||||
|
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
|
||||||
|
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
|
||||||
|
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
|
||||||
|
if(match[1]==='afk_register_dogs')continue;
|
||||||
|
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
|
||||||
|
}
|
||||||
|
return compiled.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function engine(game, options={}) {
|
||||||
|
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
|
||||||
|
let now=100000, wall=100000, serial=0;
|
||||||
|
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
|
||||||
|
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
|
||||||
|
const players=[],zombies=[];
|
||||||
|
const c=vm.createContext({level,
|
||||||
|
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
|
||||||
|
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
|
||||||
|
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
|
||||||
|
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
|
||||||
|
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
|
||||||
|
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
|
||||||
|
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
|
||||||
|
telemetryNewMatch:()=>`id-${++serial}`,
|
||||||
|
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
|
||||||
|
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
|
||||||
|
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
|
||||||
|
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
|
||||||
|
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
|
||||||
|
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
|
||||||
|
afkRemove:(_s,_u,id)=>saved.delete(id),
|
||||||
|
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
|
||||||
|
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
|
||||||
|
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
|
||||||
|
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
|
||||||
|
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
|
||||||
|
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
|
||||||
|
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
|
||||||
|
httpComplete:request=>{options.during?.(request,c);const d=request.data;
|
||||||
|
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
|
||||||
|
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
|
||||||
|
afkResponse:JSON.parse,
|
||||||
|
});
|
||||||
|
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
|
||||||
|
function player(guid='18446744073709551615') {
|
||||||
|
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
|
||||||
|
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
|
||||||
|
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
|
||||||
|
p.getPlayerAngles=()=>p.angles;
|
||||||
|
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
|
||||||
|
players.push(p);p.afk_spawn();return p;
|
||||||
|
}
|
||||||
|
const p=player(), other=player('other');
|
||||||
|
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
|
||||||
|
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
|
||||||
|
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let checks=0;
|
||||||
|
for(const game of ['t5','t6']) {
|
||||||
|
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
|
||||||
|
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
|
||||||
|
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
|
||||||
|
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
|
||||||
|
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
|
||||||
|
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
|
||||||
|
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
|
||||||
|
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
|
||||||
|
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
|
||||||
|
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
|
||||||
|
});
|
||||||
|
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
|
||||||
|
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
|
||||||
|
});
|
||||||
|
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
|
||||||
|
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
|
||||||
|
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
|
||||||
|
});
|
||||||
|
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
|
||||||
|
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
|
||||||
|
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
|
||||||
|
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
|
||||||
|
assert.equal(e.p.afk_remaining(),1799000);
|
||||||
|
});
|
||||||
|
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
|
||||||
|
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
|
||||||
|
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
|
||||||
|
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
|
||||||
|
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
|
||||||
|
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
|
||||||
|
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
|
||||||
|
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
|
||||||
|
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
|
||||||
|
assert.equal(e.p.sessionstate,'playing');
|
||||||
|
});
|
||||||
|
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
|
||||||
|
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
|
||||||
|
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
|
||||||
|
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
|
||||||
|
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
|
||||||
|
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
|
||||||
|
});
|
||||||
|
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
|
||||||
|
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
|
||||||
|
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
|
||||||
|
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
|
||||||
|
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||||
|
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
|
||||||
|
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||||
|
});
|
||||||
|
test('manual command at zero cannot create a grace period',()=>{
|
||||||
|
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
|
||||||
|
});
|
||||||
|
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
|
||||||
|
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||||
|
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
|
||||||
|
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
|
||||||
|
});
|
||||||
|
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
|
||||||
|
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
|
||||||
|
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
|
||||||
|
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
|
||||||
|
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
|
||||||
|
assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false);
|
||||||
|
assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability('));
|
||||||
|
});
|
||||||
|
test('solo and last active players can enter with unavailable or pending teammates',()=>{
|
||||||
|
for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){
|
||||||
|
const e=engine(game);
|
||||||
|
if(flags===null)e.players.splice(1,1);
|
||||||
|
else if(flags.pending)e.other.tws_afk_state.pending=true;
|
||||||
|
else Object.assign(e.other,flags);
|
||||||
|
e.enter();e.advance(1000);e.tick();
|
||||||
|
assert(e.p.hud_afk_on);assert.equal(e.p.afk_remaining(),1799000);
|
||||||
|
assert.equal(e.stops.length,0);assert.equal(e.p.sessionstate,'playing');
|
||||||
|
assert(!e.log.some(x=>x[0]==='notify'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('teammate death or disconnect during authorization does not cancel entry',()=>{
|
||||||
|
for(const at of ['reserve','start'])for(const change of ['death','disconnect']){
|
||||||
|
const e=engine(game,{during:request=>{
|
||||||
|
if(request.action!==at)return;
|
||||||
|
if(change==='death')e.other.alive=false;
|
||||||
|
else e.players.splice(1,1);
|
||||||
|
}});
|
||||||
|
e.enter();e.tick();assert(e.p.hud_afk_on);assert.equal(e.stops.length,0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('all players can be AFK and teammate loss does not force a match end',()=>{
|
||||||
|
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
|
||||||
|
e.advance(1000);e.tick();e.other.afk_tick().next();
|
||||||
|
assert(e.p.hud_afk_on);assert(e.other.hud_afk_on);assert.equal(e.stops.length,0);
|
||||||
|
e.other.alive=false;e.other.afk_tick().next();e.tick();
|
||||||
|
assert(e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'death');
|
||||||
|
e.players.splice(1,1);e.advance(1000);e.tick();assert(e.p.hud_afk_on);
|
||||||
|
assert(!e.log.some(x=>x[0]==='notify'));
|
||||||
|
assert(!e.source.includes('afk_team_watcher'));
|
||||||
|
assert(!/level\s+notify\("end_game"\)/.test(e.source));
|
||||||
|
});
|
||||||
|
test('solo protection expires normally and manual return preserves bounded grace',()=>{
|
||||||
|
for(const manual of [false,true]){
|
||||||
|
const e=engine(game,{authorized:3});e.players.splice(1,1);e.enter();
|
||||||
|
e.advance(1000);assert.equal(e.p.afk_damage(null,null,10),0);
|
||||||
|
if(manual){e.p.afk_exit();assert.equal(e.stops.at(-1).reason,'manual');}
|
||||||
|
e.advance(2000);e.tick();
|
||||||
|
assert(!e.p.hud_afk_on);assert(!e.p.tws_afk_protected);assert(!e.p.ignoreme);
|
||||||
|
assert(!e.p.tws_afk_controls_locked);assert.equal(e.p.afk_damage(null,null,10),-1);
|
||||||
|
if(!manual)assert.equal(e.stops.at(-1).reason,'expired');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('stock match end still releases and settles every AFK player',()=>{
|
||||||
|
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
|
||||||
|
const watcher=e.c.afk_end_watcher();watcher.next();watcher.next();
|
||||||
|
assert(e.level.afk_ended);assert.equal(e.stops.length,2);
|
||||||
|
for(const p of e.players){assert(!p.hud_afk_on);assert(!p.tws_afk_protected);assert(p.tws_afk_state.cancelled);}
|
||||||
|
assert(e.stops.every(s=>s.reason==='match_end'));
|
||||||
|
});
|
||||||
|
test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{
|
||||||
|
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
|
||||||
|
for(let i=0;i<40;i++)watcher.next(e.p);
|
||||||
|
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
|
||||||
|
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
|
||||||
|
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
|
||||||
|
});
|
||||||
|
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
|
||||||
|
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
|
||||||
|
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
|
||||||
|
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
|
||||||
|
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||||
|
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
|
||||||
|
assert(!e.source.includes('.score'));
|
||||||
|
});
|
||||||
|
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
|
||||||
|
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||||
|
});
|
||||||
|
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
|
||||||
|
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
|
||||||
|
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
|
||||||
|
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
|
||||||
|
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
|
||||||
|
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
|
||||||
|
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||||
|
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||||
|
});
|
||||||
|
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
|
||||||
|
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
|
||||||
|
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
|
||||||
|
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
|
||||||
|
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
|
||||||
|
});
|
||||||
|
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
|
||||||
|
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
|
||||||
|
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
|
||||||
|
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
|
||||||
|
});
|
||||||
|
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
|
||||||
|
for(const at of ['reserve','start']){
|
||||||
|
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||||
|
}
|
||||||
|
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
|
||||||
|
});
|
||||||
|
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
|
||||||
|
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
|
||||||
|
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
|
||||||
|
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);
|
||||||
+52
-57
@@ -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).`);
|
||||||
|
|||||||
+141
-9
@@ -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', 'show_record_reminder', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
|
||||||
if (name === 'help_chat_listener') continue;
|
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,10 +129,11 @@ 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),
|
||||||
|
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
|
||||||
event: name => emit(self, name), end: name => emit(level, name),
|
event: name => emit(self, name), end: name => emit(level, name),
|
||||||
get state() { return self.tws_service; },
|
get state() { return self.tws_service; },
|
||||||
get live() { return elements.filter(e => !e.destroyCalls); },
|
get live() { return elements.filter(e => !e.destroyCalls); },
|
||||||
@@ -160,7 +162,7 @@ function assertTextOnly(e) {
|
|||||||
'service elements are private, non-archived text with no background shaders');
|
'service elements are private, non-archived text with no background shaders');
|
||||||
}
|
}
|
||||||
for (const game of ['t5', 't6']) {
|
for (const game of ['t5', 't6']) {
|
||||||
check(`${game} installed HUD joins all nine commands in one view`, () => {
|
check(`${game} core and bank commands fit in one view without extra modules`, () => {
|
||||||
const e = engine(game, 8, true);
|
const e = engine(game, 8, true);
|
||||||
e.help();
|
e.help();
|
||||||
assert.deepEqual(e.helpCommands,
|
assert.deepEqual(e.helpCommands,
|
||||||
@@ -178,6 +180,20 @@ for (const game of ['t5', 't6']) {
|
|||||||
withoutHud.help();
|
withoutHud.help();
|
||||||
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
|
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
|
||||||
});
|
});
|
||||||
|
check(`${game} lock module registrations appear on the second help page`, () => {
|
||||||
|
const e = engine(game, 8, true);
|
||||||
|
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8');
|
||||||
|
for (const [, command, description] of source.matchAll(/register_help_command\("([^"]+)", "([^"]+)"\)/g))
|
||||||
|
e.c.register_help_command(command, description);
|
||||||
|
e.help();
|
||||||
|
assert.equal(e.state.help.pages, 2);
|
||||||
|
e.advance(12000);
|
||||||
|
assert.deepEqual(e.helpCommands, ['.lock', '.unlock']);
|
||||||
|
assert.deepEqual(e.helpDescriptions, ['Vote to lock (6+)', 'Vote to unlock']);
|
||||||
|
e.advance(12000);
|
||||||
|
assert.equal(e.state.help, undefined);
|
||||||
|
assert.equal(e.live.length, 0);
|
||||||
|
});
|
||||||
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
|
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
|
||||||
const e = engine(game);
|
const e = engine(game);
|
||||||
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
|
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
|
||||||
@@ -321,6 +337,120 @@ for (const game of ['t5', 't6']) {
|
|||||||
assert(e.state.record, 'previous expiry cannot hide newer request');
|
assert(e.state.record, 'previous expiry cannot hide newer request');
|
||||||
assert.equal(e.textCalls, texts);
|
assert.equal(e.textCalls, texts);
|
||||||
});
|
});
|
||||||
|
check(`${game} automatic reminders use two elements and expire after six seconds`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true);
|
||||||
|
const request = e.state.record;
|
||||||
|
assert.equal(request.kind, 'reminder');
|
||||||
|
assert.equal(request.pages, 1);
|
||||||
|
assert.equal(request.remaining, 6000);
|
||||||
|
assert.equal(e.state.record_title.text, 'Map record | Round 82');
|
||||||
|
assert.equal(e.state.rows.length, 1);
|
||||||
|
assert.equal(e.state.rows[0].text, '5 rounds to tie');
|
||||||
|
assert.equal(e.live.length, 2);
|
||||||
|
assert.equal(e.visible.length, 2);
|
||||||
|
assertCounter(e, 1, 1);
|
||||||
|
assertTextOnly(e);
|
||||||
|
const calls = e.textCalls, allocated = e.elements.length;
|
||||||
|
e.advance(5900);
|
||||||
|
assert.equal(e.state.record, request);
|
||||||
|
assert.equal(request.remaining, 100);
|
||||||
|
assert.equal(e.reminder({ round: 83 }, 'Newer target'), false);
|
||||||
|
assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint');
|
||||||
|
assert.equal(request.remaining, 100);
|
||||||
|
assert.equal(e.textCalls, calls);
|
||||||
|
assert.equal(e.elements.length, allocated);
|
||||||
|
e.advance(100);
|
||||||
|
assert.equal(e.state.record, undefined);
|
||||||
|
assert.equal(e.live.length, 0);
|
||||||
|
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||||
|
assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true);
|
||||||
|
assert.equal(e.state.record.remaining, 6000);
|
||||||
|
assert.equal(e.live.length, 2);
|
||||||
|
assert.equal(e.peakLive, 2);
|
||||||
|
});
|
||||||
|
check(`${game} automatic reminders never replace or queue behind help and full records`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
|
||||||
|
e.advance(1000);
|
||||||
|
const record = e.state.record;
|
||||||
|
const generation = e.state.generation, allocations = e.elements.length;
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
|
||||||
|
assert.equal(e.state.record, record);
|
||||||
|
assert.equal(record.remaining, 11000);
|
||||||
|
assert.equal(e.state.generation, generation);
|
||||||
|
assert.equal(e.elements.length, allocations);
|
||||||
|
e.help();
|
||||||
|
e.advance(1000);
|
||||||
|
const help = e.state.help, withHelp = e.elements.length;
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
|
||||||
|
assert.equal(e.state.help, help);
|
||||||
|
assert.equal(e.state.record, record);
|
||||||
|
assert.equal(e.elements.length, withHelp);
|
||||||
|
e.advance(11000);
|
||||||
|
assert.equal(e.state.record, record);
|
||||||
|
assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time');
|
||||||
|
e.advance(23000);
|
||||||
|
assert.equal(e.state.record, undefined);
|
||||||
|
assert.equal(e.live.length, 0, 'skipped hints do not appear later');
|
||||||
|
e.help();
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
|
||||||
|
assert.equal(e.state.record, undefined, 'help alone must not queue a hint');
|
||||||
|
e.advance(12000);
|
||||||
|
assert.equal(e.live.length, 0);
|
||||||
|
});
|
||||||
|
check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.reminder({ round: 82 }, '5 rounds to tie');
|
||||||
|
const reminderElements = [...e.live];
|
||||||
|
e.advance(2000);
|
||||||
|
e.help();
|
||||||
|
assert.equal(e.state.record, undefined);
|
||||||
|
assert(reminderElements.every(elem => elem.destroyCalls === 1));
|
||||||
|
e.advance(12000);
|
||||||
|
assert.equal(e.state.help, undefined);
|
||||||
|
assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help');
|
||||||
|
e.reminder({ round: 82 }, '4 rounds to tie');
|
||||||
|
const replaced = [...e.live];
|
||||||
|
e.advance(5900);
|
||||||
|
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
|
||||||
|
assert.equal(e.state.record.kind, 'record');
|
||||||
|
assert.equal(e.state.record.remaining, 12000);
|
||||||
|
assert.equal(e.state.record.pages, 2);
|
||||||
|
assert.equal(e.live.length, 5);
|
||||||
|
assert(replaced.every(elem => elem.destroyCalls === 1));
|
||||||
|
e.advance(100);
|
||||||
|
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
|
||||||
|
assert.equal(e.state.record.remaining, 11900);
|
||||||
|
e.advance(11900);
|
||||||
|
assert.equal(e.state.record.page, 1);
|
||||||
|
assert.equal(e.state.rows[0].text, 'Four');
|
||||||
|
e.advance(12000);
|
||||||
|
assert.equal(e.live.length, 0);
|
||||||
|
});
|
||||||
|
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
for (const bad of [undefined, {}, { round: 0 }, { round: -1 }])
|
||||||
|
assert.equal(e.reminder(bad, '5 rounds to tie'), false);
|
||||||
|
for (const message of [undefined, ''])
|
||||||
|
assert.equal(e.reminder({ round: 82 }, message), false);
|
||||||
|
assert.equal(e.state, undefined);
|
||||||
|
assert.equal(e.elements.length, 0);
|
||||||
|
});
|
||||||
|
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect'])
|
||||||
|
check(`${game} ${event} cleans a reminder and blocks reopening`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.reminder({ round: 82 }, '5 rounds to tie');
|
||||||
|
e.advance(1000);
|
||||||
|
if (event === 'disconnect') e.event(event); else e.end(event);
|
||||||
|
assert.equal(e.state.closed, true);
|
||||||
|
assert.equal(e.state.record, undefined);
|
||||||
|
assert.equal(e.live.length, 0);
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false);
|
||||||
|
e.advance(12000);
|
||||||
|
assert.equal(e.elements.length, 2);
|
||||||
|
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||||
|
});
|
||||||
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
|
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
|
||||||
const e = engine(game);
|
const e = engine(game);
|
||||||
e.record(undefined);
|
e.record(undefined);
|
||||||
@@ -335,7 +465,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);
|
||||||
@@ -371,6 +501,8 @@ for (const game of ['t5', 't6']) {
|
|||||||
check(`${game} ending before first service blocks lazy creation`, () => {
|
check(`${game} ending before first service blocks lazy creation`, () => {
|
||||||
const e = engine(game);
|
const e = engine(game);
|
||||||
e.end('end_game'); e.help(); e.record(snapshot());
|
e.end('end_game'); e.help(); e.record(snapshot());
|
||||||
|
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
|
||||||
|
assert.equal(e.state, undefined);
|
||||||
assert.equal(e.elements.length, 0);
|
assert.equal(e.elements.length, 0);
|
||||||
});
|
});
|
||||||
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
|
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
|
||||||
@@ -380,7 +512,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 +654,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 +678,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));
|
||||||
|
|||||||
+233
-7
@@ -1,4 +1,4 @@
|
|||||||
// Execute the actual data-only GSC functions with small engine-value shims.
|
// Execute actual leaderboard GSC functions with small data, player and clock shims.
|
||||||
// This is a fixture check, not a GSC VM, linker or gameplay test.
|
// This is a fixture check, not a GSC VM, linker or gameplay test.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -34,9 +34,13 @@ function functions(source) {
|
|||||||
let body = protectedSource.slice(headers.lastIndex, end - 1);
|
let body = protectedSource.slice(headers.lastIndex, end - 1);
|
||||||
// Each GSC function has its own implicit locals, including recursion.
|
// Each GSC function has its own implicit locals, including recursion.
|
||||||
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
|
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
|
||||||
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length').replace(/\bwait\s+[\d.]+;/g, ';');
|
const coroutine = match[1] === 'lb_round_watcher';
|
||||||
|
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
|
||||||
|
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
|
||||||
|
if (coroutine)
|
||||||
|
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };');
|
||||||
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
|
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
|
||||||
found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
found.set(match[1], `function${coroutine ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
@@ -44,15 +48,17 @@ function functions(source) {
|
|||||||
function engine(game) {
|
function engine(game) {
|
||||||
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
|
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
|
||||||
const all = functions(source);
|
const all = functions(source);
|
||||||
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']);
|
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener',
|
||||||
|
'lb_request', 'lb_round_watcher', 'lb_round_start', 'lb_remind_player']);
|
||||||
const table = [];
|
const table = [];
|
||||||
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
|
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
|
||||||
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
|
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
|
||||||
const logs = [], queries = [], responses = [];
|
const logs = [], queries = [], responses = [];
|
||||||
|
let now = 1000000;
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
|
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
|
||||||
GetDvar: (key) => dvars[key] ?? '',
|
GetDvar: (key) => dvars[key] ?? '',
|
||||||
getTime: () => 1000000,
|
getTime: () => now,
|
||||||
spawnStruct: () => ({}),
|
spawnStruct: () => ({}),
|
||||||
isDefined: (value) => value !== undefined && value !== null,
|
isDefined: (value) => value !== undefined && value !== null,
|
||||||
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
|
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
|
||||||
@@ -69,7 +75,76 @@ function engine(game) {
|
|||||||
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
|
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
|
||||||
});
|
});
|
||||||
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
|
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
|
||||||
return { context, all, source, dvars, responses, queries, logs };
|
return { context, all, source, dvars, responses, queries, logs,
|
||||||
|
get now() { return now; }, setTime(value) { now = value; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRuntime(game) {
|
||||||
|
const e = engine(game), c = e.context;
|
||||||
|
const players = [], attempts = [], shown = [], records = [], notifications = [], endEvents = [];
|
||||||
|
const current = (player, run) => {
|
||||||
|
const prior = c.self;
|
||||||
|
c.self = player;
|
||||||
|
try { return run(); } finally { c.self = prior; }
|
||||||
|
};
|
||||||
|
c.getPlayers = () => players;
|
||||||
|
c.endon = (entity, event) => {
|
||||||
|
assert.equal(entity, c.level);
|
||||||
|
endEvents.push(event);
|
||||||
|
};
|
||||||
|
c.playerReminder = (player, snapshot, message) => current(player, () => c.lb_remind_player(snapshot, message));
|
||||||
|
c.reminderService = (player, snapshot, message) => {
|
||||||
|
const notice = { player, snapshot, message, round: c.level.round_number };
|
||||||
|
attempts.push(notice);
|
||||||
|
if (player.busy) return false;
|
||||||
|
shown.push(notice);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
c.recordService = (player, snapshot) => records.push({ player, snapshot });
|
||||||
|
c.playerNotify = (player, event) => notifications.push({ player, event });
|
||||||
|
for (const name of ['lb_round_start', 'lb_remind_player', 'lb_request', 'lb_round_watcher']) {
|
||||||
|
const code = e.all.get(name)
|
||||||
|
.replace(/\b(?:self|level) endon\([^;]*\);/g, declaration =>
|
||||||
|
name === 'lb_round_watcher' ? declaration.replace('level endon(', 'endon(level, ') : '')
|
||||||
|
.replace(/players\[i\] lb_remind_player\(/g, 'playerReminder(players[i], ')
|
||||||
|
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record_reminder\(/g, 'reminderService(self, ')
|
||||||
|
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(self, ')
|
||||||
|
.replace(/self notify\(/g, 'playerNotify(self, ');
|
||||||
|
vm.runInContext(code, c);
|
||||||
|
}
|
||||||
|
let watcher, stopped = false;
|
||||||
|
const resumeWatcher = () => {
|
||||||
|
const step = watcher.next();
|
||||||
|
assert.equal(step.done, false);
|
||||||
|
assert.equal(step.value.entity, c.level);
|
||||||
|
assert.equal(step.value.event, 'start_of_round');
|
||||||
|
};
|
||||||
|
const startWatcher = () => {
|
||||||
|
if (watcher) return;
|
||||||
|
watcher = c.lb_round_watcher();
|
||||||
|
resumeWatcher();
|
||||||
|
};
|
||||||
|
return { ...e, players, attempts, shown, records, notifications, endEvents,
|
||||||
|
get now() { return e.now; },
|
||||||
|
readyPlayer(fields = {}) {
|
||||||
|
const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
|
||||||
|
players.push(player);
|
||||||
|
return player;
|
||||||
|
},
|
||||||
|
cache(round, saved_at = e.now) {
|
||||||
|
return (c.level.tws_lb_cache = { round, saved_at, lines: ['Holder'] });
|
||||||
|
},
|
||||||
|
round(round) { c.level.round_number = round; c.lb_round_start(round); },
|
||||||
|
request(player, manual) { return current(player, () => c.lb_request(manual)); },
|
||||||
|
startWatcher,
|
||||||
|
startRoundEvent(round) {
|
||||||
|
c.level.round_number = round;
|
||||||
|
if (stopped) return;
|
||||||
|
startWatcher();
|
||||||
|
resumeWatcher();
|
||||||
|
},
|
||||||
|
end(event) { if (endEvents.includes(event)) { stopped = true; watcher.return(); } },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapOption(game) {
|
function mapOption(game) {
|
||||||
@@ -147,6 +222,157 @@ for (const game of ['t5', 't6']) {
|
|||||||
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
|
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
|
||||||
c.level.tws_lb_cache = undefined;
|
c.level.tws_lb_cache = undefined;
|
||||||
});
|
});
|
||||||
|
for (const [record, expected] of [
|
||||||
|
[50, [30, 35, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51]],
|
||||||
|
[53, [33, 38, 43, 45, 47, 48, 49, 50, 51, 52, 53, 54]],
|
||||||
|
]) check(`${game} record ${record} reminders accelerate relative to the target`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer(); e.cache(record);
|
||||||
|
for (let round = 1; round <= record + 10; round++) e.round(round);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.round), expected);
|
||||||
|
assert.equal(e.shown[0].message, '20 rounds away from matching it');
|
||||||
|
assert.equal(e.shown.at(-3).message, '1 round away from matching it');
|
||||||
|
assert.equal(e.shown.at(-2).message, 'Record round reached!');
|
||||||
|
assert.equal(e.shown.at(-1).message, 'Recorded high round passed!');
|
||||||
|
assert.equal(e.context.level.tws_lb_chase_finished, true);
|
||||||
|
assert.equal(e.queries.length, 0);
|
||||||
|
});
|
||||||
|
check(`${game} low records avoid round-one history and handle short approaches`, () => {
|
||||||
|
for (const record of [0, 1, 2, 6]) {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer(); e.cache(record);
|
||||||
|
for (let round = 0; round <= record + 4; round++) e.round(round);
|
||||||
|
const expected = record < 2 ? [] : Array.from({ length: record + 1 }, (_, i) => i + 1);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.round), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
check(`${game} absent, expired and empty caches skip reminders without fetching`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer();
|
||||||
|
for (const cached of [undefined, { round: 0, saved_at: e.now }, { round: 1, saved_at: e.now },
|
||||||
|
{ round: 50, saved_at: e.now - 900001 }]) {
|
||||||
|
e.context.level.tws_lb_cache = cached;
|
||||||
|
e.round(30); e.round(51);
|
||||||
|
assert.equal(e.context.level.tws_lb_chase_finished, undefined);
|
||||||
|
}
|
||||||
|
assert.equal(e.attempts.length, 0);
|
||||||
|
const stillUsable = e.cache(50, e.now - 900000);
|
||||||
|
e.round(30);
|
||||||
|
assert.equal(e.shown.length, 1);
|
||||||
|
assert.equal(e.shown[0].snapshot, stillUsable);
|
||||||
|
assert.equal(e.queries.length, 0);
|
||||||
|
});
|
||||||
|
check(`${game} the next round uses the latest cache without a mid-round announcement`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer();
|
||||||
|
const first = e.cache(50);
|
||||||
|
e.round(35);
|
||||||
|
const raised = e.cache(60);
|
||||||
|
assert.equal(e.shown.length, 1);
|
||||||
|
e.round(40);
|
||||||
|
const corrected = e.cache(55);
|
||||||
|
e.round(45);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.snapshot), [first, raised, corrected]);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.message), [
|
||||||
|
'15 rounds away from matching it', '20 rounds away from matching it', '10 rounds away from matching it',
|
||||||
|
]);
|
||||||
|
assert.equal(e.queries.length, 0);
|
||||||
|
});
|
||||||
|
check(`${game} overtaking finishes this match's chase even after cache changes or a busy client`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer(); e.cache(50);
|
||||||
|
e.round(50); e.round(51); e.round(52);
|
||||||
|
e.cache(70);
|
||||||
|
e.round(55); e.round(70); e.round(71);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.round), [50, 51]);
|
||||||
|
const busy = roundRuntime(game);
|
||||||
|
const player = busy.readyPlayer({ busy: true });
|
||||||
|
busy.cache(50); busy.round(51);
|
||||||
|
assert.equal(busy.attempts.length, 1);
|
||||||
|
assert.equal(busy.shown.length, 0);
|
||||||
|
player.busy = false;
|
||||||
|
busy.round(52);
|
||||||
|
assert.equal(busy.attempts.length, 1, 'a skipped milestone cannot become a delayed celebration');
|
||||||
|
});
|
||||||
|
check(`${game} each client must be ready and thirty seconds past welcome or manual replay`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
const playing = e.readyPlayer();
|
||||||
|
const spectator = e.readyPlayer({ sessionstate: 'spectator' });
|
||||||
|
e.readyPlayer({ tws_lb_last_request: undefined });
|
||||||
|
e.readyPlayer({ tws_lb_last_request: e.now - 29999 });
|
||||||
|
for (const sessionstate of [undefined, 'connecting', 'dead'])
|
||||||
|
e.readyPlayer({ sessionstate });
|
||||||
|
e.cache(50); e.round(45);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.player), [playing, spectator]);
|
||||||
|
assert.equal(e.context.lb_player_ready({ ...playing, tws_lb_last_request: e.now - 30000 }), true);
|
||||||
|
});
|
||||||
|
check(`${game} welcome attempts and manual requests reset the reminder cooldown`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
const player = e.readyPlayer({ tws_lb_last_request: undefined });
|
||||||
|
e.request(player, false);
|
||||||
|
assert.equal(player.tws_lb_last_request, e.now);
|
||||||
|
assert.equal(e.records.length, 0, 'missing initial cache still releases the welcome readiness gate');
|
||||||
|
e.setTime(e.now + 29999);
|
||||||
|
assert.equal(e.context.lb_player_ready(player), false);
|
||||||
|
e.setTime(e.now + 1);
|
||||||
|
assert.equal(e.context.lb_player_ready(player), true);
|
||||||
|
e.cache(50); e.round(45);
|
||||||
|
e.request(player, true);
|
||||||
|
assert.equal(player.tws_lb_last_request, e.now);
|
||||||
|
assert.equal(e.records.length, 1);
|
||||||
|
assert.equal(e.notifications[0].event, 'tws_lb_requested');
|
||||||
|
e.round(46);
|
||||||
|
assert.equal(e.shown.length, 1);
|
||||||
|
e.setTime(e.now + 29999);
|
||||||
|
assert.equal(e.context.lb_player_ready(player), false);
|
||||||
|
e.setTime(e.now + 1); e.round(47);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.round), [45, 47]);
|
||||||
|
});
|
||||||
|
check(`${game} reminder readiness follows the engine's spectator/introscreen rules`, () => {
|
||||||
|
const e = roundRuntime(game), c = e.context;
|
||||||
|
const player = e.readyPlayer({ sessionstate: 'spectator', hasSpawned: false });
|
||||||
|
if (game === 't5') {
|
||||||
|
assert.equal(c.lb_player_ready(player), false);
|
||||||
|
player.hasSpawned = true;
|
||||||
|
assert.equal(c.lb_player_ready(player), true);
|
||||||
|
delete player.hasSpawned;
|
||||||
|
assert.equal(c.lb_player_ready(player), true);
|
||||||
|
} else {
|
||||||
|
assert.equal(c.lb_player_ready(player), true, 'T6 spectator newcomers keep hasSpawned false');
|
||||||
|
c.level.passed_introscreen = false;
|
||||||
|
assert.equal(c.lb_player_ready(player), false);
|
||||||
|
c.level.passed_introscreen = true;
|
||||||
|
assert.equal(c.lb_player_ready(player), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
check(`${game} watcher waits for real starts and suppresses repeats, decreases and skipped starts`, () => {
|
||||||
|
const e = roundRuntime(game), observed = [];
|
||||||
|
e.context.lb_round_start = round => observed.push(round);
|
||||||
|
e.context.level.round_number = 30;
|
||||||
|
e.startWatcher();
|
||||||
|
assert.deepEqual(observed, [], 'loading mid-round must not synthesize an announcement');
|
||||||
|
e.context.level.round_number = 31;
|
||||||
|
assert.deepEqual(observed, [], 'a counter change before the actual start must not announce');
|
||||||
|
for (const round of [undefined, -1, 31, 31, 29, 30, 31, 34, 34]) e.startRoundEvent(round);
|
||||||
|
assert.deepEqual(observed, [31, 34], 'a skipped round emits only the newly observed start');
|
||||||
|
const fresh = roundRuntime(game), initial = [];
|
||||||
|
fresh.context.lb_round_start = round => initial.push(round);
|
||||||
|
fresh.startRoundEvent(0); fresh.startRoundEvent(1); fresh.startRoundEvent(1);
|
||||||
|
assert.deepEqual(initial, [1]);
|
||||||
|
assert(source.includes('level thread lb_round_watcher();'));
|
||||||
|
assert(!all.get('lb_round_watcher').includes('hud_round_changed'));
|
||||||
|
assert(!all.get('lb_round_watcher').includes('tws_t5_hud'));
|
||||||
|
assert(!all.get('lb_round_watcher').includes('tws_t6_hud'));
|
||||||
|
});
|
||||||
|
for (const event of ['end_game', 'game_ended', 'intermission'])
|
||||||
|
check(`${game} ${event} stops the independent round watcher`, () => {
|
||||||
|
const e = roundRuntime(game);
|
||||||
|
e.readyPlayer(); e.cache(50);
|
||||||
|
e.context.level.round_number = 44; e.startWatcher(); e.startRoundEvent(45);
|
||||||
|
assert.deepEqual(e.endEvents, ['end_game', 'game_ended', 'intermission']);
|
||||||
|
e.end(event); e.startRoundEvent(46);
|
||||||
|
assert.deepEqual(e.shown.map(notice => notice.round), [45]);
|
||||||
|
});
|
||||||
check(`${game} unicode and URL encoding`, () => {
|
check(`${game} unicode and URL encoding`, () => {
|
||||||
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
|
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
|
||||||
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
|
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
|
||||||
@@ -288,7 +514,7 @@ check('paired data helpers stay identical', () => {
|
|||||||
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
|
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
|
||||||
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
|
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
|
||||||
for (const [name, fn] of t5) {
|
for (const [name, fn] of t5) {
|
||||||
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) {
|
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
|
||||||
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
|
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
|
||||||
assert.equal(normalized, fn, name);
|
assert.equal(normalized, fn, name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// Execute both lock scripts with chat, roster, clock and event shims. This does
|
||||||
|
// not emulate Plutonium's VM or prove that g_password prevents a real join.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
const coroutines = new Set(['lock_chat_listener', 'lock_round_watcher', 'lock_vote_watcher', 'lock_cleanup_watcher']);
|
||||||
|
function translate(source) {
|
||||||
|
const literals = [];
|
||||||
|
source = source.replace(/#include[^\n]*|"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
|
||||||
|
if (!token.startsWith('"')) return '';
|
||||||
|
literals.push(token);
|
||||||
|
return `__literal_${literals.length - 1}__`;
|
||||||
|
});
|
||||||
|
const result = [], headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||||
|
for (let match; (match = headers.exec(source));) {
|
||||||
|
let end = headers.lastIndex, depth = 1;
|
||||||
|
while (depth) {
|
||||||
|
assert(end < source.length, `unclosed ${match[1]}`);
|
||||||
|
const ch = source[end++];
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
if (ch === '}') depth--;
|
||||||
|
}
|
||||||
|
let body = source.slice(headers.lastIndex, end - 1);
|
||||||
|
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+)/g)].map(m => m[1]));
|
||||||
|
if (match[1] === 'lock_chat_listener') { locals.add('message'); locals.add('player'); }
|
||||||
|
body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '')
|
||||||
|
.replace(/#(__literal_\d+__)/g, '$1')
|
||||||
|
.replace(/\.size\b/g, '.length')
|
||||||
|
.replace(/level thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);')
|
||||||
|
.replace(/level endon\(([^;]+)\);/g, 'endon($1);')
|
||||||
|
.replace(/level waittill\(([^,;]+), message, player\);/g, '[message, player] = yield { event: $1 };')
|
||||||
|
.replace(/level waittill\(([^;]+)\);/g, 'yield { event: $1 };')
|
||||||
|
.replace(/level notify\(([^;]+)\);/g, 'emit($1);')
|
||||||
|
.replace(/wait \(([^;]+)\);/g, 'yield { delay: ($1) * 1000 };')
|
||||||
|
.replace(/(player|players\[i\]) iPrintLn\(/g, 'print($1, ')
|
||||||
|
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
|
||||||
|
result.push(`function${coroutines.has(match[1]) ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
||||||
|
}
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function engine(game, count = 4, lockRound = 20) {
|
||||||
|
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8');
|
||||||
|
const level = { round_number: 6 }, players = [], tasks = [], messages = [], writes = [], help = [];
|
||||||
|
let now = 0, running, random = 0, password = 'stale-password';
|
||||||
|
function resume(task, args) {
|
||||||
|
const prior = running;
|
||||||
|
running = task;
|
||||||
|
const next = task.generator.next(args);
|
||||||
|
task.done = task.done || next.done;
|
||||||
|
task.wait = next.value;
|
||||||
|
task.at = next.value?.delay !== undefined ? now + next.value.delay : Infinity;
|
||||||
|
running = prior;
|
||||||
|
}
|
||||||
|
function startThread(fn, args) {
|
||||||
|
const task = { generator: fn(...args), ends: [] };
|
||||||
|
tasks.push(task);
|
||||||
|
resume(task);
|
||||||
|
}
|
||||||
|
function emit(event, ...args) {
|
||||||
|
const listeners = tasks.filter(t => !t.done);
|
||||||
|
for (const task of listeners) if (task.ends.includes(event)) task.done = true;
|
||||||
|
for (const task of listeners) if (!task.done && task.wait?.event === event) resume(task, args);
|
||||||
|
}
|
||||||
|
function advance(milliseconds) {
|
||||||
|
const until = now + milliseconds;
|
||||||
|
for (;;) {
|
||||||
|
const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0];
|
||||||
|
if (!next) break;
|
||||||
|
now = next.at;
|
||||||
|
resume(next);
|
||||||
|
}
|
||||||
|
now = until;
|
||||||
|
}
|
||||||
|
const c = vm.createContext({
|
||||||
|
level, ensure_config() {}, register_help_command: (command, description) => help.push([command, description]),
|
||||||
|
GetDvar: () => '1', int_dvar_or: () => lockRound,
|
||||||
|
getPlayers: () => players.slice(), getTime: () => now,
|
||||||
|
isDefined: value => value !== undefined && value !== null,
|
||||||
|
is_true: value => value === true || value === 1,
|
||||||
|
spawnStruct: () => ({}), int: Math.trunc, randomInt: () => ++random,
|
||||||
|
toLower: value => value.toLowerCase(),
|
||||||
|
setDvar(name, value) { assert.equal(name, 'g_password'); password = value; writes.push(value); },
|
||||||
|
print: (player, text) => messages.push({ player, text }),
|
||||||
|
startThread, endon: event => running.ends.push(event), emit,
|
||||||
|
});
|
||||||
|
vm.runInContext(translate(source), c);
|
||||||
|
function connect() { const player = { sessionstate: 'playing' }; players.push(player); return player; }
|
||||||
|
for (let i = 0; i < count; i++) connect();
|
||||||
|
c.init();
|
||||||
|
return {
|
||||||
|
level, players, tasks, messages, writes, help, c, advance, connect, emit,
|
||||||
|
chat: (player, message) => emit('say', message, player),
|
||||||
|
round(number) { level.round_number = number; emit('hud_round_changed'); },
|
||||||
|
disconnect(player) { const index = players.indexOf(player); assert(index >= 0); players.splice(index, 1); },
|
||||||
|
get password() { return password; },
|
||||||
|
// Jump without running the timer to test expiry at the exact chat boundary.
|
||||||
|
jump(milliseconds) { now += milliseconds; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
function check(name, run) {
|
||||||
|
try { run(); checks++; }
|
||||||
|
catch (cause) { throw new Error(name, { cause }); }
|
||||||
|
}
|
||||||
|
for (const game of ['t5', 't6']) {
|
||||||
|
check(`${game}: startup clears a stale lock and registers both commands quietly`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
assert.equal(e.password, '');
|
||||||
|
assert.deepEqual(e.help.map(([command]) => command), ['.lock', '.unlock']);
|
||||||
|
e.round(5); e.round(6); e.round(19);
|
||||||
|
assert.equal(e.messages.length, 0);
|
||||||
|
assert.equal(e.writes.length, 1);
|
||||||
|
});
|
||||||
|
for (const [count, required] of [[1, 1], [2, 2], [3, 2], [4, 3], [8, 5]]) {
|
||||||
|
for (const command of ['.lock', '.unlock']) {
|
||||||
|
check(`${game}: ${command} needs ${required}/${count} distinct voters`, () => {
|
||||||
|
const e = engine(game, count);
|
||||||
|
const target = command === '.lock';
|
||||||
|
if (!target) e.round(20);
|
||||||
|
for (let i = 0; i < required - 1; i++) {
|
||||||
|
e.chat(e.players[i], command);
|
||||||
|
e.chat(e.players[i], command);
|
||||||
|
assert.equal(e.level.lock_active, !target);
|
||||||
|
}
|
||||||
|
e.chat(e.players[required - 1], command.toUpperCase());
|
||||||
|
assert.equal(e.level.lock_active, target);
|
||||||
|
assert.equal(Boolean(e.password), target);
|
||||||
|
assert.equal(e.level.lock_vote, undefined);
|
||||||
|
const writes = e.writes.length;
|
||||||
|
e.chat(e.players[0], command);
|
||||||
|
assert.equal(e.writes.length, writes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const round of [undefined, 0, 1, 4, 5]) {
|
||||||
|
check(`${game}: manual locking is unavailable at round ${round}`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
e.round(round); e.chat(e.players[0], '.lock');
|
||||||
|
assert.equal(e.password, '');
|
||||||
|
assert.equal(e.level.lock_vote, undefined);
|
||||||
|
assert.match(e.messages.at(-1).text, /round 6/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
check(`${game}: exact chat commands only; missing and disconnected senders are ignored`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
for (const text of ['hello .lock', '.lock extra', '.unlock extra', '.locked', undefined]) e.chat(e.players[0], text);
|
||||||
|
e.chat(undefined, '.lock'); e.chat({}, '.lock');
|
||||||
|
assert.equal(e.messages.length, 0);
|
||||||
|
assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
check(`${game}: round-20 auto-lock runs once; unlock persists across later rounds`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
e.round(19); assert.equal(e.password, '');
|
||||||
|
e.round(20); assert(e.password);
|
||||||
|
const writes = e.writes.length;
|
||||||
|
e.round(20); e.round(21); assert.equal(e.writes.length, writes);
|
||||||
|
e.chat(e.players[0], '.unlock');
|
||||||
|
e.round(22); e.round(40); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[0], '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: early lock/unlock stays open through round 20; later relocks use a fresh password`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
e.chat(e.players[0], '.lock'); const first = e.password;
|
||||||
|
e.chat(e.players[0], '.unlock');
|
||||||
|
e.round(20); e.round(21); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[0], '.lock'); assert(e.password); assert.notEqual(e.password, first);
|
||||||
|
});
|
||||||
|
check(`${game}: an already-open reply cannot cancel automatic locking`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
e.chat(e.players[0], '.unlock');
|
||||||
|
e.round(20); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: early lock is retained at the automatic milestone without another announcement`, () => {
|
||||||
|
const e = engine(game, 1);
|
||||||
|
e.chat(e.players[0], '.lock'); const writes = e.writes.length, messages = e.messages.length;
|
||||||
|
e.round(20); assert.equal(e.writes.length, writes); assert.equal(e.messages.length, messages);
|
||||||
|
e.chat(e.players[0], '.unlock'); e.round(21); assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
check(`${game}: custom automatic threshold below round 6 still permits unlocking`, () => {
|
||||||
|
const e = engine(game, 1, 3);
|
||||||
|
e.round(2); assert.equal(e.password, '');
|
||||||
|
e.round(3); assert(e.password);
|
||||||
|
e.chat(e.players[0], '.unlock'); assert.equal(e.password, '');
|
||||||
|
e.round(4); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[0], '.lock'); assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
check(`${game}: automatic lock clears an incomplete manual vote`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.round(19); e.chat(e.players[0], '.lock');
|
||||||
|
e.round(20); assert(e.password); assert.equal(e.level.lock_vote, undefined);
|
||||||
|
e.chat(e.players[1], '.unlock'); e.chat(e.players[2], '.unlock'); assert(e.password);
|
||||||
|
e.chat(e.players[0], '.unlock'); assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
check(`${game}: repeated and opposite commands do not extend or cancel a pending vote`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.chat(e.players[0], '.lock'); const deadline = e.level.lock_vote.deadline;
|
||||||
|
e.advance(30000); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.unlock');
|
||||||
|
assert.equal(e.level.lock_vote.deadline, deadline);
|
||||||
|
assert.equal(e.c.lock_vote_tally().yes, 1);
|
||||||
|
e.advance(30000); assert.equal(e.level.lock_vote, undefined); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[1], '.lock'); e.chat(e.players[2], '.lock'); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[0], '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: the exact deadline rejects old votes before processing chat`, () => {
|
||||||
|
const e = engine(game, 3);
|
||||||
|
e.chat(e.players[0], '.lock'); e.jump(60000); e.chat(e.players[1], '.lock');
|
||||||
|
assert.equal(e.password, ''); assert.equal(e.c.lock_vote_tally().yes, 1);
|
||||||
|
e.chat(e.players[0], '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: votes pass just before the deadline`, () => {
|
||||||
|
const e = engine(game, 2);
|
||||||
|
e.chat(e.players[0], '.lock'); e.advance(59999); e.chat(e.players[1], '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: departed voters do not count and reconnects get no inherited ballot`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
const departed = e.players[0];
|
||||||
|
e.chat(departed, '.lock'); e.disconnect(departed);
|
||||||
|
const newcomer = e.connect(); e.advance(500);
|
||||||
|
e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); assert.equal(e.password, '');
|
||||||
|
e.chat(newcomer, '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: roster shrink can complete a current majority`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock');
|
||||||
|
e.disconnect(e.players[3]); e.advance(500); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: roster growth raises the majority before accepting more votes`, () => {
|
||||||
|
const e = engine(game, 3);
|
||||||
|
e.chat(e.players[0], '.lock'); const newcomer = e.connect();
|
||||||
|
e.chat(e.players[1], '.lock'); assert.equal(e.password, '');
|
||||||
|
e.chat(newcomer, '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
check(`${game}: an empty roster cannot pass a vote`, () => {
|
||||||
|
const e = engine(game, 2);
|
||||||
|
e.chat(e.players[0], '.lock'); e.players.splice(0); e.advance(500);
|
||||||
|
assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
check(`${game}: dead, spectator and AFK connections have equal votes`, () => {
|
||||||
|
const e = engine(game, 3);
|
||||||
|
e.players[0].sessionstate = 'spectator'; e.players[1].afk = true;
|
||||||
|
e.chat(e.players[0], '.lock'); assert.equal(e.password, '');
|
||||||
|
e.chat(e.players[1], '.lock'); assert(e.password);
|
||||||
|
});
|
||||||
|
for (const event of ['hud_game_over', 'end_game', 'game_ended', 'intermission']) {
|
||||||
|
check(`${game}: ${event} clears the password/vote and stops all workers`, () => {
|
||||||
|
const e = engine(game);
|
||||||
|
e.round(20); e.chat(e.players[0], '.unlock');
|
||||||
|
e.emit(event);
|
||||||
|
assert.equal(e.password, ''); assert.equal(e.level.lock_vote, undefined);
|
||||||
|
assert(e.tasks.every(t => t.done));
|
||||||
|
const writes = e.writes.length;
|
||||||
|
e.chat(e.players[1], '.lock'); e.c.lock_request(e.players[1], '.lock');
|
||||||
|
e.round(21); e.advance(61000); e.emit('hud_game_over');
|
||||||
|
assert.equal(e.writes.length, writes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const flag of ['intermission', 'gameended', 'tws_ended']) {
|
||||||
|
check(`${game}: ${flag} blocks state changes before cleanup notifications arrive`, () => {
|
||||||
|
const e = engine(game, 2);
|
||||||
|
e.chat(e.players[0], '.lock'); e.level[flag] = true;
|
||||||
|
e.chat(e.players[1], '.lock'); e.disconnect(e.players[1]); e.advance(500); e.round(20);
|
||||||
|
assert.equal(e.password, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`PASS ${checks} T5/T6 lock vote/lifecycle checks (engine shims; live validation required)`);
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
+12
-12
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"tws_t5_afk.gsc": "1.2.0",
|
"tws_t5_afk.gsc": "2.1.0",
|
||||||
"tws_t5_bank.gsc": "2.0.3",
|
"tws_t5_bank.gsc": "2.0.3",
|
||||||
"tws_t5_common.gsc": "1.4.0",
|
"tws_t5_common.gsc": "1.5.0",
|
||||||
"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.2.0",
|
||||||
"tws_t5_lock.gsc": "1.0.0",
|
"tws_t5_lock.gsc": "1.1.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.1.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.5.0",
|
||||||
"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.2.0",
|
||||||
"tws_t6_lock.gsc": "1.0.0",
|
"tws_t6_lock.gsc": "1.1.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