Compare commits

...
15 Commits
Author SHA1 Message Date
josh caaaa102a4 Allow solo and last-active-player AFK 2026-10-17 22:19:04 -04:00
josh c19bef6dc6 Add proximity-based map record reminders 2026-10-17 21:54:13 -04:00
josh 6781a4ed5a Add persistent AFK reserves for both engines (Refs #12) 2026-10-17 16:56:06 -04:00
josh 7b95f604b4 Center zombie counters and use ASCII HUD separators 2026-10-17 16:12:39 -04:00
josh d7bf16e9c2 Add identity-safe player moderation runtimes for T5 and T6 2026-10-15 21:35:19 -04:00
josh 2b5b1955bc Simplify T5 and T6 HUD overlays and service displays 2026-10-15 21:07:10 -04:00
josh 929bf0354b Add fixed core HUD and shared service window for T5 and T6 2026-10-14 15:55:22 -04:00
josh d15bc50b7d Update Discord HUD labels and bump both scripts to 1.1.2 2026-10-13 23:25:28 -04:00
josh 2123b8f4d5 Simplify bank balance messages in T5 and T6 2026-10-13 20:51:38 -04:00
josh 463b4bcc51 Add safe bank failure diagnostics for T5 and T6
Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
2026-10-13 10:02:36 -04:00
josh ab989395f7 Fix bank GUID serialization on T5 and T6 (Refs #15) 2026-10-12 23:45:44 -04:00
josh 3bb0b5178e Use public endpoint for game bank requests (Refs #13) 2026-10-12 22:20:37 -04:00
josh 9cce0be438 Add persistent cross-game player bank (Refs #3) 2026-10-12 20:45:41 -04:00
josh eda2c7e76d Add persistent Discord URL to T5 and T6 HUDs 2026-10-12 19:42:39 -04:00
josh 664e8da81b Fix T6 newcomer join eligibility after setup spawns 2026-10-12 19:38:57 -04:00
67 changed files with 12846 additions and 976 deletions
+745
View File
@@ -4,8 +4,753 @@ Changes are listed newest first, with an independent version for every individua
T5/T6 script and each native plugin. Current versions are recorded in T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning). [versions.json](versions.json); see the [version policy](README.md#versioning).
## 2026-10-17
### Allow solo and last-active-player AFK
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Remove the teammate requirement before reservation and after start
authorization on both engines. Solo players, the last active player and
players whose teammates are already AFK or awaiting authorization can enter.
- Remove the AFK team watcher that ended matches when no non-AFK survivor
remained. All players may be AFK together, and teammate death/disconnect
does not cancel protection. Keep reserve accounting, the local deadline,
cooldown, player-state checks and normal stock match-end settlement.
- Compatibility: no new dependency requirement. Existing common `1.4.0+`,
telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and
host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same
policy change using their existing engine-specific state checks.
- Validation: 47 AFK script behavior checks passed, including solo entry,
unavailable/pending teammates, teammate loss during authorization, all-AFK
play, solo return/expiration and stock match-end cleanup. The changed T6
script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries.
Portable checks use engine shims; live T5/T6 validation remains required.
No Windows plugin build, live server test or deployment was performed.
- Deployment: copy the updated AFK script for each installed game and restart
the map. No full server-process restart or additional deployment action is
required for this script-only update. See the [AFK guide](docs/afk.md) for
the updated live validation cases.
### Increase map-record reminders as players approach the target
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` |
- Add matching T5/T6 round-start reminders using the latest usable cached map
record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the
final five. Show matching/overtake milestones, then stop reminders after the
first overtake for the rest of the match. Milestones distinguish reaching a round
from saving the result after match completion; `.leaders` remains available.
Listen to the stock `start_of_round` event so reminders follow the actual
start, including on T6 where the counter advances during the round break.
Ignore duplicate starts and counter rewinds within the match.
- Common renders a compact record header and one proximity/milestone line for
six seconds, using two nonarchived elements in the existing service area.
Keep full join/`.leaders` announcements, all tied holders and 12-second pages.
Skip pending or active help/records, unready players and recent welcome/manual
record requests; opening help discards a reminder. Release elements on idle,
interruption, disconnect and game end. Both games work without optional HUD.
- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or
expired records suppress reminders; skipped reminders are not queued.
- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`.
There is no new native-plugin, backend or upstream dependency requirement.
Unchanged HUD, AFK and other scripts retain their individual versions.
- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join,
23 telemetry and 39 AFK portable checks passed, as did both moderation
script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool
`8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked
after the event-timing fix. JSON uniqueness, all 18 script manifest entries,
version/changelog consistency, local documentation links and `git diff --check`
passed. Portable checks use engine shims; no live Plutonium validation or
backend integration was performed. T5 has no gsc-tool compiler backend.
- Deployment: copy the updated leaderboard and common scripts together for
each installed game, then restart the map. No full server-process restart is
required for this script-only update. No deployment performed. Live T5/T6
rendering, readiness and round-transition validation remain required; see
[record validation](README.md#map-record-announcements).
### Persistent AFK reserve and immediate expiration (Refs #12)
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.0.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.0.0` | `t6/scripts/tws_t6_afk.gsc` |
| `t5-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
| `t6-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
- Replace unlimited AFK with shared GUID reserves, authenticated authorization,
explicit bank purchase confirmation, real contribution rewards and durable
request/session journals. Both engines use matching contracts and behavior.
- Show an independent central countdown, final-minute urgency and audible warnings.
Zero immediately releases AFK protection and owned controls/weapons. Manual
`.back` retains its separate two-second grace; it cannot renew an AFK session.
Reject downed activation (including T5), stock restrictions and last-active-player
entry. End the match when only AFK players remain, respecting stock recovery.
- Award each successful revive, including repeated revives of one teammate. Count
kill/headshot events, round participation and active engagement without using
score changes. EC owns configurable rates, caps, persistent progress and atomic
bank conversion; no second points bank is introduced.
- Compatibility: AFK `2.0.0` requires matching TWS plugin `1.4.0+` (native AFK API
`1.0.0`), common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+ and the EC #151
endpoints. EC host agent `1.12.0+` must be deployed before activation. Existing
private `tws_bank_token` and `tws_server_id` authenticate requests. There is no
new upstream dependency pin or native loader `plugin_version()` requirement.
- Deployment: deploy EC and upgraded host agents first; build/install both plugin
DLLs and matching scripts, retain persistent `tws-afk` storage, then **fully
restart each game-server process**. No deployment, Windows DLL build or live
Plutonium validation is claimed. See [AFK operation and validation](docs/afk.md).
- Validation: both engines pass native AFK journal/adapter tests with ASan/UBSan,
39 AFK behavior checks, 14 core HUD and 56 service HUD checks. All nine T6 scripts
compiled/assembled with upstream gsc-tool `8f1d6bd` T6 PC compiler/assembler
libraries. Script and integration checks are documented in the AFK guide; final
in-game T5/T6 validation remains required. Unchanged common/HUD/bank/telemetry
scripts retain their individual versions.
### Center the complete zombie count above the health bar
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.4.2` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.2` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the separately positioned number and label with one center-aligned
`N ZOMBIES` readout at the health track's midpoint in both engines. Use a
uniform font scale of `1.25` and the existing count color; digit changes and
the initial `-- ZOMBIES` placeholder share the same center.
- Update text only when the count changes using `SetTextUnlimited`, avoiding
a new config string for each distinct count. Keep counting, `.hud` visibility,
game-over termination and disconnect cleanup behavior. Core HUD allocation
decreases from 11 to 10 archived elements; AFK still adds one.
- Compatibility: HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334), which provides
`SetTextUnlimited` for T5 and T6. No new plugin or backend requirement.
Common and AFK need no source change for this fix; both HUD scripts change.
- Validation: 73 HUD checks passed, including digit transitions, a non-default
track midpoint, unchanged-count polling, hidden updates and service cleanup.
Leaderboard (143), bank (42), join (17) and telemetry (23) portable checks
passed. These use engine shims; actual EC integration and live Plutonium
validation were not run. GSC compilation was unavailable because gsc-tool
and PowerShell are not installed. Verify rendering on Windows/Plutonium.
- Deployment: copy each updated HUD script and restart the map on r5334+.
Older installations must first update Plutonium on clients and servers and
fully restart those processes. No full server-process restart is required
for the script update alone. No deployment performed.
### Use ASCII separators in HUD branding and map records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.1` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.1` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the middle dot with an ASCII vertical bar in the top-center branding
and map-record heading after the separator displayed as two dots on T5.
Apply the same text change to T6 for a consistent appearance. Keep existing
spacing, positioning and HUD/service behavior.
- Compatibility: no new dependency requirement. No native-plugin, backend or
other script changes are required.
- Validation: 71 HUD, 143 leaderboard, 42 bank, 17 join and 23 telemetry portable
checks passed. These use engine shims; actual EC bank integration, GSC
compilation and live Windows/Plutonium validation were not performed.
- Deployment: copy the updated common and HUD scripts for each installed game,
then restart the map. No full server-process restart is required. No deployment
performed; live Windows/Plutonium rendering validation remains outstanding.
## 2026-10-15 — Player moderation runtime
- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional
paired scripts register current GUIDs and use the existing shared connection
dispatcher. Require common 1.2.0, the matching native plugin 1.3.0, and Electric
Cherry's instance-authenticated moderation endpoint/private configuration.
- **t5-plugin 1.3.0 / t6-plugin 1.3.0**: add the native moderation component and
policy/journal utilities, connection-bound synchronous actions, durable cached
bans/mutes and expiry/revocation, and an opt-in bounded HTTP response limit.
Existing HTTP callers keep their previous limit behavior. Sources are each
plugin's component/moderation.cpp, utils/moderation.*, and utils/http.*.
Both games change for matching functionality; their chat opcodes remain distinct.
- Deploy the receiver first; build and install both matching DLL/script pairs,
supply separate instance credentials, and fully restart game-server processes.
Persistent tws-moderation directories must survive restart. No deployment or
live Windows/Plutonium verification is claimed. See [runtime contract and
validation](docs/moderation.md).
- Validation: paired native moderation policy/adapter checks passed with ASan and
UBSan, including separate-process journal and session-sequence recovery. The
GSC identity and retry/disable/resume checks, native HTTP cap/error checks,
existing HTTP component, bank and telemetry checks, metadata/link validation,
and `git diff --check` passed. Native adapter tests use engine shims; no DLL
build, GSC compilation or live game validation was performed.
## 2026-10-15
### Replace the dashboard panels with unboxed HUD overlays
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.2.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.2.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both games use the supplied minimal layout: unlabeled match/round timers at
upper left, combined branding at top center, zombies above a slim health bar
at bottom center, and numeric current/maximum HP to its right only while
injured. Center the AFK indicator above the zombie count. Remove dashboard
backgrounds while retaining outlined text and health colors.
- Keep `.hud` as a private visibility toggle for the entire core and AFK
indicator. Hidden data updates, frozen game-over values, connection/spawn
lifecycle, AFK protection and stock HUD behavior are unchanged.
- Move services into an unboxed upper-right area below the Plutonium tag.
Records use a compact right-aligned `Map record · Round N` header and up to
three existing bounded holder lines, with no footer or single-page counter.
Record data, cache behavior, all tied holders and 12-second paging remain
unchanged; leaderboard source and versions are unchanged.
- Show all nine installed commands, including `.hud`, in one help list with
full `.d <amount|all>` / `.w <amount|all>` syntax. Both engines use explicit
command/description rows; extra registrations use pages of nine, with a
counter only for multi-page lists. Preserve private requests, help priority,
record pause/resume, replay replacement and 12-second page timing.
- Allocate only the active service view in both engines and destroy it when
idle or before switching kinds. Core + AFK use 11 + 1 archived elements;
nine-command help uses 19 nonarchived elements (20 with a multi-page counter),
and records use four (five with a multi-page counter). Idle holds none.
These counts do not establish live Plutonium rendering capacity.
- Common adds fullscreen overlay helpers using direct normalized `640 × 480`
positions and integer shader dimensions. Retain legacy panel helpers for
compatibility. Both HUD and AFK implementations use the new helpers.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 56 service/
static checks (71 total). Leaderboard passed 143 checks, bank 42, join 17 and
telemetry 23 through their portable check scripts. JSON uniqueness, the
16-script manifest, all six version/changelog matches, 66 local documentation
links/anchors and `git diff --check` passed. Checks use engine shims; actual
EC bank integration was not run. GSC compilation was not run because
gsc-tool/PowerShell are unavailable; live Windows/Plutonium validation remains
outstanding.
- Compatibility: HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`.
Both engines receive companion changes; leaderboard `1.1.0` remains
compatible. No new Plutonium, native-plugin or backend requirement.
- Deployment: copy each game's common, HUD and AFK scripts together where
installed, then restart the map. No full server-process restart or plugin
update is required. No deployment performed. Windows/Plutonium layout,
element capacity, stock HUD overlap and lifecycle validation remain required.
## 2026-10-14
### Release T5 service elements when records or help close
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.1` | `t5/scripts/tws_t5_common.gsc` |
- Address core HUD elements disappearing when the first record announcement
opens and remaining absent after it closes. The previous T5 service allocated
all 24 help/record elements at once and retained them while idle. That pattern
is consistent with HUD allocation pressure; the live T5 SP capacity has not
been established.
- Allocate only the active service view, with at most 12 elements for help or
11 for records. Release the elements when the service becomes idle and before
switching between help and records. Reuse them for pages within the same
view, and render the page indicator as one cached text element. The single
controller, help priority, paused record timing and request replacement remain
intact. Core elements and their visibility setting are unchanged.
- T6 needs no source change: the reported failure is on T5, whose explicit help
rows expanded its former allocation to 24 elements. T6 retains its smaller
18-element multiline renderer; validate its existing behavior separately.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 53 service/
static checks (68 total), including active-view allocation, idle release,
repeated service cycles and core preservation. Leaderboard passed 143 checks,
bank 42, join 17 and telemetry 23 through their portable check scripts. These
use engine shims; actual EC bank integration was not run. Live Windows/
Plutonium rendering validation was not performed.
- Compatibility: HUD `1.3.0`, AFK `1.1.0` and leaderboard `1.1.0` remain
compatible. No new dependency requirement. Deployment: copy only
`tws_t5_common.gsc` and restart the map. No plugin update or full server-process
restart is required. No deployment performed; live T5 validation must confirm
core integrity during automatic records, help, view switches and dismissal.
### Add a per-player HUD visibility command
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.3.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.3.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.3.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both HUD modules handle the exact, case-insensitive `.hud` chat command to
toggle that player's core branding, timers, zombie count, HP/bar and AFK
indicator. Visibility defaults on, persists across death/respawn on the
current connection/map, and resets on reconnect or map restart.
- Common tracks core elements and their intended alpha values so changing
visibility preserves each element's state. Data and timers continue updating
while hidden, and game-over values remain frozen. AFK routes only its
indicator visibility through these helpers; protection behavior is unchanged.
Help, records and stock HUD elements are unaffected.
- Register `.hud` in help only when the HUD module is installed. The full
command order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`,
`.d`, `.w`: nine commands across pages of four, four and one. T5 retains
explicit help rows; T6 retains multiline columns.
- Compatibility: HUD `1.3.0` and AFK `1.1.0` require their matching common
`1.3.0+`. Both engines receive companion implementations. No new Plutonium,
native-plugin or backend dependency requirement.
- Deployment: update each game's common, HUD and AFK scripts together where
installed, then restart the map. No plugin update or full server-process
restart is required. No deployment performed; GSC compilation and live
T5/T6 validation remain required.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 48 service/
static checks (63 total), covering private visibility, intended opacity,
late-created elements, hidden updates/frozen clocks, AFK transitions, service
independence, conditional help and three-page ordering. Leaderboard passed
143 checks, bank 42, join 17 and telemetry 23 through their existing portable
check scripts. These use engine shims; actual EC bank integration was not run.
GSC compiler and PowerShell were unavailable, and live Windows/Plutonium
validation was not performed.
### Render T5 help as separate rows
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.6` | `t5/scripts/tws_t5_common.gsc` |
- Replace T5's newline-separated help columns with four preallocated
command/description pairs, positioned on separate rows. This addresses BO1
rendering the newline text as one concatenated line. Reuse the row elements
across pages and hide unused rows on short pages.
- Preserve panel dimensions, four commands per page, 12-second timing and the
removed help footer. T5's service now uses 24 nonarchived HUD elements.
T6 needs no source change: its existing multiline renderer remains in place
with 18 service elements; the reported rendering issue was observed in T5.
- Validation: `node tools/check_hud.mjs` passed 8 core and 42 service checks
(50 total), covering both engines' pagination and T5's distinct row elements,
partial-page hiding, reuse, record transitions and disconnect cleanup.
GSC compilation and live T5 display/notification validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only the updated T5 common script and restart
the map. No plugin update or full server-process restart is required.
No deployment performed; live T5 display validation remains required.
### Remove the help footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.5` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.5` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove the `Four commands per page` footer and its HUD element.
Help still displays four commands per page with its page indicator and
existing timing. The shared service now uses 18 nonarchived elements.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including the 18-element allocation and removed help footer.
GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Hide the page indicator for single-page records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.4` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.4` | `t6/scripts/tws_t6_common.gsc` |
- Both games hide `1 / 1` when the record service has only one page. Records
spanning multiple pages and all help pages retain their page indicators.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including empty/unavailable records, multi-page indicators,
replacement by a single-page record, resuming after help, and single-page
help indicators. GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Restore panel backgrounds and health bars
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.3` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.3` | `t6/scripts/tws_t6_common.gsc` |
- Both engines apply the final integer conversion after `max()` in
`make_panel_width()`. Native `max()` returns a float, which `SetShader`
rejects for its integer width argument; converting before `max()` left
backgrounds and health-bar shaders missing. The corrected helper supplies
integer widths to both panel creation and health-bar updates.
- Retain panel color `(0.07, 0.10, 0.08)`, opacity `0.72`, and the corrected
text positions/font sizes. HUD source is unchanged and remains `1.2.1`.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total). The new regression models native float typing and strict integer
shader widths, and checks the existing panel color/opacity. GSC compiler and
PowerShell were unavailable; GSC compilation and live Windows/Plutonium display
validation were not performed.
- Compatibility: use matching common `1.2.3+` with HUD `1.2.1`; no new
Plutonium, native-plugin or backend dependency requirement. Deployment: if
HUD `1.2.1` is already installed, copy only each game's updated common script
and restart the map. No plugin update or full server-process restart is
required. No deployment performed; verify restored shaders in T5 and T6.
### Correct footer font size and screen-edge panel placement
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.2` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.2` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.2.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.2.1` | `t6/scripts/tws_t6_hud.gsc` |
- Both engines enforce a minimum text font scale of `1.0`, with both service
footers explicitly at `1.0`, addressing the oversized `.leaders` footer.
- Use fullscreen horizontal and vertical anchors so panels follow the actual
upper-right screen edge instead of safe-area insets. Convert horizontal
design offsets and shader widths by `0.75` into the normalized 640-wide
viewport; use a top offset of 20. The 1920×1080 reference yields a 495-pixel
panel, 22.5-pixel right margin and 45-pixel top margin, leaving space for the
Plutonium tag. Other aspect ratios retain normalized horizontal geometry,
so physical proportions vary; live font fit and tag clearance need validation.
- Both HUD scripts use common's new `make_panel_width(width)` for health-bar
updates so the fill uses the same width conversion as its track. T5 and T6
receive the same fix with their matching namespaces.
- Compatibility: HUD `1.2.1` requires matching common `1.2.2+`; upgrade common
and HUD together. No new Plutonium, native-plugin or backend dependency is
required.
- Deployment: copy each game's updated common and HUD scripts and restart the
map. No full server-process restart is required. No deployment performed;
GSC compilation and live Windows/Plutonium display validation remain required.
- Validation: `node tools/check_hud.mjs` passed 8 core and 39 service/static
geometry checks (47 total); `node tools/check_leaderboard.mjs` passed 143
checks. The geometry shims cover 4:3, 16:9 and ultrawide bounds in a normalized
640×480 frame, not live glyph fit or tag clearance. GSC compiler and PowerShell
were unavailable; no GSC compilation or live display validation was performed.
### Shorter map-record footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.1` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove `Briefly shown` and its separator from the map-record
footer, retaining `.leaders to view again`. Record timing and replay behavior
are unchanged.
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks
using engine shims. GSC compilation and live Plutonium display validation
were not run.
- Compatibility: no new dependency requirement. Deployment: copy each game's
updated common script and restart the map. No plugin update or full
server-process restart is required. No deployment performed; GSC compilation
and live T5/T6 display validation remain required.
### Fixed upper-right core HUD and shared service window
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.2.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.2.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_leaderboard.gsc` | `1.1.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.1.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_afk.gsc` | `1.0.1` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.0.1` | `t6/scripts/tws_t6_afk.gsc` |
- Both engines use a fixed upper-right core panel below the top edge, with
`TWS ZOMBIES`, `discord.tws.lol`, side-by-side match/round timers, zombie count,
current/maximum HP even at full health, and a full-width health bar. Replace
the co-op name row with reserved footer space for the AFK indicator. Core
creation on connection supports spectator placeholders without seeding the
match clock early. Preserve each engine's timer behavior and frozen clocks at
game end; health and zombie-count workers now freeze on both engines as well.
Missing max-health values use a display fallback without modifying gameplay health.
- Common owns one fixed-height service window below the core. Help shows four
commands per page for 12 seconds each, in stable order and filtered by loaded
modules. `.help` / `.commands` restart at page one. The final page dismisses
automatically; opening a service never shifts or resizes the core.
- Render help with two fixed multiline text columns and a muted command-column
backdrop. The service uses 19 nonarchived HUD elements regardless of command
or page count; the core and AFK use 15 + 1 archived elements. Reuse text and
numeric displays instead of allocating elements for every command/page.
- Records use the same window, with a prominent round, page count, replay hint,
and up to three holder lines per page. Abbreviate overlong individual names
without splitting UTF-8 characters; retain every tied holder across bounded
lines and pages. Help pauses records and resumes their remaining page time;
`.leaders` during help replaces the pending record from page one. Unavailable
records are distinct from an empty recorded history. Help and records remain
private and work without the HUD module. Reused elements and request state
protect newer displays from stale timers and clean up on disconnect/game end.
- Compatibility: HUD `1.2.0`, leaderboard `1.1.0`, and AFK `1.0.1` require the
matching game's common `1.2.0+`. Existing common helper signatures remain
available for unchanged consumers. No new native-plugin, backend or external
dependency requirement; existing leaderboard HTTP/JSON requirements still apply.
Both T5 and T6 receive companion implementations adapted to their engines.
- Deployment: copy each game's matching common, HUD, leaderboard and AFK scripts
that are installed, then restart the map. No plugin update or full server-process
restart is required for this change. No deployment performed.
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks;
`node tools/check_leaderboard.mjs` passed 143 checks, `node tools/check_bank.mjs`
passed 42 fixture groups, `node tools/check_join.mjs` passed 17 checks, and
`node tools/check_telemetry.mjs` passed 23 checks. These use engine shims.
No local GSC compiler or PowerShell was available; GSC compilation and live
Windows/Plutonium validation remain required. Follow the
[HUD validation guide](README.md#hud-validation) before rollout.
## 2026-10-13
### Shorter Discord URL
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.1.2` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.1.2` | `t6/scripts/tws_t6_hud.gsc` |
- Both games replace the displayed Discord URL with `https://discord.tws.lol`.
Keep the smaller font and muted gray introduced in the already-uploaded
`1.1.1` release.
- Compatibility: no new dependency requirement; matching common `1.1.0+`
remains required. Deployment: copy each updated HUD script and restart the
map. No plugin update or full server-process restart is required. No
deployment of `1.1.2` performed here; GSC compilation and live T5/T6 display
verification remain required.
### More subtle Discord label
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.1.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.1.1` | `t6/scripts/tws_t6_hud.gsc` |
- Both games reduce the top-left Discord URL font scale from `1.4` to `1.2`
and darken its neutral gray from `(0.85, 0.85, 0.85)` to `(0.65, 0.65, 0.65)`.
The label keeps its position and connection lifetime; other HUD text is
unaffected.
- Compatibility: no new dependency requirement; matching common `1.1.0+`
remains required. Deployment: copy each updated HUD script and restart the
map. No plugin update or full server-process restart is required. The user
reports `1.1.1` has been uploaded; GSC compilation and live T5/T6 appearance
verification have not been performed here.
### Simplified bank balance message
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.3` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.3` | `t6/scripts/tws_t6_bank.gsc` |
- Both games now display `.b` as `Bank: {balance} points`, retaining the
existing colors and omitting the available amount. Update startup version
reporting and the existing balance-message fixtures. API data and point
accounting are unchanged.
- Validation: all 42 portable bank script fixture groups passed with
`node tools/check_bank.mjs`, including exact balance text for both games.
- Compatibility: no new dependency requirement; matching TWS plugins `1.2.0+`
remain required. Deployment: copy each updated bank script and restart the
map. No plugin update or full server-process restart is required for this
change. No deployment performed; GSC compilation and live T5/T6 display
verification remain required.
### Bank failure diagnostics — TWS #14
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.2` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.2` | `t6/scripts/tws_t6_bank.gsc` |
| `t5-plugin` | `1.2.0` | `t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
| `t6-plugin` | `1.2.0` | `t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
- Both engines log startup script/loaded TWS plugin versions, effective endpoint,
safe server identity, credential format validation and pending journal count.
Add diagnostic correlation for every HTTP attempt without changing API IDs;
balance requests keep their empty acknowledgment ID contract.
- Distinguish creation, transport, HTTP, missing diagnostics, JSON/schema,
response-ID and application failures, with safe HTTP/curl/timing fields.
Native acknowledgment parsing adds fixed failure reasons without changing
existing validity rules. Only known bounded response codes reach logs; never
log credentials, headers, bodies or native exception contents.
- Log journal load/write/removal failures and settlement decision, attempts,
pending duration, next retry delay and resolution. Suppress identical failures
for 30 seconds, report suppression counts, and log reason changes and recovery
immediately. Recovered pending duration starts at this map's journal load.
Preserve points, durable decisions, retry behavior and player-facing messages.
- Compatibility: both scripts require matching plugin `1.2.0+` for the additive
`bankVersion()` native and parser reason field. No new EC/agent or third-party
dependency requirement; existing common/telemetry and migration requirements
remain. Upstream pins and native loader `plugin_version()` are unchanged.
- Deployment: build/install both DLLs and **restart the full server processes**;
install matching scripts, restarting the map if copied after process startup.
Verify installed DLL/GSC hashes against the release artifacts and startup
version logs, particularly Ascension's reported stale GSC artifact. No deployment
performed. Windows DLL builds, GSC compilation and live T5/T6 checks remain
required; see [bank diagnostics](README.md#bank-diagnostics--tws-14).
- Validation: 44 portable script fixture groups passed, including actual EC
bank service/SQLite integration at checkout `d614707`. Fixtures assert safe
startup/outcome logs, missing diagnostics, HTTP 401, timeout, malformed/schema
acknowledgments, ID mismatch, storage failures, rate limiting and recovery,
alongside existing accounting/replay tests. Native parser/storage/process-restart
and registration-adapter checks run with address/undefined-behavior sanitizers.
## 2026-10-12 ## 2026-10-12
### String bank identities — TWS #15
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.1` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.1` | `t6/scripts/tws_t6_bank.gsc` |
- Normalize T5's `player getGuid()` and T6's persistent `player.guid` to
strings before balance/prepare requests or durable cancel/commit decisions.
Preserve large and zero-prefixed string GUIDs without numeric conversion.
Reject missing, unsupported or invalid identities privately before HTTP,
journaling or score changes, using EC's 1–64 character token contract.
- Both engines receive the fix. T6 uses the same field as telemetry, populated
by stock connection initialization; fixtures no longer give T6 a getter shim.
Other scripts and native plugins are unchanged.
- Checks: `node tools/check_bank.mjs` covers numeric/string/missing identities,
balance, prepare, both decisions and exact-body replay after restart.
Its optional `--ec-root` mode executes the actual EC bank service and bank
migrations against isolated SQLite, covering the original numeric rejection,
deposits, withdrawals, zero-balance `.w all`, cancellation and idempotent
commit recovery. All 34 fixture groups passed with EC checkout `d614707`;
`python3 tools/test_plugin_bank.py` also passed both engines' native
storage/response, process-restart and component checks with sanitizers.
- Compatibility: no new dependency requirement or EC source change. Existing
plugins `1.1.0`, common/telemetry `1.0.0+`, and EC agent `1.9.1+` remain
sufficient. Deployment: copy each updated bank script to its game's script
directory and restart the map; no full server-process restart is required
for this fix. Continue following the endpoint migration procedure if also
upgrading from a legacy URL.
- This fix prevents new invalid journals; it does not rewrite old pending
decisions. No production recovery or deployment was performed as part of
this change.
GSC compilation, deployed-artifact verification and live T5/T6 command checks
remain required; see [bank identity validation](README.md#bank-identity-validation).
### Public game-bank endpoint — TWS #13
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.0` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.0` | `t6/scripts/tws_t6_bank.gsc` |
- Use `https://ec.thewrightserver.net/api/bank` for all balance, transfer and
settlement requests on both engines. Remove the `tws_bank_url` override;
stale settings are ignored. This configuration removal requires a major bump.
Keep EC's `tws_server_id` and private per-instance `tws_bank_token` authentication.
- Both T5 and T6 receive the same behavior with their existing namespaces and
startup guards. Native plugins and other scripts need no source changes.
- Compatibility: requires companion EC agent `1.9.1+` to recognize public and
legacy controller-derived journals during authenticated teardown settlement.
Existing plugin `1.1.0`, common `1.0.0+` and telemetry `1.0.0+` remain sufficient;
no new native API or third-party dependency requirement.
- Deployment: update agents first. When changing endpoints, **rebuild through
EC** so the old process stops and its pending decisions settle before a new
process starts. Do not switch scripts with just a map restart in that case;
journals are endpoint-scoped. If the old URL already matches exactly, copy the
scripts and restart the map; no full server-process restart is required.
See [endpoint upgrade and recovery](README.md#upgrading-the-bank-endpoint).
No deployment was performed; agent release signing, Windows/GSC compilation
and live T5/T6 validation remain required.
- Checks: portable script fixtures exercise real initialization with stale
overrides, every request type, authentication, empty balances and retries.
Native fixtures cover zero-balance and insufficient-balance replies alongside
existing durable storage and process-restart checks.
### Persistent cross-game player bank — TWS #3 / Electric Cherry #146
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `1.0.0` | New `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `1.0.0` | New `t6/scripts/tws_t6_bank.gsc` |
| `t5-plugin` | `1.1.0` | `t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
| `t6-plugin` | `1.1.0` | `t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
- Add `.b`, `.d <amount|all>` and `.w <amount|all>` plus help registration on
both engines. Store shared GUID balances and ledger history through Electric
Cherry's authenticated bank API. EC generates and injects a private token per
immutable server instance, preserving it on rebuild/move and revoking it on
retirement. No manual token map is required;
the shared telemetry token does not authorize bank operations.
- Add native `bankLoad`, `bankStore`, `bankRemove` and `bankResponse` helpers.
Save cancellation before preparing a transfer, replace it with a commit
decision before applying points without yielding, and replay settlement
after map/process restarts. Invalid replies, insufficient funds, changed
player state, and failed journal writes leave spendable points unchanged.
Preserve earned `score_total`; applied withdrawals belong to the current match.
- T5 and T6 receive equivalent behavior with their own namespaces, last-stand
APIs, entry points and journal directories. Other scripts are unchanged;
existing common help registration and telemetry match keys already suffice.
- Compatibility: deploy Electric Cherry #146 first, then each game's plugin
`1.1.0` and bank script `1.0.0`, with common and telemetry `1.0.0` or newer.
EC host agents `1.9.0+` settle pending journals before destructive teardown.
No new third-party dependency, upstream pin, or loader version requirement.
Keep `tws-bank/<game>/` on persistent writable storage; preserve pending
journals when rotating credentials or restarting servers.
- Deployment: build/install both Windows x86 DLLs and **restart the full server
processes**, then install the new bank scripts and create/rebuild through EC
to inject private credentials. Restart maps after script changes. See
[bank setup and recovery](README.md#player-bank-t5-and-t6).
No deployment was performed. Windows DLL linking, GSC compilation and live
Plutonium checks remain required.
- Checks: `node tools/check_bank.mjs` exercises the actual transfer functions;
`python3 tools/test_plugin_bank.py` checks native journals, response validation,
failed writes and process recovery with sanitizers. The T6 compiler check
now includes all eight modules.
### T6 newcomer join eligibility — `tws_t6_join.gsc` `1.0.1`
- Fix `t6/scripts/tws_t6_join.gsc` treating the initial T6 setup spawn as
gameplay, which rejected a new spectator's `.join` on Farm Survival round 3.
Wait for the stock delayed spectator transition, then record actual play.
Continue observing later spawns so waiting for the next round cannot grant
an extra life after death. Reject requests while a spawn is settling; a real
death during that window also permanently closes eligibility.
- T5 is unchanged: its separate SP spawn flow and `self.initialized` guard
do not use T6's delayed game-mode spectator transition.
- Compatibility: existing T6 common `1.0.0` or later; no new dependency
requirement. Deploy the updated join script to `storage/t6/scripts/zm/`
and restart the map. No full server-process restart is required.
- Portable regression fixtures: `node tools/check_join.mjs`. Compilation and
live Plutonium validation remain required; no deployment was performed.
### Persistent top-left Discord URL
| Script | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.1.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t5_common.gsc` | `1.1.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_hud.gsc` | `1.1.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t6_common.gsc` | `1.1.0` | `t6/scripts/tws_t6_common.gsc` |
- Add `https://discord.thewrightserver.net` as a small, light-gray top-left
HUD label, created once per connection, including spectator newcomers.
It has no expiry or death/respawn reset and is cleaned up on disconnect.
Move the gameplay stack down 20 HUD units to leave room above the timers;
help and record positioning follow the shared layout automatically.
- Both engines receive the feature using their existing HUD anchors: T5
left/top safe-area alignment and T6 fullscreen alignment.
- Compatibility: deploy each game's HUD and common `1.1.0` together for the
reserved top line. No new native plugin or external dependency requirement.
Other scripts and both plugins retain their existing versions.
- Deployment: copy the updated HUD and common scripts to `storage/t5/scripts/sp/`
or `storage/t6/scripts/zm/`, respectively, and restart the map. No full
server-process restart is required. No deployment has been performed here.
- Live validation remains required on both Plutonium games: check placement
and readability, join as a spectator, die/respawn, reconnect, and open help
and records to verify spacing and that only one URL label appears.
### Initial versioning baseline ### Initial versioning baseline
Established independent version `1.0.0` entries for each of the 14 scripts and Established independent version `1.0.0` entries for each of the 14 scripts and
+398 -11
View File
@@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal
changes, the minor for compatible features, and the major for breaking changes changes, the minor for compatible features, and the major for breaking changes
to commands, configuration, native APIs, or data contracts. Reset lower fields to commands, configuration, native APIs, or data contracts. Reset lower fields
when increasing the minor or major. Bump once per logical change; documentation-only when increasing the minor or major. Bump once per logical change; documentation-only
and test-only changes are exempt. All 14 scripts and both plugins advance independently. and test-only changes are exempt. All 18 scripts and both plugins advance independently.
The initial `1.0.0` versions document the existing source baseline. These TWS The initial `1.0.0` versions document the existing source baseline. These TWS
versions are separate from upstream revisions in each plugin's `upstream.json` versions are separate from upstream revisions in each plugin's `upstream.json`
@@ -170,18 +170,341 @@ Modules currently in each tree:
| lock | `t5/scripts/tws_t5_lock.gsc` | `t6/scripts/tws_t6_lock.gsc` | | lock | `t5/scripts/tws_t5_lock.gsc` | `t6/scripts/tws_t6_lock.gsc` |
| telemetry | `t5/scripts/tws_t5_telemetry.gsc` | `t6/scripts/tws_t6_telemetry.gsc` — queued `game_start` / `heartbeat` / `round_change` / `game_end` matching T5 | | telemetry | `t5/scripts/tws_t5_telemetry.gsc` | `t6/scripts/tws_t6_telemetry.gsc` — queued `game_start` / `heartbeat` / `round_change` / `game_end` matching T5 |
| join | `t5/scripts/tws_t5_join.gsc` | `t6/scripts/tws_t6_join.gsc` | | join | `t5/scripts/tws_t5_join.gsc` | `t6/scripts/tws_t6_join.gsc` |
| 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` |
## Player bank (T5 and T6)
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and
telemetry scripts (`1.0.0` or later). Players can use `.b` to read their balance,
`.d <amount|all>` to deposit spendable points, and `.w <amount|all>` to withdraw.
Configured bank modules register all three commands in `.help`. Balances are
shared by GUID across games and survive map/server restarts. Transfers do not
change `score_total`; bank-assisted matches still count on existing leaderboards.
Deploy [Electric Cherry #146](https://gitea.thewrightserver.net/josh/electric-cherry/issues/146)
first, then build/install both native plugins `1.2.0` and **restart the server
processes**. The plugins provide `bankLoad`, `bankStore`, `bankRemove` and
`bankResponse`. Removing the bank script is necessary to omit these linking
requirements; disabling it by dvar does not avoid linking.
Electric Cherry generates a unique bank token when it prepares a native server
instance and injects it through that instance's private
`electric_cherry_bank.cfg`, with `tws_server_id` supplied by the launch configuration.
Both bank scripts `2.0.0` always use `https://ec.thewrightserver.net/api/bank`;
`tws_bank_url` is no longer read, even if an old configuration still sets it.
The host agent's enrolled controller address is independent and continues to
serve agent traffic. Agent `1.9.1+` writes only the private `tws_bank_token` in
the bank configuration. Rebuilds and host moves keep the instance token;
destroying the instance revokes it. Reusing a server name gets a new token.
There is no `EC_BANK_TOKENS` map or per-server secret to maintain. Keep secrets
outside shared scripts and promoted configs.
Set `tws_bank_enabled 0` to disable the module; restart maps after script/config changes.
Electric Cherry's `EC_BANK_MAX_BALANCE` defaults to 250000 (range 1–1000000).
Each transfer and bank-funded spendable balance is capped at 1000000 points.
Transfers require a living player recorded in the server's current telemetry
match. Newcomers may wait until the next 30-second heartbeat. The game validates
points and player state again after a prepare reply; failed or uncertain
prepares leave game points unchanged and cancel the reservation. After points
move, a durable commit decision retries settlement without applying them again.
A transfer in progress blocks new bank requests on that server. A GUID can
have only one prepared transfer across the fleet.
Preserve `tws-bank/t5/` and `tws-bank/t6/`, relative to the game process working
directory, across process/container restarts, with one process per server/endpoint
directory. EC host agents **1.9.1+** settle
pending journals before deleting runtime storage on removal, rebuild, migration
or launch cancellation. Update the agents before enabling the bank. An outage
or invalid journal blocks cleanup and preserves the files. Decisions are scoped
to server name and URL and contain no token. Recoveries run before new bank requests;
failed cleanup/replies retry with the original identity. Corrupt storage
blocks the bank; failed writes prevent points changes. Restore storage or
credentials to finish pending work, rather than deleting the journal. Holds
have no automatic expiry. Already-applied withdrawals are match points and
are not refunded when a match ends or its process crashes. As with any live
score, the journal cannot make game memory and the remote database atomic
across a machine/storage failure.
### Bank diagnostics — TWS #14
Bank scripts `2.0.2` require their matching TWS plugins `1.2.0+`. Startup logs
report the script version, the loaded plugin's TWS version via `bankVersion()`,
the effective endpoint, a validated server ID, credential format validation,
and the loaded journal count. Credential validation checks the local format;
HTTP 401/403 still identifies a credential rejected by EC. A failed journal load
logs `pending=unknown` and disables the bank; invalid configuration reports that
journals were not loaded. Server IDs outside the bounded log-safe token alphabet
are shown as `invalid`/`unavailable` without changing the configured API identity.
Each HTTP attempt receives a local `correlation=bank-<map milliseconds>-<counter>`
for console diagnostics, including `.b`. These IDs are scoped to a map/process
log, never sent to EC, and never replace durable transfer IDs. Balance requests
still omit the API request ID and require an empty response ID. Transfer logs
also include their original request ID, linking prepare, journal and settlement.
Outcomes distinguish request creation, transport failure, HTTP rejection,
missing diagnostics/response, invalid acknowledgment (with a fixed parser reason),
request-ID mismatch, unexpected state, and valid application rejection. Available
HTTP status, curl code and cumulative DNS/connect/TLS/total milliseconds are
included; absent or malformed fields say `unavailable`. Only allowlisted response
codes reach logs; unfamiliar codes say `unknown`. No token, authorization header,
raw response/request body, transport exception or private configuration contents
are logged. Existing point accounting, journal format, API payloads, retry delays
and player-facing messages are unchanged.
Settlement lines add the durable decision, attempt count, pending seconds, next
retry delay, cleanup failures and eventual resolution. Pending time begins when
a new cancel decision is attempted; recovered journals measure from this map's
load, so downtime is not included. Counters reset on map/process restart. Repeated
identical failures per action are summarized at most every 30 seconds, with a
`suppressed` count. Reason/status/code changes and recovery log immediately.
Native storage logs similarly identify load/write/remove failures and recovery.
Rate-limited attempts retain their correlation internally; only emitted lines
appear in the console. No log history or retry metadata is added to the journal.
Build/install both updated DLLs and **restart each full server process**, then
install the corresponding scripts and restart the map if they were copied after
startup. `bankVersion()` is a new linking requirement even when the bank is
disabled by dvar. No EC/agent change or new third-party dependency is required;
existing bank API, common/telemetry and endpoint-migration requirements still apply.
Verify deployed DLL and GSC SHA-256 hashes against the built release artifacts,
and confirm startup reports `script=2.0.3 plugin=1.2.0` before interpreting results.
An old bank script checksum on Ascension was reported in issue #14; deploying
only the DLL does not update GSC. No deployment was performed here.
Run `node tools/check_bank.mjs --ec-root /path/to/electric-cherry` and
`python3 tools/test_plugin_bank.py`. On **both T5 and T6**, compile/link and test
successful `.b`/deposit/withdrawal, HTTP 401/403, timeout, malformed JSON/schema,
wrong response ID, unwritable/corrupt journals and settlement recovery after a
map/process restart. Check first/change/recovery logs, 30-second suppression,
HTTP timings, unchanged player messages/points and one remote ledger entry per
transfer. Keep journal files intact during failure tests. Windows DLL/GSC builds,
installed-artifact verification and these live game checks remain required.
### Bank identity validation
Bank scripts `2.0.1` use T5's `player getGuid()` and T6's persistent
`player.guid`, matching each game's telemetry identity source. Both normalize
integer identities to strings and preserve existing strings exactly, including
large or zero-prefixed GUIDs. Missing or invalid identities show a private
"Bank player identity unavailable" message before any request, journal write
or score change. GUIDs must meet EC's existing 1–64 character token contract
(`A–Z`, `a–z`, `0–9`, `_`, `.`, `:`, `-`). Persisted decisions and their retries
retain that original string identity, even after the player disconnects.
No new plugin or EC update is required for this fix. Install the updated bank
script in each game's script directory and restart the map. This does not
repair an older numeric-GUID journal: preserve any such pending decisions and
their request IDs for scoped recovery; never delete them to unblock a rebuild.
The endpoint upgrade procedure below still applies to legacy URLs.
Run `node tools/check_bank.mjs` for both scripts' portable fixtures. To also
exercise the actual EC bank service and bank migrations with isolated SQLite:
```sh
node tools/check_bank.mjs --ec-root /path/to/electric-cherry
python3 tools/test_plugin_bank.py
```
The optional EC check requires that checkout's installed `typescript` and
`better-sqlite3` dependencies. It uses no EC configuration, credentials, network
or production database. It covers numeric rejection, normalized balances and
transfers, zero-balance `.w all`, cancellation, and idempotent commit replay.
Native checks require the pinned JSON headers as described above. These tests
do not validate game VM linking, HTTP route authentication or deployed artifacts.
After deployment, compare the SHA-256 hashes of both installed bank scripts
with the source files from this release, and confirm the map restarted. On T5
and T6, check `.b`, a deposit, a withdrawal, and `.w all` with zero bank balance
(an insufficient-balance message with no point change). Confirm EC records
string GUIDs matching telemetry, one ledger entry per completed transfer, and
unchanged `score_total`. On a test server, interrupt settlement and restart the
map/process: the original request ID and decision must settle once without
reapplying game points. GSC compilation and these live checks remain required.
### Upgrading the bank endpoint
Deploy the Electric Cherry agent `1.9.1+` companion to
[TWS #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13)
before installing bank scripts `2.0.0`. It accepts journals for the public game
endpoint and the legacy bank URL derived from that host's enrolled controller.
It settles through the existing authenticated, instance-scoped agent API at the
controller; it never follows journal URLs and rejects other endpoint identities.
The instance token and server identity must stay the same through the upgrade.
**If the old `tws_bank_url` differs from the public endpoint, rebuild the server
through EC with agent `1.9.1+` and the new scripts.** The agent stops the old
process and settles its original commit/cancel decisions before starting the
replacement and deleting old storage. A lost reply replays the same request ID;
EC deduplicates it. An outage, corrupt journal or failed acknowledgment retains
storage and blocks the replacement. Resolve the failure and retry the rebuild.
Keep the enrolled controller address unchanged until legacy decisions drain.
Do not merely replace scripts and restart the map: native journals are keyed by
server and endpoint, so the new scripts cannot see a legacy endpoint's pending
decisions. Do not rename, rewrite or delete those journals to force migration.
If the previous endpoint already was the exact public URL, install the scripts
and restart the map; saved decisions replay under the same journal identity.
No new plugin build or full process restart is required for that case. Other
legacy endpoints are refused by the agent and require recovery with their
original configuration before switching. No deployment has been performed here.
Run `node tools/check_bank.mjs` and `python3 tools/test_plugin_bank.py`.
Before rollout, build/link Windows DLLs, compile GSC, and verify both games:
help, balance and `all` commands, score HUD/persistence, max/overdraw errors,
death/respawn/disconnect during HTTP, late telemetry, lost prepare/commit
responses, disk failures and map/process restart with pending decisions.
For this upgrade also verify stale URL settings are ignored, an empty `.b`
shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal
settles legacy and public journals without duplicate ledger entries or lost points.
## Core HUD and service window (T5 and T6)
The core uses a light, unboxed layout: match time above round time at the upper
left, `TWS ZOMBIES | discord.tws.lol` centered along the top, and the zombie
count centered near the bottom above a slim health bar. The complete
`N ZOMBIES` text uses one centered element at the health track's midpoint,
keeping the group centered as the count gains or loses digits. It uses a
uniform font scale of `1.25` and shows `-- ZOMBIES` before data is available.
Current/maximum HP appears to the right of the bar only while injured, then
disappears at full health. The AFK reserve countdown is independently centered on screen. Text
uses an outline for contrast; there are no panel backgrounds or timer labels.
Fullscreen X/Y anchors bypass safe-area insets. Overlay positions and shader
widths use the normalized `640 × 480` viewport directly. Text uses a minimum
font scale of `1.0`. Check the upper-left timers
against stock map inventory/objectives and performance overlays, and keep the
upper-right service area below the Plutonium tag. Physical proportions vary
with aspect ratio, so verify text fit and screen-edge clearance in-game.
The core is created once per connection and stays in place when services open,
across death and respawn, and while newcomers spectate. Unavailable values use
placeholders until the first-spawn callback binds the data displays. Creation
does not start the match clock. Each engine retains its timer behavior; clocks,
health and zombie-count displays freeze at game over. Health display fallbacks
never modify gameplay max health.
Type `.hud` in chat to hide or show your core, including its branding, timers,
zombie count, health bar and damage-only HP. The AFK countdown stays visible. The exact command
is case-insensitive. Visibility defaults on and persists across death/respawn
within the current connection/map; reconnecting or restarting the map restores
the visible default. Data and timers continue updating while hidden, so showing
the HUD restores current values or the frozen game-over values. Help, records
and the stock HUD remain unaffected.
Common owns one unboxed service area at the upper right. `.help` and its
`.commands` alias show installed commands in pages of up to nine rows with command
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
restriction. Additional registered commands use pages of nine rows; a page
count appears only when more than one page is needed. Each page lasts
12 seconds and the list closes after the final page. Repeating either help
command restarts page one. Both engines position rows explicitly, avoiding
T5's newline-rendering problem.
Records use the same service area, right aligned: `Map record | Round N`
followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. Help pauses a visible record and resumes its remaining
page time when help ends. `.leaders` during help replaces the pending record
with a fresh replay from page one. Round-start reminders use just the record
header and one proximity or milestone line for six seconds. They skip busy
services; opening help discards a reminder instead of resuming it later.
One service is visible at a time. Services remain private and work when the
HUD module is omitted.
Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's
elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements
still occupy slots. Compact reminders use two nonarchived elements.
The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) on both games.
The centered counter uses `SetTextUnlimited` only when the count changes,
avoiding dynamic counter strings in the text config-string pool. Numeric HP
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
the map; no full server-process restart is required. On older Plutonium
revisions, update clients and server and fully restart them before deploying.
Leaderboard `1.2.0` requires matching common `1.5.0+` for round-start reminders.
AFK `2.0.0` requires plugin `1.4.0+`,
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
leaderboard plugin requirements still apply. Common retains its older panel
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
with the same integer shader-width handling.
### HUD validation
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
reminder rendering, request replacement and cleanup checks, and
`node tools/check_leaderboard.mjs` for record data handling and reminder cadence.
Run `node tools/check_bank.mjs`,
`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged
command/lifecycle regressions. These
checks use engine shims and do not prove GSC VM behavior. Follow the
[T6 compiler instructions](t6/scripts/README.md#validation); T5 requires a live
Plutonium server because gsc-tool has no T5 backend.
Before rollout, validate both engines in Plutonium:
1. Check 16:9 and narrower/wider resolutions, safe-area settings, long match
timers, stock map inventory/objectives, performance overlays, player scores,
ammo and interaction prompts. Check upper-left timers, top-center branding,
bottom-center zombies/health and the upper-right service area. Text must stay
legible without panel backgrounds; services must clear the Plutonium tag.
Verify the health-bar fill stays within its track and damage-only HP clears
stock scores/ammo. Check text fit at other aspect ratios because horizontal
geometry scales with viewport width. Check `0`, `6`, `9`, `10`, `99` and
`100 ZOMBIES`: the entire text must stay centered over the health track,
including after `.hud` hides/shows it and after game over freezes the count.
2. Join before spawning, join mid-match, die/respawn and reconnect. Verify one
core per player, spectator placeholders, no early match-clock start, correct
health/Juggernog changes, and frozen timers at game over. HP numbers must
appear only while injured and disappear on full healing. Toggle AFK and back
without overlapping zombies/health or changing existing protection behavior.
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
the installed commands including AFK balance/buy, full bank syntax and a page counter and
automatic dismissal after 12 seconds. Add custom registered commands to
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
Remove optional modules and restart the map to confirm filtering, including
`.hud` disappearing without HUD, then restore them.
4. Interrupt records with help and check that the interrupted page resumes for
its remaining time. Repeat `.leaders` while help is open; only the latest
replay should start, at page one. Check long holder names, ties spanning
pages, no records, and unavailable records. Repeat without the HUD installed.
5. Disconnect or end the game while help/records are active, reconnect and
request them again. Verify no duplicate elements, stale timeout closures or
surviving service windows. Confirm `.join`, bank commands and lobby lock
retain their existing behavior with their required modules installed.
6. Toggle `.hud` and `.HUD` before spawning, during play, while AFK and after
game over. Check private visibility, all core elements and the independently visible AFK countdown,
continued updates while hidden, and unchanged help/records/stock HUD. Verify
death/respawn preserves the choice and reconnect/map restart restores visibility.
7. On T5, watch the first automatic record announcement and its dismissal, then
repeat `.leaders`, help and interrupted/resumed records. Verify the core
health bar, damage-only HP, zombie count and timers remain intact while a
service is visible and after it closes. Include stock round/connection
notifications and other installed HUD mods; portable allocation checks do
not establish live T5 SP rendering capacity.
## 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 the updated common module** for that game. **along with common `1.5.0+`** for that game when using leaderboard `1.2.0`.
The new module privately announces the current map's fleet-wide highest recorded Copy both scripts for each installed game and restart the map. This update
adds no native-plugin or backend requirement and needs no full server-process
restart when existing leaderboard dependencies are already installed.
The module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers. 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.
Repeating the command restarts the announcement from its first page. It appears Repeating the command restarts the announcement from its first page. It appears
in `.help` when the leaderboard module is enabled. If no usable record is in `.help` when the leaderboard module is enabled. If no usable record is
available, the command asks you to try again shortly. available, the service identifies the record as unavailable; this differs from
a valid empty record history. Neither state adds a replay footer.
It reads Electric Cherry's public `/api/leaderboard` endpoint without a token; It reads Electric Cherry's public `/api/leaderboard` endpoint without a token;
only completed matches contribute. Ties may come from the same run or separate only completed matches contribute. Ties may come from the same run or separate
runs. Names are the API's latest player names, with players identified by GUID. runs. Names are the API's latest player names, with players identified by GUID.
@@ -203,13 +526,44 @@ exist in the API's map list and match the current game and engine map. Clear it
when changing variants; restart the map after changing these settings. 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 in the same HUD area as `.help`, initial record fetch if necessary. It appears as right-aligned text in the
below the health bar, with a heading such as `Record: Round 23` and holder names common-owned upper-right service area: `Map record | Round N`, followed by up
underneath. Each page shows up to three holder lines for 12 seconds. Opening to three holder lines. There is no background or replay footer, and a page
`.help` hides the record and pauses its display time until help closes. count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining
All holders are included; colour/control codes are removed, and unusually long page time until help closes. A `.leaders` request during help replaces any
names are shortened to keep messages readable. HUD elements are removed when pending replay and starts from page one after help closes.
the announcement finishes, the player disconnects, or the game ends. All holders are included; colour/control codes are removed, and overlong
individual names are abbreviated without splitting UTF-8 characters. Bounded
lines and additional pages preserve every tied holder. The window closes when
the announcement finishes. Both games release their view elements when idle or
changing service kinds. Disconnect or game end cleans up any remaining service
elements.
At round start, reminders become more frequent as the current round approaches
the latest usable cached record. The cadence is measured back from that record:
| Rounds below the record | Reminder rounds |
| --- | --- |
| More than 20 | None |
| 11–20 | At gaps of 20 and 15 rounds |
| 6–10 | At gaps of 10, 8 and 6 rounds |
| 1–5 | Every round |
| Record reached | Matching-round milestone |
| First round above the record | Overtake milestone, then stop for the match |
For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and
45–49, with milestones at 50 and 51. Each uses the existing record header and
one short proximity or milestone line for six seconds, without holder pages.
Milestones describe the round reached; the new result is recorded only after
the match ends. The full join announcement and `.leaders` retain all holders
and their 12-second pages. `.leaders` remains available after the overtake.
Reminders require player readiness and skip players with a pending welcome,
help or record view, or a welcome/manual record request in the last 30 seconds.
Skipped reminders are not queued. Opening help discards an active reminder.
Empty, unavailable and expired snapshots produce no reminders. The existing
cache worker supplies all record data; round starts do not trigger HTTP requests.
Both games use the same behavior, including with the optional HUD omitted.
One level worker fetches the map list and pages of three players, stopping when 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.
@@ -243,6 +597,19 @@ page: each should start one private announcement from the first page. Repeat
while help is open, then disconnect or end the game during a replay to check 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
@@ -281,3 +648,23 @@ powershell -File t6/scripts/tools/check.ps1 -CompilerPath C:\path\to\gsc-tool.ex
``` ```
Requires PowerShell and `gsc-tool` (T6 PC). Requires PowerShell and `gsc-tool` (T6 PC).
## Player moderation (T5 and T6)
See [Moderation runtime](docs/moderation.md) for the instance credential contract,
policy persistence, command-result limits and deployment validation. New
`tws_t5_moderation.gsc` / `tws_t6_moderation.gsc` 1.0.0 require the corresponding
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
configuration it stays disabled. Existing bank and telemetry credentials are
never reused. Native plugin updates require a full game-server process restart.
## Persistent AFK reserves (T5 and T6)
See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior,
recovery, coordinated EC deployment and the required live validation. AFK `2.1.0`
requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`,
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
is required when installing the updated plugins.
Solo and last-active-player AFK are allowed, including all players being AFK
together. Upgrading only the AFK scripts from `2.0.0` requires a map restart,
with no new dependencies or full server-process restart.
+203
View File
@@ -0,0 +1,203 @@
# Persistent AFK reserve
AFK `2.0.0` replaces unlimited protected breaks on both T5 and T6. A GUID owns
one free allowance and saved reserve across servers, games, maps and reconnects.
Electric Cherry owns balances, policy, purchase transactions and the time ledger.
The game owns verified contribution events, player commands, countdown rendering,
protection and accepted stop decisions. This uses the existing bank account and
private instance credential, not a second points bank.
## Commands and behavior
| Command | Result |
| --- | --- |
| `.afk` | Reserve available time, acknowledge its start, then enter protected AFK |
| `.back` | Stop time accounting and return, preserving the existing two-second manual grace |
| `.afk balance` | Show free, saved and currently reserved seconds as a clock |
| `.afk buy 10` | Quote ten minutes and its bank-point price |
| `.afk buy 10 confirm` | Accept that quote within 30 seconds |
Return with `.back` before purchasing; an existing reservation never extends
automatically. Purchases require whole policy packs; the initial pack is 10 minutes for 10,000
points. There is no automatic purchase. EC atomically rejects insufficient funds,
pending bank transfers, changed quotes and over-capacity purchases without a
point debit. A submitted purchase retains its ID until EC acknowledges it.
The central countdown and “Protection ends when this timer reaches zero.” remain
visible even with `.hud` hidden. The final minute is larger and red; warnings
sound at 60, 30, and each of the final ten seconds. At zero, AFK protection ends
immediately, including in the damage callback; the script restores its own controls,
weapons and zombie-targeting state. Expiration has no spectator transition, purchase
or extra grace. Repeating `.afk` does not restart the timer or reserve twice.
AFK `2.1.0` allows solo players and the last active player to enter; all players
may be AFK at the same time. Entry still rejects downed/dead/spectating players
and stock protection/control restrictions. A default 15-second reentry cooldown prevents repeated
combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage
callback passes through when it does not own protection, preserving stock game
invulnerability. Other mods that restrict controls should set
`player.tws_afk_external_controls` while their restriction applies.
AFK leaves player session state unchanged, so stock player-count zombie scaling
continues to include AFK players. Losing the last non-AFK teammate does not end
protected breaks or force a match end. Authorized time continues to count down;
normal expiration and stock match-end cleanup still apply. Verify these rules
on each deployed map/mode.
## Rewards
Initial EC policy:
| Source | Time |
| --- | --- |
| Free allowance | 30 minutes per rolling 24 hours |
| Five minutes of active play | 1 minute |
| Completed round with participation | 15 seconds |
| 100 kills | 30 seconds |
| 25 headshot kills | 15 seconds |
| Each successful teammate revive | 30 seconds |
Saved earned and purchased time has a combined six-hour capacity and no expiry.
Free time is separate, non-accumulating and consumed first. EC retains fractional
reward progress across reconnects. Round/kill/headshot rewards share eight minutes
per rolling active hour; headshots stack with kills. Active-play rewards and
**every successful revive** are outside that cap. There is no per-teammate,
per-match or hourly revive limit. Retrying a report cannot award it again.
Both engines consume stock `zom_kill`, `player_revived` and `end_of_round`
notifications. T5 also attaches to stock hellhound spawn/death callbacks because
those enemies omit `zom_kill`; T6 hellhounds already emit the stock zombie death
event. Completed round numbers are deduplicated locally. Headshots require a lethal head/helmet hit with a qualifying damage
type. No reward uses spendable score or `score_total`. A round requires a kill,
revive, or at least 30 seconds of active engagement and eligibility at completion.
The one-second activity sampler uses movement over four units, a view change over
two degrees or a changed input button to refresh a 15-second engagement interval.
The stock `is_reviving` state counts support; holding a zombie that targets
the player within 512 units counts while deliberate input occurred in the last
60 seconds. Kill/revive events also refresh engagement. AFK, dead and spectator
time do not earn rewards. Contributions after manual `.back` count immediately,
including during its separate two-second grace. This permits support and zombie
holding without a minimum kill rate, while passive connection alone earns nothing.
Stock integration was checked against
[T5 scripts 70689b8](https://github.com/plutoniummod/t5-scripts/tree/70689b8288e60079d48926657e670c41aab4abdf)
and [T6 scripts 2d84403](https://github.com/plutoniummod/t6-scripts/tree/2d8440321602f22f63f5335817110d60ddf223d5).
Recheck callback contracts if the stock runtime changes.
## Configuration and coordinated rollout
1. Back up EC and the game runtime journals. Deploy EC #151 and restart its service.
2. Update all EC host agents to **1.12.0+** before enabling these scripts. This
adds AFK settlement before deleting/rebuilding stopped runtime storage.
3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK
**2.1.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and
Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes.
4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must
establish the current match before AFK commands can be authorized.
5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory,
on persistent writable storage with one process per server/endpoint scope.
Upgrading AFK scripts from `2.0.0` to `2.1.0` adds no dependency requirement:
copy the matching script for each game and restart the map. No full server-process
restart is required for this script-only update.
| Game dvar | Default | Meaning |
| --- | --- | --- |
| `tws_afk_enabled` | enabled | `0` disables new AFK service startup |
| `tws_afk_cooldown_seconds` | `15` | Reentry cooldown, clamped to 2–300 seconds |
| `tws_afk_warning_sound` | `zmb_perks_packa_ready` | Local warning sound alias; verify on deployed maps |
Economic configuration lives in EC's `EC_AFK_*` environment settings. See the
[EC AFK guide](https://gitea.thewrightserver.net/josh/electric-cherry/src/branch/main/docs/guides/afk.md)
for exact rates, rolling-window accounting and JSON schemas. The fixed game
endpoint is `https://ec.thewrightserver.net/api/afk`; requests reuse the private
`ec_bank_` credential. Shared telemetry tokens cannot spend or reserve time.
## API and recovery ownership
The matching native API `1.0.0` exposes `afkVersion`, `afkResponse`, `afkStore`,
`afkLoad`, `afkRemove`, `afkPrepare`, `afkAuthorize`, `afkBegin`, `afkRemaining`,
`afkCheckpoint`, `afkStop`, and `afkRecover`. Responses must validate their complete
schema and request/session identity before authorization takes effect. Requests
are immutable; journal writes precede network transmission and native activation.
EC actions are `balance`, `event`, `purchase`, `reserve`, `start` and `settle`.
Events carry cumulative counters scoped to GUID, instance and match, with a
monotonic sequence. The native journal coalesces newer cumulative event snapshots
to bound outage queues; EC prevents retries and reordered reports awarding twice.
Revive counts still increase for every distinct successful revive. Kill/active
progress is flushed at most every 30 seconds and on normal disconnect/end; a hard
process crash can lose progress that had not yet reached the local journal.
Already accepted rewards and journaled snapshots persist.
Protection starts only after reservation and start acknowledgments, a final
player-state recheck, and a durable native clock start. Once authorized, an EC
outage cannot shorten protection; a local monotonic deadline still ends it at zero.
Manual return, disconnect, death and match end stop accounting immediately. A
failed stop-journal write freezes the original stop time for retry rather than
charging during the delay. Never replay a reserve/start from a background sender.
Before activation, an interrupted session recovers as `never_started` with zero
consumption. Its tombstone also blocks delayed reserve/start delivery. After
activation, a crash with no durable stop becomes `unresolved`, preserving its last
confirmed checkpoint and full reservation. Absence of a heartbeat never releases
spendable time. An administrator must reconcile such a stopped session using EC's
audited correction endpoint; corrected charges remain distinct from exact durations.
Do not delete journals or change IDs to unblock a hold.
The EC agent independently proves the old process is stopped before recovering
journals during remove/rebuild/cancel-create. It sends requests only to its enrolled
controller using the assigned teardown operation and host credential. Lost replies,
unrecognized endpoints or malformed journals preserve runtime storage for retry.
## Validation
Portable checks (engine shims are not live Plutonium):
```sh
node tools/check_afk.mjs
python3 tools/test_plugin_afk.py --json-header /path/to/pinned/json.hpp
node tools/check_bank.mjs
node tools/check_hud.mjs
node tools/check_telemetry.mjs
```
Use each plugin's pinned JSON header; the native harness also accepts a path with
`{game}` for different T5/T6 dependency revisions. It compiles native utilities
and actual registration adapters with ASan/UBSan, then exercises separate-process
journal recovery. T6 source compilation follows its [script instructions](../t6/scripts/README.md#validation).
T5 linking and behavior require a live server.
Before production activation, validate **both engines**:
- Enter/return/expire, reconnect, die, change maps and restart the process; compare
the displayed countdown and EC balances/ledger. At expiry attempt moving, firing,
throwing equipment and receiving zombie damage immediately, including `.hud` off.
- Try T5 entry while downed, then after a successful revive. Check T6 laststand,
afterlife, intermission and stock control restrictions; no stock protection should
be removed. Repeat rapid `.afk`/`.back` and concurrent commands during slow replies.
- Earn each reward; revive the same teammate repeatedly beyond the performance cap.
Verify passive, spectating, AFK and bank point changes earn nothing. Test support
and zombie holding. Carry partial progress across maps/servers/T5/T6.
- Confirm the displayed purchase price; try expired/changed quotes, insufficient
bank funds, pending bank transfers and capacity overflow. Drop purchase replies
and retry without a second debit. Attempt simultaneous reservations on two servers.
- Disconnect EC after authorization; the countdown must continue and expire locally.
Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and
restart. Verify never-started refunds, durable exact stops and unconfirmed crash
holds. Correct only a proven stopped session and confirm the ledger audit.
- Enter as the only connected player and as the last active player with downed,
dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the
last active teammate, including while reserve/start replies are pending; verify
protection continues without a forced match end. Check solo return/expiration,
normal stock match-end settlement, afterlife/Grief recovery and zombie scaling.
- Rebuild/remove with journals from both games, retire the instance credential,
interrupt the agent after a lost reply, and retry. Runtime deletion must wait for
acknowledged recovery and cannot authorize a new purchase.
Validation performed for the initial `2.0.0` change: 39 AFK behavior checks, 14 core HUD and
56 service HUD checks passed. All nine T6 scripts compiled and assembled with
the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306)
using its T6 PC compiler/assembler library in a temporary local driver. No Windows
DLL build, production deployment or live T5/T6 validation is implied by these checks.
+114
View File
@@ -0,0 +1,114 @@
# Moderation runtime
T5 and T6 use the same protocol and native policy core, with separate native
chat opcodes and existing engine symbols. Install the game's new moderation
script 1.0.0, common 1.2.0, and native plugin 1.3.0. The controller must implement
`POST /api/moderation/runtime` before enabling the scripts.
## Private configuration
Each instance receives its own non-shared credential. Keep this configuration
out of shared scripts, release artifacts, ordinary config exports, and logs:
```cfg
set tws_server_id "registered-server"
set tws_moderation_url "https://controller.example/api/moderation/runtime"
set tws_moderation_token "ec_moderation_REPLACE_WITH_INSTANCE_SECRET"
set tws_moderation_generation "1"
```
Set `tws_moderation_enabled "0"` to disable the runtime. Missing or invalid
configuration leaves it waiting; it retries every three seconds so private configuration can be installed without a map restart. Disabling stops synchronization and enforcement; it does not revoke controller sanctions or promise to restore existing engine mutes. HTTPS is mandatory; redirects are not followed.
The generation must match the controller's live instance. Only that instance's
credential authorizes its roster, receipts and policy; the bank and telemetry
credentials cannot authorize moderation.
## Synchronization
Native HTTP runs outside the game thread with 3-second connection and 5-second
transfer deadlines. Polls start at most once per 3 seconds; only one poll is in
flight. Responses are limited to 1 MiB at the transport (oversize fails without
truncation), 1000 sanctions and 100 actions. The native cache never crosses a GSC
string boundary. Requests include protocol 1, server, generation, runtime session, sessionSequence,
players `{guid,name,connection}`, capabilities, appliedRevision and up to 256
receipts. Sessions change on map/process startup. A positive safe-integer sessionSequence is incremented and atomically persisted before each new runtime starts; the controller rejects older sequences and conflicting sessions at the same sequence. Repeated unchanged configuration preserves the current sequence. Never share an instance credential or its cache directory between simultaneous game-server processes. Connections change on every
native connect/disconnect event, even when the same GUID reuses the same slot.
GUIDs remain strings; pure decimal IDs have leading zeroes removed without a numeric conversion; the T5 script reads getGuid and the T6 script reads the
stock persistent guid field. GUIDs are limited to 128 ASCII identifier bytes. Roster names are bounded to 255 UTF-8 bytes; malformed name bytes become question marks and ASCII controls become spaces. Blank names, including Unicode whitespace-only names, become Unknown player. Names never select an action target.
Responses contain `{ok,revision,serverTime,sanctions,actions,pollAfterMs}`.
Sanctions contain `{id,revision,guid,kind,reason,expiresAt}`, with `kind` ban/mute
and nullable expiry. Actions contain `{id,guid,connection,kind,reason,expiresAt}`;
kind is warn/message/kick/announce. Only announce uses null GUID and connection.
Deadlines and policy expiry are UTC ISO timestamps. Clock skew over two minutes
rejects an update. Received server time is anchored to monotonic elapsed time without moving backward between polls, conservatively including the full transfer age and a one-second timestamp rounding margin, so a slow or backward-jumping host clock cannot extend one-time deadlines. This may expire an action or timed policy slightly early. Deadlines are checked again after writing the action journal and before dispatch. Revision rollback, inconsistent policy at the same revision,
malformed data, duplicate IDs and changed action content reject an update.
Removing sanctions already expired according to the validated serverTime is permitted without a revision increment.
The current client uses its fixed 3-second poll interval after validating the
server's pollAfterMs bounds (1000–60000).
## Enforcement and recovery
Keep `tws-moderation/t5/` or `tws-moderation/t6/`, relative to the game process
working directory, on persistent writable storage. Files are scoped to server,
endpoint, instance generation and SHA-256 credential fingerprint and do not contain the credential. Complete
snapshots replace files atomically. Every write verifies ownership of the current session sequence so a superseded runtime cannot roll the counter or policy backward. Corrupt cache disables startup and preserves
the file; storage failures are logged and block new effectful actions. Cache loss must be recovered by rebuilding the controller instance generation and issuing a new private credential; resetting or deleting a cache under an existing credential cannot reset the controller's highest session sequence. Retired
instance generations cannot inherit a newer generation's policy cache.
Cached bans and mutes continue during controller outages and expire using the
host wall clock until synchronized, then the later of host time and the monotonic server-time estimate. After a restart without connectivity, host clock drift remains an operator responsibility. Ban enforcement is a kick after connection, checked through the
shared connected dispatcher and a 500 ms loop; it is not a pre-authentication
admission hook. Still-connected banned players are checked again every five
seconds. Removing the last applicable mute restores clients muted by this
runtime; unrelated manually applied mutes are not deliberately cleared. Managed
mute ownership survives process/map restart so revoked cached mutes can be
removed when the affected player returns. No native ban.txt is written.
One-time actions require the exact current connection and unexpired deadline.
The native game-thread adapter validates the target and executes immediately;
GSC's buffered executeCommand is never used. Only fixed kick/mute/unmute command
templates are available; untrusted text cannot add delimiters, quotes, line
breaks or backslash escapes. Visible text is bounded to 240 UTF-8 bytes without splitting codepoints. Messages use direct per-game server message calls.
Before one-time dispatch, the action and an unknown receipt are saved durably.
Duplicate IDs never execute again, including after restart. A crash between
journal write and dispatch may therefore lose a one-time action; its result
stays unknown instead of risking a duplicate kick. Journal entries expire from
storage only after receipt acknowledgment and at least a day past their action
deadline. The journal is bounded at 10000 entries; saturation stops new actions
and requires controller delivery/storage investigation.
A confirmed private-message receipt means the game server dispatched it, not
that the player read it. A kick is confirmed only if its synchronous dispatch
observes the native disconnect callback; otherwise the result is unknown.
Native mute commands have no readback, so appliedRevision means the policy is
cached and being enforced, not independently verified voice/text suppression.
Do not describe a cached policy revision as proven enforcement.
## Validation and rollout
Portable checks:
```sh
node tools/check_moderation.mjs
python3 tools/test_plugin_moderation.py
python3 tools/test_plugin_http.py
python3 tools/test_plugin_component.py
```
The native moderation runner accepts `--json-header '/path/{game}-json.hpp'`
when dependencies have not been restored. Use each plugin's pinned JSON revision.
It compiles the actual core and registration/HTTP bridge against minimal engine
shims with ASan/UBSan, including separate process persistence tests. These checks
do not validate Windows linking or the Plutonium ABI.
Build both x86 DLLs using the existing Windows build workflow. Deploy the new
plugins and scripts together, then fully restart each game-server process; a map
restart alone cannot load the native methods. Test in isolated T5 Zombies and T6
Zombies instances before fleet rollout: duplicate names, same-account reconnect,
slot reuse, kick command/reason behavior, text and voice mute/unmute, reconnect
and map/process restart, expiry, revocation, controller outage, credential
revocation, truncated/oversized responses and disk failure. Verify clientkick and
muteclient command support in the deployed Plutonium revisions. If unsupported,
keep moderation disabled; this change has no automatic production deployment.
+36
View File
@@ -101,3 +101,39 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
The localhost tests compile the actual HTTP utility against the host libcurl. The localhost tests compile the actual HTTP utility against the host libcurl.
They do not validate Windows linking, its Schannel backend or game VM notification They do not validate Windows linking, its Schannel backend or game VM notification
handling. Those checks require the Windows build and a running Plutonium server. handling. Those checks require the Windows build and a running Plutonium server.
## Bank support (TWS 1.1.0)
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
decisions and strict response validation for the new bank script. Build and
install the DLL, then restart the full process. No upstream pins or external
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
Portable checks: `python3 tools/test_plugin_bank.py`.
## Bank diagnostics (TWS 1.2.0)
`bankVersion()` returns the loaded DLL's TWS component version, independently of
upstream pins and the native loader's unchanged `plugin_version()` value.
`bankResponse()` now also returns `reason`: `ok` on success, otherwise one of
`empty_body`, `body_limit`, `invalid_json`, `schema`, `field_limit`, `state`,
`amount_range`, `ok_state`, `missing_id`, or `zero_transfer`. Invalid responses
clear all parsed fields. Existing acknowledgment acceptance rules are unchanged.
The returned `code` on valid responses remains API data; bank scripts allowlist
it before logging. Callers must never log arbitrary response fields.
Native journal logs name load/write/remove, limit identical failures to one
per 30 seconds, and log recovery with suppressed counts. Exception text, journal
contents and credentials are excluded. The new scripts require this DLL even
when disabled by dvar because `bankVersion()` must link. No new third-party
dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t5",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,98 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/bank.hpp>
#include <array>
#include <chrono>
namespace bank
{
namespace
{
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
{
return {"tws-bank/t5", server, endpoint};
}
// Only fixed operation names are printed, never exception text or paths
// from private configuration. Per-operation state has bounded memory.
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, 3> states;
constexpr const char* names[] = {"load", "write", "remove"};
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_bank] 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;
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("bankversion", []() { return std::string("1.2.0"); });
gsc::function::add("bankload", [](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["decision"] = value.decision;
entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0, false);
return entries;
}
catch (...) { storage_result(0, true); return {}; }
});
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{
try { spool(server, endpoint).store(body); storage_result(1, false); return true; }
catch (...) { storage_result(1, true); return false; }
});
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { spool(server, endpoint).erase(id); storage_result(2, false); return true; }
catch (...) { storage_result(2, true); return false; }
});
gsc::function::add("bankresponse", [](const std::string& response)
{
const auto ack = utils::bank::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["amount"] = ack.amount;
result["balance"] = ack.balance;
return result;
});
}
};
}
REGISTER_COMPONENT(bank::component)
@@ -0,0 +1,161 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "scripting.hpp"
#include "scheduler.hpp"
#include "command.hpp"
#include "game/game.hpp"
#include "game/scripting/array.hpp"
#include <utils/moderation.hpp>
#include <utils/http.hpp>
#include <utils/cryptography.hpp>
#include <atomic>
#include <chrono>
#include <memory>
namespace moderation
{
namespace
{
using runtime = utils::moderation::runtime;
struct transfer
{
std::string session, response;
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
std::atomic<bool> done{false};
bool success = false;
};
std::shared_ptr<runtime> current;
std::shared_ptr<transfer> pending;
std::string endpoint, credential, configured_server;
int configured_generation = 0;
std::chrono::steady_clock::time_point next_poll{}, next_log{};
bool fault = false;
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
void failure()
{
if (std::chrono::steady_clock::now() >= next_log)
{
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
}
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
std::string execute(const std::string& kind, int slot, const std::string& reason)
{
auto active = current;
if (!active) return "failed";
// This function is called only by the server-thread GSC pump. No queue/yield
// separates runtime target validation from synchronous engine dispatch.
if (kind == "announce" || kind == "warn" || kind == "message")
{
const auto text = std::string("h \"") + reason + "\"";
game::SV_GameSendServerCommand(slot, 0, text.c_str());
return "confirmed";
}
if (slot < 0 || slot >= 64) return "failed";
if (kind == "mute" || kind == "unmute")
{
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
return "unknown"; // Engine has no mute-state readback in this adapter.
}
if (kind != "kick") return "failed";
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
}
void pump()
{
if (!current || fault) return;
try
{
if (pending && pending->done.load())
{
auto completed = pending; pending.reset();
if (completed->session == current->session)
{
if (completed->success)
{
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
current->accept(completed->response, now(), age);
}
else failure();
}
}
auto active = current;
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
{
return current == active ? execute(kind, slot, reason) : std::string("failed");
});
if (current != active) return;
if (!pending && std::chrono::steady_clock::now() >= next_poll)
{
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
scheduler::thread_pool.push([work, request, target, token]
{
try
{
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
limits.max_response_bytes = 1024 * 1024;
const auto response = utils::http::get_data(target, request,
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
if (response && response->code == CURLE_OK && response->status == 200)
{ work->response = response->buffer; work->success = true; }
}
catch (...) { work->success = false; }
work->done.store(true);
});
}
}
catch (...) { failure(); }
}
}
class component final : public component_interface
{
public:
void on_startup(plugin::plugin* plugin) override
{
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
scripting::on_shutdown([]
{
// In-flight requests own only native data. Results from the old VM are abandoned.
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
{
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
try
{
auto configured = std::make_shared<runtime>("tws-moderation/t5", server, url, generation,
utils::cryptography::sha256::compute(token, true));
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
}
catch (...) { fault = true; failure(); return false; }
});
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
{
if (!current || fault) return false;
const auto ref = entity.get_entity_reference();
if (ref.classnum != 0 || ref.entnum >= 64) return false;
try { current->observe(ref.entnum, guid, name); return true; }
catch (...) { failure(); return false; }
});
gsc::function::add("moderationstop", []
{
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationpump", pump);
}
};
}
REGISTER_COMPONENT(moderation::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;
};
}
@@ -0,0 +1,187 @@
#include <stdinc.hpp>
#include "bank.hpp"
#include <cstdint>
#include <iomanip>
#include <set>
namespace utils::bank
{
namespace
{
constexpr size_t max_body_size = 0x5000;
constexpr size_t max_file_size = 16 * 1024 * 1024;
constexpr size_t max_reports = 4096;
// Stable, compact filename for arbitrary server names and endpoints.
// The envelope also checks both strings, so a collision fails closed.
std::string scope_name(const std::string& scope)
{
uint64_t hash = 14695981039346656037ull;
for (const unsigned char byte : scope)
hash = (hash ^ byte) * 1099511628211ull;
std::ostringstream result;
result << std::hex << std::setfill('0') << std::setw(16) << hash;
return result.str();
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
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 result;
if (!data.at("ok").is_boolean()) return invalid("schema");
result.state = data.at("state").get<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
if (result.id.size() > 64 || result.code.size() > 64)
return invalid("field_limit");
if (result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected") return invalid("state");
for (const auto* key : {"amount", "balance"})
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000)
return invalid("amount_range");
result.amount = data.at("amount").get<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (result.state != "rejected")) return invalid("ok_state");
if (result.state != "balance" && result.id.empty()) return invalid("missing_id");
if ((result.state == "prepared" || result.state == "committed") && result.amount == 0)
return invalid("zero_transfer");
result.valid = true;
result.reason = "ok";
return result;
}
catch (...) { return invalid("schema"); }
}
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint))
{
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
}
report outbox::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size)
throw std::runtime_error("invalid bank request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_)
throw std::runtime_error("invalid bank scope");
report value;
value.id = data.at("request_id").get<std::string>();
value.decision = data.at("decision").get<std::string>();
if (value.id.empty() || value.id.size() > 64 ||
(value.decision != "cancel" && value.decision != "commit"))
throw std::runtime_error("invalid bank decision");
value.body = body;
return value;
}
std::vector<report> outbox::load() const
{
if (!std::filesystem::exists(file_))
return {};
if (std::filesystem::file_size(file_) > max_file_size)
throw std::runtime_error("bank outbox exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream)
throw std::runtime_error("cannot open bank outbox");
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("reports").is_array() || data.at("reports").size() > max_reports)
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
std::vector<report> reports;
std::set<std::string> identities;
for (const auto& body : data.at("reports"))
{
auto value = decode(body.get<std::string>());
if (!identities.emplace(value.id).second)
throw std::runtime_error("duplicate identity in bank outbox");
reports.push_back(std::move(value));
}
return reports;
}
void outbox::save(const std::vector<report>& reports) const
{
if (reports.size() > max_reports)
throw std::runtime_error("bank outbox is full; delivery blocked");
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
{"reports", nlohmann::json::array()}};
for (const auto& value : reports)
data["reports"].push_back(value.body);
const auto encoded = data.dump();
if (encoded.size() > max_file_size)
throw std::runtime_error("bank outbox is full; delivery blocked");
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 bank outbox; old file retained");
stream.close();
if (stream.fail())
throw std::runtime_error("cannot close bank outbox; 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 bank outbox; old file retained");
#else
// Used by the portable tests; the production plugin targets Windows.
std::filesystem::rename(temporary, file_);
#endif
}
void outbox::store(const std::string& body) const
{
const auto incoming = decode(body);
auto reports = load();
for (auto& value : reports)
{
if (value.id != incoming.id)
continue;
auto old_data = nlohmann::json::parse(value.body);
auto new_data = nlohmann::json::parse(body);
old_data.erase("decision");
new_data.erase("decision");
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
throw std::runtime_error("bank identity/decision conflict");
if (value.body != body)
{
value = incoming;
save(reports);
}
return;
}
reports.push_back(incoming);
save(reports);
}
void outbox::erase(const std::string& id) const
{
auto reports = load();
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
{
return value.id == id;
});
if (end == reports.end())
return;
reports.erase(end, reports.end());
save(reports);
}
}
@@ -0,0 +1,32 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace utils::bank
{
struct report { std::string id, decision, body; };
struct acknowledgement
{
bool valid = false;
std::string state, code, id;
// Fixed diagnostic vocabulary; never includes response text.
std::string reason = "schema";
int amount = 0, balance = 0;
};
acknowledgement parse_acknowledgement(const std::string& response);
// One game-thread writer; cancel intent is replaced by commit before points move.
class outbox
{
public:
outbox(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<report> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
private:
std::filesystem::path file_;
std::string server_, endpoint_;
report decode(const std::string& body) const;
void save(const std::vector<report>& reports) const;
};
}
@@ -14,13 +14,17 @@ namespace utils::http
{ {
namespace namespace
{ {
struct response_buffer { std::string bytes; size_t limit; };
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
{ {
const auto buffer = static_cast<std::string*>(userp); const auto buffer = static_cast<response_buffer*>(userp);
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
const auto total_size = size * nmemb; const auto total_size = size * nmemb;
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
try try
{ {
buffer->append(static_cast<char*>(contents), total_size); buffer->bytes.append(static_cast<char*>(contents), total_size);
} }
catch (...) catch (...)
{ {
@@ -68,7 +72,7 @@ namespace utils::http
header_list.reset(appended); header_list.reset(appended);
} }
std::string buffer{}; response_buffer buffer{{}, limits.max_response_bytes};
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get()); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
curl_easy_setopt(curl, CURLOPT_URL, url.data()); curl_easy_setopt(curl, CURLOPT_URL, url.data());
@@ -106,7 +110,7 @@ namespace utils::http
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME); response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME); response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
if (response.code == CURLE_OK) if (response.code == CURLE_OK)
response.buffer = std::move(buffer); response.buffer = std::move(buffer.bytes);
return response; return response;
} }
} }
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <cstddef>
#include <optional> #include <optional>
#include <unordered_map> #include <unordered_map>
@@ -25,6 +26,8 @@ namespace utils::http
{ {
long connect_timeout_ms = 5000; long connect_timeout_ms = 5000;
long timeout_ms = 15000; long timeout_ms = 15000;
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
size_t max_response_bytes = 0;
}; };
using headers = std::unordered_map<std::string, std::string>; using headers = std::unordered_map<std::string, std::string>;
@@ -0,0 +1,349 @@
#include <stdinc.hpp>
#include "moderation.hpp"
#include <chrono>
#include <cctype>
#include <ctime>
#include <iomanip>
#include <random>
#include <set>
namespace utils::moderation
{
namespace
{
constexpr size_t max_file = 16 * 1024 * 1024;
constexpr size_t max_response = 1024 * 1024;
bool identifier(const std::string& value, size_t limit = 128)
{
return !value.empty() && value.size() <= limit &&
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
});
}
std::string bounded_text(const std::string& value, size_t limit)
{
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
// split a codepoint or let one bad name poison the complete JSON roster.
std::string result;
for (size_t i = 0; i < value.size() && result.size() < limit;)
{
const auto ch = static_cast<unsigned char>(value[i]);
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
bool valid = width && i + width <= value.size();
for (size_t j = 1; valid && j < width; ++j)
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
if (valid && width >= 3)
{
const auto second = static_cast<unsigned char>(value[i + 1]);
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
}
if (!valid) { result += '?'; ++i; continue; }
if (result.size() + width > limit) break;
result.append(value, i, width); i += width;
}
return result;
}
std::string player_name(const std::string& value)
{
auto name = bounded_text(value, 255);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
auto visible = name;
// Match ECMAScript trim whitespace so the controller cannot reject an
// otherwise complete roster because one display name is blank.
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
return visible.empty() ? "Unknown player" : name;
}
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
std::string hash_scope(const std::string& text)
{
unsigned long long hash = 14695981039346656037ull;
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
std::ostringstream out; out << std::hex << hash; return out.str();
}
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
std::string field(const json& value, const char* key, size_t max, bool empty = false)
{
require(value.contains(key) && value.at(key).is_string());
auto result = value.at(key).get<std::string>();
require((empty || !result.empty()) && result.size() <= max);
return result;
}
void reason(const json& value) { field(value, "reason", 500, true); }
void sanction(const json& value, long long revision)
{
require(value.is_object());
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
}
void action(const json& value)
{
require(value.is_object() && identifier(field(value, "id", 128)));
auto kind = field(value, "kind", 16);
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
reason(value); timestamp(field(value, "expiresAt", 32));
}
}
long long timestamp(const std::string& value)
{
require(value.size() == 20 || value.size() == 24);
require(value.back() == 'Z' && value[10] == 'T');
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
const auto original = tm;
#ifdef _WIN32
auto result = _mkgmtime64(&tm);
#else
auto result = timegm(&tm);
#endif
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
return result;
}
std::string command_text(const std::string& value)
{
// Untrusted text is never a console delimiter or a quoted command escape.
std::string result;
for (unsigned char ch : bounded_text(value, 240))
{
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
}
return result;
}
std::string canonical_guid(const std::string& value)
{
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
{
const auto first = value.find_first_not_of('0');
return first == std::string::npos ? "0" : value.substr(first);
}
return value;
}
std::string new_session()
{
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
return out.str();
}
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
{
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
if (std::filesystem::exists(file_))
{
require(std::filesystem::file_size(file_) < max_file);
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
{
require(identifier(it.key())); action(it.value().at("action"));
auto state = field(it.value(), "state", 16);
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
}
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
saved_ = std::move(value);
}
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
save(next); // Allocate this boot/map sequence durably before exposing its session.
}
void runtime::save(const json& next)
{
const auto bytes = next.dump(); require(bytes.size() < max_file);
const auto exists = std::filesystem::exists(file_);
require(exists || !allocated_);
if (exists)
{
// A synchronous engine command may replace the map/runtime before the
// older call frame returns. It must never overwrite the new sequence.
require(std::filesystem::file_size(file_) < max_file);
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
}
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
require(stream.good()); stream.close(); require(!stream.fail());
#ifdef _WIN32
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
#else
std::filesystem::rename(temp, file_);
#endif
saved_ = next; allocated_ = true;
}
void runtime::observe(int slot, const std::string& guid, const std::string& name)
{
require(slot >= 0 && slot < 64 && guid_identifier(guid));
auto found = players.find(slot);
if (found == players.end() || found->second.guid != canonical_guid(guid))
{
disconnect(slot);
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
}
else found->second.name = player_name(name);
}
void runtime::disconnect(int slot)
{
auto found = players.find(slot);
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
}
json runtime::request()
{
json roster = json::array(), receipts = json::array(); sent_.clear();
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
{
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
sent_.push_back(it.key());
}
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
}
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
{
const auto received = std::chrono::steady_clock::now();
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
auto revision = value.at("revision").get<long long>();
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
std::set<std::string> ids;
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
ids.clear();
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
if (revision == this->revision() && revision != 0)
{
auto previous = saved_.at("sanctions");
for (auto it = previous.begin(); it != previous.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
auto incoming = value.at("sanctions");
for (auto it = incoming.begin(); it != incoming.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
require(incoming == previous);
}
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
save(next); pending_ = value.at("actions");
const auto accepted = std::chrono::steady_clock::now();
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
std::chrono::milliseconds(transfer_age_ms) + processing;
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
clock_anchor_ = accepted;
}
long long runtime::effective_time(long long local) const
{
if (!server_anchor_.time_since_epoch().count()) return local;
// Never extend deadlines when the host clock lags or jumps backward. The
// full request age is conservative because serverTime is generated during it.
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
return std::max(local, static_cast<long long>(estimate));
}
void runtime::enforce(long long now, const executor& execute)
{
now = effective_time(now);
auto pending = pending_; pending_ = json::array();
for (const auto& item : pending)
{
auto id = item.at("id").get<std::string>();
if (saved_["journal"].contains(id))
{
// A duplicate immutable action is acknowledged, never executed twice.
require(saved_["journal"][id].at("action") == item);
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
}
require(saved_["journal"].size() < 10000);
auto kind = item.at("kind").get<std::string>(); int slot = -1;
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
else if (kind != "announce")
{
for (const auto& [number, target] : players)
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
}
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
if (state == "unknown")
{
// All target validation and dispatch occur on the calling game thread, without yielding.
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
else
{
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
catch (...) { state = "unknown"; }
if (state != "confirmed" && state != "failed") state = "unknown";
}
next = saved_; next["journal"][id]["state"] = state;
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
}
}
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
const auto roster = players;
for (const auto& [slot, target] : roster)
{
now = effective_time(now);
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
bool ban = false, mute = false; std::string why;
for (const auto& policy : saved_.at("sanctions"))
{
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
if (policy.at("kind") == "mute") mute = true;
}
if (ban)
{
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
continue;
}
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
auto owned = saved_.at("ownedMutes");
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
if (mute != was_muted || (!mute && managed))
{
if (mute && !managed)
{
require(owned.size() < 10000); owned.push_back(target.guid);
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
if (!mute && managed)
{
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
}
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <filesystem>
#include <chrono>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include <json.hpp>
namespace utils::moderation
{
using json = nlohmann::json;
struct player { int slot; std::string guid, name, connection; };
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
long long timestamp(const std::string& value);
std::string command_text(const std::string& value);
std::string new_session();
std::string canonical_guid(const std::string& value);
class runtime
{
public:
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
std::string session = new_session();
std::map<int, player> players;
void observe(int slot, const std::string& guid, const std::string& name);
void disconnect(int slot);
json request();
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
void enforce(long long now, const executor& execute);
long long revision() const { return saved_.at("revision").get<long long>(); }
private:
std::filesystem::path file_;
std::string server_, endpoint_, credential_scope_;
int generation_;
unsigned long long serial_ = 0;
bool allocated_ = false;
std::chrono::system_clock::time_point server_anchor_{};
std::chrono::steady_clock::time_point clock_anchor_{};
json saved_, pending_ = json::array();
std::vector<std::string> sent_;
std::map<std::string, bool> muted_;
std::map<std::string, long long> kicked_;
void save(const json& next);
long long effective_time(long long local) const;
};
}
+71 -5
View File
@@ -32,9 +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 text: use `SetValue`, not `SetText`.** `SetText` allocates a - **Dynamic counter text uses `SetTextUnlimited`; HP uses `SetValue`.**
config string per call; per-tick rebuilds overflow and take the server Repeatedly generating unique strings through `SetText` risks exhausting the
down. Composite strings like `"87/160"` are three elements. text config-string pool; identical strings can be reused by the engine's
[string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46).
The centered `N ZOMBIES` element uses Plutonium r5334+'s `SetTextUnlimited`
only when the count changes. Composite HP retains numeric elements around a
static slash and `HP` suffix; the group appears only while injured. Help
reuses up to nine explicitly positioned command/description pairs, and
record rows skip unchanged text.
- **`NewClientHudElem(player)`, not `NewHudElem()`.** `NewHudElem` is - **`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
@@ -56,18 +62,78 @@ before copying the renamed scripts.
Many BO1 maps pre-set `round_number = 1` at init, so the round watcher Many BO1 maps pre-set `round_number = 1` at init, so the round watcher
fires on transitions only and the timer row seeds on first spawn as a fires on transitions only and the timer row seeds on first spawn as a
fallback. fallback.
- **Connection-time HUD creation must not seed clocks.** The unboxed core is
available to waiting spectators with placeholders: timers upper left,
branding top center and zombies/health bottom center. Timer updates retain
their existing spawn/round lifecycle, independently of element creation.
- **Visibility is separate from updates.** HUD owns the exact, case-insensitive
`.hud` chat command and its help entry. Common's core wrappers register each
element and preserve its intended alpha behind a per-player visibility mask;
health and AFK changes use `set_core_alpha()`. Hidden data/timers keep updating
until game over freezes them. Help, records and stock elements remain separate.
Visibility defaults on, survives death/respawn during the connection/map, and
resets on reconnect or map restart. AFK protection is unaffected.
- **One service view per player.** Common owns the unboxed upper-right service
area, ordered help rows, record display state and cleanup. Help shows all
nine installed commands; extra registrations use nine rows per page. Help
pauses record page time; `.leaders` during help replaces the pending replay
from page one. Keep both services independent of the HUD module. Services
never reposition the core, and AFK remains centered above the zombie count.
- **Bound HUD element counts, including hidden elements.** Core + AFK use
10 + 1 archived elements. Common allocates only the active service view:
19 nonarchived elements for nine-command help (20 with a multi-page counter),
or four for records (five with a multi-page counter), and none while idle.
Switching help/record views destroys the previous view first; pages reuse
its elements. Keep the controller and pending requests separate from element
lifetime so paused records resume normally. Both engines use explicit help
rows; BO1 can concatenate newline-separated HUD text. Hiding an element does
not free its slot. The previous 24-element persistent service allocation was
consistent with the reported core corruption after records first appeared.
The exact live T5 SP capacity remains unverified. The
[BO1 HUD update](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game/g_hudelem.cpp#L2596)
comes from an MP-based reconstruction and is not proof of T5 SP limits or
current Plutonium stock usage. Validate automatic records, help, view switches
and dismissal alongside stock notifications and other installed HUD mods;
the core must remain intact throughout and after every service closes.
- **Game-over trigger is level-scope, raced across three candidate - **Game-over trigger is level-scope, raced across three candidate
notifies** (`end_game` / `game_ended` / `intermission`) since BO1 maps notifies** (`end_game` / `game_ended` / `intermission`) since BO1 maps
are inconsistent. Per-player triggers do not work — `self waittill("death")` are inconsistent. Per-player triggers do not work — `self waittill("death")`
is intercepted by the zombiemode scripts before it reaches our scope, and is intercepted by the zombiemode scripts before it reaches our scope, and
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.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
`SetTextUnlimited`. On r5334+, update the HUD script and restart the map;
older Plutonium clients and servers need updates and full restarts first.
AFK `1.2.0` and leaderboard `1.1.0` remain compatible; AFK still requires
common `1.4.0+`.
The full help order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`: one page of nine commands, with full `<amount|all>` bank
syntax. `.hud` is absent when HUD is omitted. Records use right-aligned
`Map record | Round N` and up to three bounded holder lines, with no footer
and no single-page counter. Both views retain 12-second page timing.
Fullscreen X/Y overlay anchors use direct normalized `640 × 480` coordinates.
Dynamic health fills use `make_overlay_width()` like their tracks. Apply
`int()` after `max()` in that helper: native `max()` returns a float, but
`SetShader` requires an integer width. Legacy panel helpers and their `0.75`
horizontal conversion remain available for existing consumers. Font scale is
at least `1.0`. Numeric HP appears only while injured; the core and services
have no panel backgrounds. The counter is one `N ZOMBIES` element centered
at the health track's midpoint with uniform scale `1.25`, starting as
`-- ZOMBIES`. There is no new native-plugin/backend dependency or full
server-process restart requirement when already using Plutonium r5334+.
Run the [shared HUD checks](../../README.md#hud-validation); portable fixtures
do not replace T5 live loading, layout and lifecycle validation. 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
| File | Role | Fires notifies | Reads notifies | | File | Role | Fires notifies | Reads notifies |
|---|---|---|---| |---|---|---|---|
| `tws_t5_common` | Shared config, HUD helpers, `.help` registry, spawn-handler registry, single connect listener + dispatcher | — | `say` | | `tws_t5_common` | Shared config, HUD helpers, paginated `.help`, service window, spawn-handler registry, single connect listener + dispatcher | — | `say`, game-end notifications |
| `tws_t5_hud` | Timer row, zombie count, health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) | | `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` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` |
+195 -60
View File
@@ -1,7 +1,7 @@
# TWS T5 Zombies scripts # TWS T5 Zombies scripts
Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5 Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
(Black Ops 1) zombies dedicated servers. Split across seven `.gsc` files, (Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files,
with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module. with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module.
Platform quirks and deploy commands: [NOTES.md](NOTES.md). Platform quirks and deploy commands: [NOTES.md](NOTES.md).
@@ -10,17 +10,21 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| File | Role | | File | Role |
|---|---| |---|---|
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, `.help`, 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) | Bottom-left HUD stack: timers, zombie count, health bar | | [tws_t5_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator | | [tws_t5_afk.gsc](tws_t5_afk.gsc) | Persistent AFK reserve, contribution rewards, purchases and central countdown |
| [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends | | [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round | | [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | | [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection | | [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
## Install / upgrade ## Install / upgrade
Copy `tws_t5_*.gsc` to `storage/t5/scripts/sp/`, then restart the map. Copy the desired `tws_t5_*.gsc` modules to `storage/t5/scripts/sp/`, then restart the map.
Before adding the optional moderation module, install native plugin 1.3.0+ and
restart the full game-server process; see [moderation setup](../../docs/moderation.md).
When upgrading an existing installation, first remove these old scripts from When upgrading an existing installation, first remove these old scripts from
that directory: that directory:
@@ -31,10 +35,80 @@ that directory:
Do not leave both common scripts installed: duplicate connection listeners Do not leave both common scripts installed: duplicate connection listeners
break T5's connect handshake. Existing dvar names are unchanged. break T5's connect handshake. Existing dvar names are unchanged.
## Player bank
Bank `2.0.2` requires plugin `1.2.0+` for startup version reporting and bounded
parser diagnostics. Install the DLL and restart the full server process before
loading these scripts. Follow the [diagnostic rollout and live checks](../../README.md#bank-diagnostics--tws-14).
The optional `tws_t5_bank.gsc` adds `.b`, `.d <amount|all>` and
`.w <amount|all>` and registers them in help. It requires common and telemetry
`1.0.0` or newer, the TWS t5 plugin `1.2.0`, Electric Cherry's bank API and
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
including a full process restart for the plugin. Bank `2.0.0+` uses only
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
an EC rebuild must settle legacy journals before the new scripts start.
When the endpoint already matches, only a map restart is required.
Bank `2.0.1` converts T5's `player getGuid()` result to a string before
requests or journal writes, preserving existing strings exactly. Missing or
invalid identities fail privately without changing points. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Core HUD and service window
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. `N ZOMBIES` is one centered element at
the health track's midpoint, using uniform font scale `1.25` so the whole group
stays centered as the digit count changes. Its initial placeholder is
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
The core is created once per connection, including for spectator newcomers
with placeholders until values are available. Creation does not seed the match
clock. It persists across death and respawn. Clocks, health and zombie-count
displays freeze at game over. Health fallbacks only affect the display and
never modify gameplay max health.
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
It defaults visible, keeps your choice across death/respawn on the current
connection/map, and resets visible on reconnect or map restart. Timers and data
continue updating while hidden; game-over values remain frozen. Help, records
and the stock HUD are unaffected. `.hud` requires the HUD module.
Common provides one unboxed upper-right service area below the Plutonium tag.
Records are right aligned, with `Map record | Round N` followed by up to three
holder lines; there is no replay footer or single-page counter. Help displays
the installed command list in pages of nine rows in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
HUD `1.4.2` requires common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
updated HUD script and restart the map on r5334+; no full server-process
restart is required. Older Plutonium revisions need client/server updates and
full restarts first. 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;
@@ -46,27 +120,50 @@ 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 appears below the health bar for 12 seconds, clear of the timers, zombie Help uses the unboxed upper-right service area. All nine installed commands
counter and health display. Typing either command again restarts that display time. fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
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
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 |
| `.afk` | Pause with protection | | `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
| `.afk` | Enter protected AFK using your time reserve |
| `.back` | Return from AFK | | `.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) |
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
Each module registers its own help entries; removing AFK or join also removes Each module registers its own help entries. Commands follow the order above,
those entries from `.help` after a map restart. independent of initialization order; removing a module also removes its entries
from `.help` after a map restart.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
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 temporarily hides the record until its own display ends. help pauses the record's remaining page time and resumes it after the final
help page. `.leaders` during help replaces the pending record replay from page one.
## Dependency graph ## Dependency graph
@@ -116,16 +213,20 @@ graph BT
## tws_t5_common.gsc ## tws_t5_common.gsc
Hub file. Shared config, HUD helpers, spawn-handler registry, and the Hub file. Shared config, HUD helpers, service window, spawn-handler registry, and the
single `level waittill("connected")` in the fleet. Adding a second single `level waittill("connected")` in the fleet. Adding a second
connect listener anywhere else crashes the T5 SP handshake. connect listener anywhere else crashes the T5 SP handshake.
**Features** **Features**
- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`, - Shared HUD constants for overlay positions, rows/columns, fonts, health bar,
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold` colors and update rate
- Spawn-handler registry - Spawn-handler registry
- Connection-handler registry for optional modules that serve spectator newcomers - Connection-handler registry for optional modules that serve spectator newcomers
- `.help` listener and help registry populated by installed command modules - `.help` / `.commands` listener and ordered help registry populated by installed modules
- One service controller per player, with help/record pagination,
interruption/resume, request replacement and disconnect/game-end cleanup;
display elements exist only for the active view and are released when idle
- Core element registry with per-element alpha state and a per-player visibility mask
- Connect listener + per-player dispatch: iterates - Connect listener + per-player dispatch: iterates
`level.tws_spawn_handlers` after `spawned_player` and threads each on `level.tws_spawn_handlers` after `spawned_player` and threads each on
the player the player
@@ -140,6 +241,19 @@ connect listener anywhere else crashes the T5 SP handshake.
| `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` | | `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` |
| `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline | | `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline |
| `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element | | `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element |
| `make_overlay_text(p, x, y, ax, s, c)` | Text at absolute fullscreen coordinates in the normalized `640 × 480` frame |
| `make_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Shader at absolute fullscreen coordinates |
| `make_overlay_width(width)` | Clamp a normalized width and return the integer required by `SetShader` |
| `make_core_overlay_text(p, x, y, ax, s, c)` | Create overlay text registered for `.hud` visibility |
| `make_core_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Create an overlay shader registered for `.hud` visibility |
| `make_panel_text(p, x, y, ax, s, c)` | Legacy text positioned relative to the former upper-right panel origin |
| `make_panel_shader(p, x, y, w, h, c, alpha, sort)` | Legacy shader positioned relative to that panel origin |
| `make_panel_width(width)` | Retained legacy design-width conversion for existing consumers |
| `make_core_text(p, x, y, ax, s, c)` | Retained legacy panel text with `.hud` visibility |
| `make_core_shader(p, x, y, w, h, c, alpha, sort)` | Retained legacy panel shader with `.hud` visibility |
| `set_core_alpha(p, elem, alpha)` | Preserve the element's intended alpha while applying the player's visibility choice |
| `toggle_core_hud()` | Toggle the player's registered core elements without stopping updates |
| `show_record(snapshot)` | Replace the player's record service request; an undefined snapshot shows unavailable |
| `destroy_on_disconnect(elems)` | Waits on `disconnect`, destroys the array | | `destroy_on_disconnect(elems)` | Waits on `disconnect`, destroys the array |
| `int_dvar_or(name, fallback)` | Parse an integer dvar with default | | `int_dvar_or(name, fallback)` | Parse an integer dvar with default |
| `format_mmss(elapsed_ms)` | Format milliseconds as `M:SS` | | `format_mmss(elapsed_ms)` | Format milliseconds as `M:SS` |
@@ -148,27 +262,44 @@ connect listener anywhere else crashes the T5 SP handshake.
**Live validation (requires a T5 server)** **Live validation (requires a T5 server)**
1. Type `.help` and `.HELP` while playing and while spectating before the first 1. Type `.help`, `.HELP` and `.commands` while playing and spectating before the
spawn. Verify all four descriptions appear below the health bar, only for the first spawn. Verify all nine commands appear in one unboxed list, private to
requesting player. Take damage so health numbers are visible and check that the requesting player. Check explicit rows, full bank syntax and the absence
they do not cover the help text. of a single-page counter. HP should appear beside the bottom health bar only
while injured.
2. Remove AFK or join, restart the map, and verify its entries disappear while 2. Remove AFK or join, restart the map, and verify its entries disappear while
`.help` and the remaining commands still work. Restore the scripts afterward. `.help` and the remaining commands still work. Restore the scripts afterward.
3. Repeat `.help` before 12 seconds elapse; verify one list stays visible for 3. Verify help closes after 12 seconds. Add custom registered commands to test
12 seconds after the latest request, then hides. Reopen it after expiry, the nine-row page limit, counter and repeat-to-restart behavior. Interrupt a record
die/respawn and reconnect; verify no duplicate text or leftover help elements. with help and verify remaining page time resumes; repeat `.leaders` during
help and verify only the latest replay starts from page one.
4. Reopen after expiry, die/respawn and reconnect; verify no duplicate text or
leftover elements. Repeat without the HUD module. Follow the complete
[HUD validation guide](../../README.md#hud-validation) before rollout.
5. Watch the first automatic record announcement and its dismissal. Repeat
records, help and interrupted/resumed records alongside stock notifications.
Check that branding, the health bar, damage-only HP, zombie count and timers
remain correct during each view and after it closes.
Include other installed HUD mods; the live T5 SP element limit is unverified.
6. Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` at different resolutions:
the complete text must stay centered above the health track. Repeat after
toggling `.hud` and after game over freezes the count.
--- ---
## tws_t5_hud.gsc ## tws_t5_hud.gsc
Bottom-left cluster. Per-player elements spawn via `::hud_start`. Unboxed core overlays. Per-player elements are created on connection;
gameplay updates start through the existing spawn lifecycle.
**Features** **Features**
- Match timer + 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)
- Player-name row (only visible in co-op) - `.hud` toggles this player's core; the AFK countdown stays visible and data updates continue
- Zombies-remaining counter - Top-center `TWS ZOMBIES | discord.tws.lol` branding
- Health bar with green / amber / red ramp; numbers fade in below full HP - One `N ZOMBIES` element centered above the health track, separate from the central AFK countdown
- Slim health bar with green / amber / red ramp; current/maximum HP appears
beside it only while injured
- Spectator placeholders without starting the match clock on connection
- Round-transition and game-over notify machinery - Round-transition and game-over notify machinery
**Provides** **Provides**
@@ -182,47 +313,38 @@ Bottom-left cluster. Per-player elements spawn via `::hud_start`.
Spawn handler registered: `::hud_start` — threads `hud_timer_row`, Spawn handler registered: `::hud_start` — threads `hud_timer_row`,
`hud_zombie_counter`, `hud_health_display`. `hud_zombie_counter`, `hud_health_display`.
Connection handler registered: `::hud_connect` — creates the core with placeholders
without seeding clocks; `hud_start` calls it as a fallback if needed.
**Depends on** **Depends on**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`, - `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`make_shader_elem`, `destroy_on_disconnect`, `format_mmss`, `make_core_overlay_shader`, `make_overlay_width`, `set_core_alpha`, `toggle_core_hud`,
`register_spawn_handler` `destroy_on_disconnect`, `format_mmss`, `register_help_command`,
`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`, `self.name` / `level.players`, `self.health`, `self.maxhealth`, AI entities' `.animname`
`self.playername`, AI entities' `.animname` and `.ignore_enemy_count` and `.ignore_enemy_count`
--- ---
## tws_t5_afk.gsc ## tws_t5_afk.gsc
Self-invoked pause mode. Owns the `[AFK]` indicator element entirely — HUD Persistent protected breaks using the shared GUID reserve. See the
module has zero knowledge of AFK. [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 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_text_elem`,
`destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
- Shared config: `level.hud_x`, `level.hud_row_match`,
`level.hud_font_small`, `level.hud_c_afk`
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
Spawn handler registered: `::afk_spawn`.
--- ---
@@ -486,3 +608,16 @@ and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Com
and leaderboard HTTP, JSON, type and string helpers. Install a build exposing and leaderboard HTTP, JSON, type and string helpers. Install a build exposing
the natives listed in each module's documentation, then restart the server the natives listed in each module's documentation, then restart the server
process. No database is required. process. No database is required.
## Moderation
Optional `tws_t5_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
configuration, persistence, action results and deployment checks.
## Persistent AFK reserve
AFK `2.0.0` uses EC authorization and a local central countdown. See the
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
and live checks. Install plugin `1.4.0+` and fully restart the server process.
The countdown remains visible with `.hud` hidden; expiration has no grace period.
+943 -56
View File
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
/* tws_t5_bank.gsc - shared GUID bank, with durable transfer decisions. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0")
return;
// Game containers use the public API, independent of the host controller URL.
level.bank_url = "https://ec.thewrightserver.net/api/bank";
level.bank_server = GetDvar("tws_server_id");
level.bank_token = GetDvar("tws_bank_token");
level.bank_logs = [];
level.bank_diagnostic_serial = 0;
credentials = "valid";
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
credentials = "invalid";
print("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
" credentials=" + credentials);
// A private per-server cfg supplies this; never embed bank credentials.
if (level.bank_server == "" || level.bank_token.size < 40 ||
getSubStr(level.bank_token, 0, 8) != "ec_bank_")
{
print("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token; pending=not_loaded");
return;
}
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
if (!isDefined(level.bank_queue))
{
print("[tws_bank] startup journal_load=failure pending=unknown bank=disabled");
return;
}
print("[tws_bank] startup journal_load=success pending=" + level.bank_queue.size);
for (i = 0; i < level.bank_queue.size; i++)
{
level.bank_queue[i]["since"] = getTime();
level.bank_queue[i]["attempts"] = 0;
}
level.bank_read = 0;
level.bank_write = level.bank_queue.size;
level.bank_busy = false;
level.bank_ended = false;
scripts\sp\tws_t5_common::register_help_command(".b", "Show bank balance");
scripts\sp\tws_t5_common::register_help_command(".d <amount|all>", "Deposit points");
scripts\sp\tws_t5_common::register_help_command(".w <amount|all>", "Withdraw points");
level thread bank_end_watcher();
level thread bank_sender();
level thread bank_chat_listener();
}
bank_end_watcher()
{
level waittill_any("end_game", "game_ended", "intermission");
level.bank_ended = true;
}
bank_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
args = strTok(toLower(message), " ");
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
continue;
if (level.bank_busy || level.bank_read < level.bank_write)
{
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
continue;
}
level.bank_busy = true;
level thread bank_request(player, args);
}
}
bank_amount(text)
{
if (text == "all")
return -1;
if (text.size == 0 || text.size > 7)
return 0;
digits = "0123456789";
for (i = 0; i < text.size; i++)
{
found = false;
for (j = 0; j < digits.size; j++)
if (text[i] == digits[j])
found = true;
if (!found)
return 0;
}
amount = int(text);
if (amount < 1 || amount > 1000000)
return 0;
return amount;
}
bank_playing(player)
{
return !level.bank_ended && !is_true(level.intermission) &&
isDefined(player) && isDefined(player.sessionstate) &&
player.sessionstate == "playing" && isAlive(player) &&
!(player maps\_laststand::player_is_in_laststand()) &&
isDefined(player.score) && player.score >= 0;
}
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
bank_guard(entry)
{
self endon("tws_bank_finished");
self waittill_any("disconnect", "killed_player", "spawned_player");
entry.active = false;
}
bank_say(entry, text)
{
if (entry.active && isDefined(entry.player))
entry.player iPrintLnBold(text);
}
bank_request(player, args)
{
entry = spawnStruct();
entry.player = player;
entry.active = true;
player thread bank_guard(entry);
bank_transfer(entry, args);
if (entry.active && isDefined(player))
player notify("tws_bank_finished");
level.bank_busy = false;
}
bank_guid(player)
{
if (!isDefined(player))
return undefined;
guid = player getGuid();
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return undefined;
// T5 can return a number. Preserve string identities without numeric casts.
guid = "" + guid;
if (guid.size == 0 || guid.size > 64)
return undefined;
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < guid.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (guid[i] == allowed[j])
found = true;
if (!found)
return undefined;
}
return guid;
}
bank_transfer(entry, args)
{
player = entry.player;
guid = bank_guid(player);
if (!isDefined(guid))
{
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
return;
}
data = [];
data["server"] = level.bank_server;
data["guid"] = guid;
if (args[0] == ".b")
{
if (args.size != 1)
{
bank_say(entry, "^3Usage: .b");
return;
}
reply = bank_http("balance", jsonSerialize(data), "");
if (!isDefined(reply) || reply["state"] != "balance")
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
else
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
return;
}
if (args.size != 2)
{
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
return;
}
amount = bank_amount(args[1]);
if (amount == 0)
{
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
return;
}
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
{
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
return;
}
operation = "withdraw";
if (args[0] == ".d")
{
operation = "deposit";
if (amount == -1)
amount = int(player.score);
if (amount <= 0 || amount > 1000000 || player.score < amount)
{
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
return;
}
}
capacity = 1000000 - int(player.score);
if (capacity < 0)
capacity = 0;
if (operation == "withdraw" && amount != -1 && amount > capacity)
{
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
return;
}
id = telemetryNewMatch();
if (!isDefined(id))
{
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
return;
}
data["request_id"] = id;
data["match"] = level.telemetry_match;
data["operation"] = operation;
data["amount"] = amount;
if (amount == -1)
data["amount"] = "all";
data["capacity"] = capacity;
data["decision"] = "cancel";
body = jsonSerialize(data);
pending_since = getTime();
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
if (!bank_store(body, id, "cancel"))
{
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
return;
}
reply = bank_http(operation, body, id);
if (isDefined(reply) && reply["state"] == "prepared")
{
transfer = reply["amount"];
if (entry.active && bank_playing(player) &&
(amount == -1 || amount == transfer) &&
((operation == "deposit" && player.score >= transfer) ||
(operation == "withdraw" && player.score <= 1000000 - transfer)))
{
data["decision"] = "commit";
commit_body = jsonSerialize(data);
// No yield between recording the decision and updating the balance.
// Do not change score_total: bank funds are not earned match points.
if (bank_store(commit_body, id, "commit"))
{
if (operation == "deposit")
player.score -= transfer;
else
player.score += transfer;
player.pers["score"] = player.score;
body = commit_body;
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
}
else
{
data["decision"] = "cancel";
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
}
}
else
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
}
else if (isDefined(reply) && reply["state"] == "rejected")
bank_say(entry, bank_error(reply["code"]));
else
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
pending = [];
pending["id"] = id;
pending["body"] = body;
pending["decision"] = data["decision"];
pending["since"] = pending_since;
pending["attempts"] = 0;
level.bank_queue[level.bank_write] = pending;
level.bank_write++;
}
bank_error(code)
{
if (code == "insufficient_balance" || code == "empty")
return "^3Not enough bank balance. Your points are unchanged.";
if (code == "bank_limit")
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
if (code == "points_limit")
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
if (code == "not_in_match")
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
if (code == "pending")
return "^3A bank transfer is pending on another server. Try again shortly.";
return "^3Bank rejected the transfer. Your points are unchanged.";
}
// Only locally generated identities/configured server names pass through here.
// Never use this to print response fields: those use fixed vocabularies below.
bank_log_token(value)
{
if (!isDefined(value) || typeof(value) != "string" || value.size == 0 || value.size > 128)
return "unavailable";
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < value.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (value[i] == allowed[j])
found = true;
if (!found)
return "invalid";
}
return value;
}
bank_response_code(code)
{
// Unknown values may contain secrets or console controls, even if short.
codes = strTok("ok insufficient_balance empty bank_limit points_limit not_in_match pending invalid_request unauthorized forbidden not_found conflict disabled", " ");
for (i = 0; i < codes.size; i++)
if (isDefined(code) && code == codes[i])
return codes[i];
return "unknown";
}
bank_response_reason(reason)
{
reasons = strTok("empty_body body_limit invalid_json schema field_limit state amount_range ok_state missing_id zero_transfer", " ");
for (i = 0; i < reasons.size; i++)
if (isDefined(reason) && reason == reasons[i])
return reasons[i];
return "unknown";
}
bank_diagnostic_number(diagnostics, key)
{
if (!isDefined(diagnostics) || typeof(diagnostics) != "array" || !isDefined(diagnostics[key]))
return "unavailable";
value = diagnostics[key];
if ((typeof(value) != "int" && typeof(value) != "float") || value < 0 || value > 2147483647)
return "unavailable";
return "" + int(value);
}
bank_http_details(diagnostics)
{
detail = "";
keys = strTok("http_status curl_code dns_ms connect_ms tls_ms total_ms", " ");
for (i = 0; i < keys.size; i++)
detail += " " + keys[i] + "=" + bank_diagnostic_number(diagnostics, keys[i]);
return detail;
}
// Fixed keys bound memory. Correlation IDs/timings never form the signature.
// Report first failure, reason/status/code changes, 30s reminders and recovery.
bank_log(key, signature, detail, failed)
{
now = getTime();
previous = level.bank_logs[key];
suppressed = 0;
recovery = "";
if (isDefined(previous))
{
suppressed = previous["suppressed"];
if (failed && previous["signature"] == signature && now >= previous["time"] && now - previous["time"] < 30000)
{
previous["suppressed"]++;
level.bank_logs[key] = previous;
return;
}
if (previous["failed"] && !failed)
recovery = " recovery=true";
}
print("[tws_bank] " + detail + recovery + " suppressed=" + suppressed);
current = [];
current["signature"] = signature;
current["time"] = now;
current["failed"] = failed;
current["suppressed"] = 0;
level.bank_logs[key] = current;
}
bank_store(body, id, decision)
{
saved = bankStore(level.bank_server, level.bank_url, body);
outcome = "success";
if (!saved)
outcome = "storage_failure";
bank_log("write_" + decision, outcome, "journal_write decision=" + decision +
" request_id=" + bank_log_token(id) + " outcome=" + outcome, !saved);
return saved;
}
bank_http_outcome(action, id, correlation, reason, code, diagnostics, failed)
{
level.bank_http_signature = reason + ":" + code + ":" +
bank_diagnostic_number(diagnostics, "http_status") + ":" + bank_diagnostic_number(diagnostics, "curl_code");
level.bank_http_detail = "correlation=" + correlation + " action=" + action +
" request_id=" + bank_log_token(id) + " outcome=" + reason + " code=" + code + bank_http_details(diagnostics);
// Settlement adds progress and cleanup outcome before logging this attempt.
if (action != "commit" && action != "cancel")
bank_log(action, level.bank_http_signature, level.bank_http_detail, failed);
}
bank_http(action, body, id)
{
level.bank_diagnostic_serial++;
correlation = "bank-" + getTime() + "-" + level.bank_diagnostic_serial;
headers = [];
headers["Content-Type"] = "application/json";
headers["Authorization"] = "Bearer " + level.bank_token;
options = [];
options["method"] = "POST";
options["headers"] = headers;
options["body"] = body;
options["connect_timeout_ms"] = 5000;
options["timeout_ms"] = 15000;
request = http::request(level.bank_url + "/" + action, options);
if (!isDefined(request))
{
bank_http_outcome(action, id, correlation, "request_creation", "none", undefined, true);
return undefined;
}
request waittill("done", response, success, error, diagnostics);
reason = "ok";
if (!isDefined(success) || !success)
reason = "transport_failure";
else if (bank_diagnostic_number(diagnostics, "http_status") == "unavailable")
reason = "missing_diagnostics";
else if (diagnostics["http_status"] != 200)
reason = "http_rejection";
else if (!isDefined(response))
reason = "missing_response";
if (reason != "ok")
{
bank_http_outcome(action, id, correlation, reason, "none", diagnostics, true);
return undefined;
}
reply = bankResponse(response);
if (!isDefined(reply) || !isDefined(reply["valid"]) || !reply["valid"])
{
reason = "unknown";
if (isDefined(reply))
reason = bank_response_reason(reply["reason"]);
bank_http_outcome(action, id, correlation, "invalid_ack_" + reason, "none", diagnostics, true);
return undefined;
}
if (reply["request_id"] != id)
{
bank_http_outcome(action, id, correlation, "request_id_mismatch", "none", diagnostics, true);
return undefined;
}
if (reply["state"] == "rejected")
reason = "application_rejection";
else if ((action == "balance" && reply["state"] != "balance") ||
((action == "deposit" || action == "withdraw") && reply["state"] != "prepared") ||
((action == "commit" || action == "cancel") && !bank_resolved(action, reply["state"])))
reason = "unexpected_state";
bank_http_outcome(action, id, correlation, reason, bank_response_code(reply["code"]), diagnostics, reason != "ok");
return reply;
}
bank_resolved(decision, state)
{
if (decision == "commit")
return state == "committed";
return state == "cancelled" || state == "rejected";
}
bank_sender()
{
delay = 1;
for (;;)
{
if (level.bank_busy || level.bank_read >= level.bank_write)
{
wait 0.1;
continue;
}
entry = level.bank_queue[level.bank_read];
entry["attempts"]++;
level.bank_queue[level.bank_read] = entry;
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
duration = int((getTime() - entry["since"]) / 1000);
progress = " decision=" + entry["decision"] + " attempt=" + entry["attempts"] +
" pending_seconds=" + duration;
outcome = "pending";
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
{
// Cleanup failures never reapply points; settlement is idempotent.
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
{
bank_log("settlement", "resolved", level.bank_http_detail + progress +
" journal_remove=success settlement=resolved next_retry_seconds=0", false);
level.bank_queue[level.bank_read] = undefined;
level.bank_read++;
if (level.bank_read == level.bank_write)
{
level.bank_queue = [];
level.bank_read = 0;
level.bank_write = 0;
}
delay = 1;
continue;
}
outcome = "journal_remove_failure";
}
bank_log("settlement", level.bank_http_signature + ":" + outcome, level.bank_http_detail + progress +
" settlement=" + outcome + " next_retry_seconds=" + delay, true);
wait delay;
delay *= 2;
if (delay > 30)
delay = 30;
}
}
+602 -59
View File
@@ -3,6 +3,11 @@
#include maps\_utility; #include maps\_utility;
#include common_scripts\utility; #include common_scripts\utility;
main()
{
precacheShader("white");
}
init() init()
{ {
if (GetDvar(#"zombiemode") != "1") if (GetDvar(#"zombiemode") != "1")
@@ -11,6 +16,9 @@ init()
common_config(); common_config();
register_help_command(".help", "List available commands"); register_help_command(".help", "List available commands");
level thread service_game_over_via("end_game");
level thread service_game_over_via("game_ended");
level thread service_game_over_via("intermission");
level thread help_chat_listener(); level thread help_chat_listener();
level thread on_player_connect_dispatch(); level thread on_player_connect_dispatch();
} }
@@ -20,41 +28,84 @@ common_config()
if (isDefined(level.hud_x)) if (isDefined(level.hud_x))
return; return;
// anchor: move the whole cluster with these two // Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10; level.hud_x = 10;
level.hud_y = 10; level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
// The panel renderers below use dedicated row/column offsets instead.
level.hud_row_match = level.hud_y; level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15; level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32; level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54; level.hud_row_count = level.hud_y + 54;
level.hud_row_bar = level.hud_y + 84;
level.hud_font_small = 1.4;
level.hud_font_large = 2.0;
level.hud_font_mid = 1.6;
level.hud_bar_width = 120;
level.hud_bar_height = 8;
level.hud_col_count = level.hud_x + 90; level.hud_col_count = level.hud_x + 90;
level.hud_col_name = level.hud_x + 60; level.hud_col_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + level.hud_bar_width + 12; level.hud_col_health = level.hud_x + 120 + 12;
level.hud_health_threshold = 1.0;
level.hud_panel_right = 10;
level.hud_panel_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
level.hud_service_height = 124;
level.hud_pad = 10;
level.hud_row_header = 10;
level.hud_row_timers = 34;
level.hud_row_stats = 59;
level.hud_row_bar = 80;
level.hud_row_afk = 86;
level.hud_font_small = 1.1;
level.hud_font_large = 1.6;
level.hud_font_mid = 1.25;
level.hud_font_afk = 1.0;
level.hud_bar_width = 200;
level.hud_bar_height = 4;
level.hud_rate = 0.1; level.hud_rate = 0.1;
level.hud_c_panel = (0.07, 0.10, 0.08);
// green/amber/red ramp is health-only level.hud_panel_alpha = 0.72;
level.hud_c_label = (0.85, 0.85, 0.85); level.hud_c_label = (0.72, 0.77, 0.74);
level.hud_c_dim = (0.55, 0.55, 0.55); level.hud_c_dim = (0.65, 0.70, 0.67);
level.hud_c_count = (1, 1, 1); level.hud_c_count = (0.93, 0.96, 0.94);
level.hud_c_name = (0.4, 0.9, 0.5); level.hud_c_name = (0.65, 0.85, 0.70);
level.hud_c_high = (0.2, 1, 0.4); level.hud_c_high = (0.65, 0.85, 0.70);
level.hud_c_mid = (1, 0.8, 0); level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2); level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2); level.hud_c_afk = (1, 0.8, 0.2);
// numbers fade in below this fraction of max hp // Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
level.hud_health_threshold = 1.0; level.hud_overlay_timer_x = 6;
level.hud_overlay_match_y = 8;
level.hud_overlay_round_y = 30;
level.hud_overlay_brand_x = 320;
level.hud_overlay_brand_y = 8;
level.hud_overlay_count_x = 300;
level.hud_overlay_count_y = 440;
level.hud_overlay_count_label_x = 306;
level.hud_overlay_count_label_y = 446;
level.hud_overlay_bar_x = 242;
level.hud_overlay_bar_y = 466;
level.hud_overlay_bar_width = 156;
level.hud_overlay_bar_height = 4;
level.hud_overlay_health_y = 460;
level.hud_overlay_health_x = 414;
level.hud_overlay_slash_x = 418;
level.hud_overlay_maxhealth_x = 445;
level.hud_overlay_hp_x = 449;
level.hud_overlay_afk_x = 320;
level.hud_overlay_afk_y = 421;
level.hud_service_left = 432;
level.hud_service_right = 633;
level.hud_service_description_x = 538;
level.hud_service_title_y = 54;
level.hud_service_help_y = 80;
level.hud_service_help_pitch = 24;
level.hud_service_help_rows = 9;
level.hud_service_record_y = 36;
level.hud_service_holders_y = 60;
level.hud_service_holders_pitch = 18;
if (!isDefined(level.tws_spawn_handlers)) if (!isDefined(level.tws_spawn_handlers))
level.tws_spawn_handlers = []; level.tws_spawn_handlers = [];
@@ -111,58 +162,408 @@ help_chat_listener()
} }
} }
// A single per-player controller owns service priority, pages and expiry.
show_help() show_help()
{ {
// Cache the text per connection; repeated SetText calls consume config strings. if (isDefined(level.tws_service_ended))
if (!isDefined(self.tws_help_elems)) return;
{ self service_ensure();
elems = []; state = self.tws_service;
// Use the same anchors as the HUD, with room below the health numbers. service_advance(state, getTime());
y = level.hud_row_bar + level.hud_bar_height + 20; // Round-start notices are timely hints, not requests to resume after help.
title = make_text_elem(self, level.hud_x, y, if (isDefined(state.record) && state.record.kind == "reminder")
"left", level.hud_font_mid, level.hud_c_afk); state.record = undefined;
title SetText("Commands"); state.generation++;
elems[elems.size] = title; state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
}
// An undefined snapshot is the explicit unavailable view for manual requests.
show_record(snapshot)
{
if (isDefined(level.tws_service_ended))
return;
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
count = 0;
if (isDefined(snapshot))
count = snapshot.lines.size;
state.generation++;
state.record = service_request("record", service_page_count(count, 3), state.generation);
state.record.snapshot = snapshot;
service_render(state);
}
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows)
{
return int((max(count, 1) + rows - 1) / rows);
}
service_request(kind, pages, generation)
{
request = spawnStruct();
request.kind = kind;
request.generation = generation;
request.page = 0;
request.pages = pages;
request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request;
}
service_current(state)
{
if (isDefined(state.help))
return state.help;
return state.record;
}
service_advance(state, now)
{
elapsed = max(0, now - state.updated_at);
state.updated_at = now;
request = service_current(state);
if (!isDefined(request) || state.closed)
return;
// Only the visible request consumes time. Help leaves the record untouched.
request.remaining -= elapsed;
if (request.remaining > 0)
return;
request.page++;
request.remaining = 12000;
if (request.page >= request.pages)
{
if (request.kind == "help")
state.help = undefined;
else
state.record = undefined;
}
}
// Stable order is independent of module initialization order; absent commands
// stay absent. Unknown future commands follow the built-in ones in registry order.
help_rank(command)
{
if (command == ".help") return 0;
if (command == ".hud") return 1;
if (command == ".afk") return 2;
if (command == ".back") return 3;
if (command == ".join") return 4;
if (command == ".leaders") return 5;
if (command == ".b") return 6;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
return 9;
}
help_entries()
{
entries = [];
if (!isDefined(level.tws_help_commands))
return entries;
for (rank = 0; rank <= 9; rank++)
{
for (i = 0; i < level.tws_help_commands.size; i++) for (i = 0; i < level.tws_help_commands.size; i++)
{ {
entry = level.tws_help_commands[i]; entry = level.tws_help_commands[i];
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18, if (help_rank(entry.command) == rank)
"left", level.hud_font_small, level.hud_c_count); entries[entries.size] = entry;
elem SetText("^3" + entry.command + "^7 - " + entry.description);
elems[elems.size] = elem;
} }
self.tws_help_elems = elems;
self thread destroy_on_disconnect(elems);
} }
return entries;
for (i = 0; i < self.tws_help_elems.size; i++)
{
elem = self.tws_help_elems[i];
elem.alpha = 1;
}
// Only the expiry timer is replaced; the say listener and cleanup stay alive.
self notify("tws_help_refresh");
self thread hide_help_after_delay();
} }
hide_help_after_delay() help_command_label(command)
{
return command;
}
help_description(entry)
{
rank = help_rank(entry.command);
if (rank == 0) return "Show commands";
if (rank == 1) return "Toggle core HUD";
if (rank == 2) return "Protect & freeze";
if (rank == 3) return "Return to play";
if (rank == 4) return "Join as newcomer";
if (rank == 5) return "View records";
if (rank == 6) return "Bank balance";
if (rank == 7) return "Deposit";
if (rank == 8) return "Withdraw";
return entry.description;
}
service_ensure()
{
if (isDefined(self.tws_service))
return;
ensure_config();
state = spawnStruct();
state.elems = [];
state.commands = help_entries();
state.help = undefined;
state.record = undefined;
state.closed = false;
state.updated_at = getTime();
state.rendered = false;
state.generation = 0;
state.last_generation = 0;
state.last_page = -1;
state.record_text = [];
state.help_page = -1;
state.rows = [];
state.command_rows = [];
state.description_rows = [];
state.view_kind = undefined;
self.tws_service = state;
self thread service_loop(state);
self thread service_disconnect(state);
self thread service_end_game(state);
}
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
service_view_create(state, request)
{
service_view_destroy(state);
state.view_kind = request.kind;
state.view_paged = request.pages > 1;
if (state.view_paged)
state.page_counter = service_text(self, state, level.hud_service_right,
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
if (request.kind == "help")
{
state.help_title = service_text(self, state, level.hud_service_left,
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
rows = state.commands.size;
if (rows > level.hud_service_help_rows)
rows = level.hud_service_help_rows;
// Explicit lines also work on T5, which can collapse multiline strings.
for (i = 0; i < rows; i++)
{
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
state.command_rows[i] = service_text(self, state, level.hud_service_left,
y, "left", 1.0, "", level.hud_c_count);
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
y, "left", 1.0, "", level.hud_c_label);
}
return;
}
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
if (isDefined(state.page_counter))
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
}
service_view_destroy(state)
{
for (i = 0; i < state.elems.size; i++)
{
if (isDefined(state.elems[i]))
state.elems[i] Destroy();
}
state.elems = [];
state.view_kind = undefined;
state.view_paged = undefined;
state.page_counter = undefined;
state.help_title = undefined;
state.command_rows = [];
state.description_rows = [];
state.record_title = undefined;
state.rows = [];
state.page_text = undefined;
state.record_header_text = undefined;
state.record_text = [];
state.help_page = -1;
}
service_text(player, state, x, y, align, scale, text, colour)
{
elem = make_overlay_text(player, x, y, align, scale, colour);
elem.alpha = 0;
elem.archived = false;
if (text != "")
elem SetText(text);
state.elems[state.elems.size] = elem;
return elem;
}
service_render(state)
{
if (state.closed)
return;
request = service_current(state);
page = -1;
generation = 0;
if (isDefined(request))
{
page = request.page;
generation = request.generation;
}
if (state.rendered && state.last_generation == generation && state.last_page == page)
return;
state.rendered = true;
state.last_generation = generation;
state.last_page = page;
if (!isDefined(request))
{
service_view_destroy(state);
return;
}
paged = request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
service_view_create(state, request);
for (i = 0; i < state.elems.size; i++)
state.elems[i].alpha = 0;
if (paged)
{
page_text = "" + (page + 1) + " / " + request.pages;
if (!isDefined(state.page_text) || state.page_text != page_text)
{
state.page_counter SetText(page_text);
state.page_text = page_text;
}
state.page_counter.alpha = 1;
}
if (request.kind == "help")
{
state.help_title.alpha = 1;
for (i = 0; i < state.command_rows.size; i++)
{
index = page * level.hud_service_help_rows + i;
if (index >= state.commands.size)
continue;
if (state.help_page != page)
{
entry = state.commands[index];
state.command_rows[i] SetText(help_command_label(entry.command));
state.description_rows[i] SetText(help_description(entry));
}
state.command_rows[i].alpha = 1;
state.description_rows[i].alpha = 1;
}
state.help_page = page;
return;
}
header = "Map record unavailable";
snapshot = request.snapshot;
if (isDefined(snapshot))
{
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record | Round " + snapshot.round;
}
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
state.record_title SetText(header);
state.record_header_text = header;
}
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++)
{
index = page * 3 + i;
if (index < snapshot.lines.size)
{
text = snapshot.lines[index];
if (!isDefined(state.record_text[i]) || state.record_text[i] != text)
{
state.rows[i] SetText(text);
state.record_text[i] = text;
}
state.rows[i].alpha = 1;
}
}
}
service_loop(state)
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_help_refresh"); level endon("tws_service_game_over");
for (;;)
wait 12;
for (i = 0; i < self.tws_help_elems.size; i++)
{ {
elem = self.tws_help_elems[i]; service_advance(state, getTime());
elem.alpha = 0; service_render(state);
wait 0.1;
} }
} }
// only connect listener in the fleet; a second one crashes the T5 SP handshake service_disconnect(state)
{
self waittill("disconnect");
service_destroy(state);
}
service_end_game(state)
{
self endon("disconnect");
level waittill("tws_service_game_over");
service_destroy(state);
}
service_destroy(state)
{
if (state.closed)
return;
state.closed = true;
state.help = undefined;
state.record = undefined;
service_view_destroy(state);
}
service_game_over_via(event)
{
level endon("tws_service_game_over");
level waittill(event);
level.tws_service_ended = true;
level notify("tws_service_game_over");
}
// The only connected listener; modules use the shared registries.
on_player_connect_dispatch() on_player_connect_dispatch()
{ {
for (;;) for (;;)
@@ -211,7 +612,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x; elem.x = x;
elem.y = y; elem.y = y;
elem.foreground = 1; elem.foreground = 1;
elem.fontScale = scale; // Sub-1.0 text can render oversized on clients; keep a safe minimum.
elem.fontScale = max(1.0, scale);
elem.alpha = 1; elem.alpha = 1;
elem.color = colour; elem.color = colour;
@@ -238,6 +640,147 @@ make_shader_elem(player, x, y)
return elem; return elem;
} }
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
make_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x, y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, x, y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
return elem;
}
make_overlay_width(width)
{
// Native max() returns a float; shader dimensions must be integers.
return int(max(1, width));
}
make_core_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_overlay_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
// engine safe area. Horizontal conversion preserves the original size at 16:9;
// other aspects retain the right-edge anchor with proportional horizontal size.
make_panel_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_panel_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_panel_width(width), height);
return elem;
}
// Native shader widths are integers; dynamic fills use the same conversion.
make_panel_width(width)
{
// max() returns a float even for integer inputs; cast its final result.
return int(max(1, width * level.hud_panel_x_scale));
}
// Core elements share a visibility mask; services keep their independent alpha.
make_core_text(player, x, y, align_x, scale, colour)
{
elem = make_panel_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
register_core_elem(player, elem)
{
live = [];
if (isDefined(player.tws_core_hud_elems))
{
for (i = 0; i < player.tws_core_hud_elems.size; i++)
{
old = player.tws_core_hud_elems[i];
if (isDefined(old))
live[live.size] = old;
}
}
live[live.size] = elem;
player.tws_core_hud_elems = live;
set_core_alpha(player, elem, elem.alpha);
}
set_core_alpha(player, elem, alpha)
{
if (!isDefined(elem))
return;
elem.tws_core_alpha = alpha;
elem.alpha = alpha;
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
elem.alpha = 0;
}
toggle_core_hud()
{
hidden = false;
if (isDefined(self.tws_hud_hidden))
hidden = self.tws_hud_hidden;
self.tws_hud_hidden = !hidden;
if (!isDefined(self.tws_core_hud_elems))
return;
live = [];
for (i = 0; i < self.tws_core_hud_elems.size; i++)
{
elem = self.tws_core_hud_elems[i];
if (!isDefined(elem))
continue;
set_core_alpha(self, elem, elem.tws_core_alpha);
live[live.size] = elem;
}
self.tws_core_hud_elems = live;
}
destroy_on_disconnect(elems) destroy_on_disconnect(elems)
{ {
self waittill("disconnect"); self waittill("disconnect");
+143 -161
View File
@@ -1,4 +1,4 @@
/* tws_t5_hud.gsc — bottom-left HUD stack. Timer / zombie count / health bar. */ /* tws_t5_hud.gsc - minimal edge HUD, toggleable with .hud. */
#include maps\_utility; #include maps\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -9,23 +9,108 @@ 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(".hud", "Toggle core HUD");
level thread round_watcher(); level thread round_watcher();
level thread game_over_watcher(); level thread game_over_watcher();
level thread hud_chat_listener();
scripts\sp\tws_t5_common::register_connect_handler(::hud_connect);
scripts\sp\tws_t5_common::register_spawn_handler(::hud_start); scripts\sp\tws_t5_common::register_spawn_handler(::hud_start);
} }
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\sp\tws_t5_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
{
self endon("disconnect");
if (isDefined(self.tws_hud_elems))
return;
self.tws_hud_elems = [];
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
// Center the entire readout on the track, regardless of the digit count.
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
self.tws_hud_count = self hud_text(count_x,
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
// Independent numeric elements avoid a new config string on every tick.
// The readout appears only after a valid damaged-health sample.
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_health, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_slash, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_hp, 0);
track = scripts\sp\tws_t5_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
(0.18, 0.22, 0.19), 0.8, 10);
self.tws_hud_elems[self.tws_hud_elems.size] = track;
self.tws_hud_bar = scripts\sp\tws_t5_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
level.hud_c_high, 0, 11);
self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(self.tws_hud_elems);
}
hud_text(x, y, align, scale, colour, text)
{
elem = scripts\sp\tws_t5_common::make_core_overlay_text(self, x, y, align, scale, colour);
elem SetText(text);
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
return elem;
}
hud_start() hud_start()
{ {
self endon("disconnect"); self endon("disconnect");
self hud_connect();
if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended))
return;
self.tws_hud_bound = true;
self thread hud_timer_row(); self thread hud_timer_row();
self thread hud_zombie_counter(); self thread hud_zombie_counter();
self thread hud_health_display(); self thread hud_health_display();
} }
// BO1 maps use different end-of-match notify names; race all three // Race the map-dependent game-over notifications once at level scope.
game_over_watcher() game_over_watcher()
{ {
level thread game_over_via("end_game"); level thread game_over_via("end_game");
@@ -37,17 +122,14 @@ game_over_via(notify_name)
{ {
level endon("hud_game_over"); level endon("hud_game_over");
level waittill(notify_name); level waittill(notify_name);
level.tws_ended = true;
level notify("hud_game_over"); level notify("hud_game_over");
} }
/* // Pre-set round 1 is seeded on first spawn; only observed transitions seed here.
Watch level.round_number rather than a notify — the notify names vary
between BO1 map scripts, the variable doesn't. Seed on OBSERVED
transitions only: many maps pre-set round_number = 1 at level init
and we don't want getTime() captured there (see NOTES.md).
*/
round_watcher() round_watcher()
{ {
level endon("hud_game_over");
last = 0; last = 0;
if (isDefined(level.round_number)) if (isDefined(level.round_number))
@@ -77,7 +159,7 @@ hud_timer_row()
{ {
self endon("disconnect"); self endon("disconnect");
// fallback seed for the round-1-already-active case (round_watcher misses it) // A visible HUD for a waiting newcomer must not start a pre-set round 1.
if (!isDefined(level.match_start_time) && if (!isDefined(level.match_start_time) &&
isDefined(level.round_number) && isDefined(level.round_number) &&
level.round_number >= 1) level.round_number >= 1)
@@ -87,59 +169,17 @@ hud_timer_row()
level notify("hud_match_started"); level notify("hud_match_started");
} }
match = scripts\sp\tws_t5_common::make_text_elem( round = self.tws_hud_round;
self, level.hud_x, level.hud_row_match,
"left", level.hud_font_small, level.hud_c_dim);
match.alpha = 0; // revealed by match_timer_bind when round 1 opens
round = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_x, level.hud_row_round,
"left", level.hud_font_small, level.hud_c_dim);
round SetTimerUp(0); round SetTimerUp(0);
// name only shows in co-op — your own name is noise in solo
name = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_x, level.hud_row_name,
"left", level.hud_font_small, level.hud_c_name);
if (isDefined(self.name))
name SetText(self.name);
else
name SetText(self.playername);
name.alpha = 0;
elems = [];
elems[elems.size] = match;
elems[elems.size] = round;
elems[elems.size] = name;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
self thread reset_timer_each_round(round); self thread reset_timer_each_round(round);
self thread match_timer_bind(match); self thread match_timer_bind(self.tws_hud_match);
self thread freeze_timers_on_game_over(match, round); self thread freeze_timers_on_game_over(self.tws_hud_match, round);
coop = -1;
for (;;)
{
is_coop = 0;
if (isDefined(level.players) && level.players.size > 1)
is_coop = 1;
if (is_coop != coop)
{
name.alpha = is_coop;
coop = is_coop;
}
wait (1);
}
} }
reset_timer_each_round(timer) reset_timer_each_round(timer)
{ {
level endon("hud_game_over");
self endon("disconnect"); self endon("disconnect");
for (;;) for (;;)
@@ -151,26 +191,20 @@ reset_timer_each_round(timer)
} }
} }
// SetTimerUp only ticks with arg 0 on T5 SP; late joiners start at 0 // T5 SP only ticks with a zero seed; late joiners retain that engine limitation.
match_timer_bind(elem) match_timer_bind(elem)
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(level.match_start_time)) if (!isDefined(level.match_start_time))
level waittill("hud_match_started"); level waittill("hud_match_started");
if (isDefined(elem)) if (isDefined(elem))
{
elem SetTimerUp(0); elem SetTimerUp(0);
elem.alpha = 1;
}
} }
/* // Preserve authoritative frozen clocks when the core is shown after game over.
Level-scope trigger only — per-player death/health polling is intercepted
by the zombiemode scripts. Freeze value math relies on the timers being
anchored at first spawn / first round transition, not level init.
*/
freeze_timers_on_game_over(match_elem, round_elem) freeze_timers_on_game_over(match_elem, round_elem)
{ {
self endon("disconnect"); self endon("disconnect");
@@ -186,31 +220,21 @@ freeze_timers_on_game_over(match_elem, round_elem)
hud_zombie_counter() hud_zombie_counter()
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
label = scripts\sp\tws_t5_common::make_text_elem( count = self.tws_hud_count;
self, level.hud_x, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_label);
label SetText("Zombies");
count = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_col_count, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_count);
count SetValue(0);
elems = [];
elems[elems.size] = label;
elems[elems.size] = count;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
last = -1; last = -1;
for (;;) for (;;)
{ {
remaining = get_enemy_count() + level.zombie_total; remaining = get_enemy_count();
if (isDefined(level.zombie_total))
remaining += max(0, level.zombie_total);
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;
} }
@@ -238,83 +262,45 @@ get_enemy_count()
hud_health_display() hud_health_display()
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(self.maxhealth) || self.maxhealth <= 0) bar_width = level.hud_overlay_bar_width;
self.maxhealth = 100; bar = self.tws_hud_bar;
cur = self.tws_hud_health;
bar_width = level.hud_bar_width; slash = self.tws_hud_slash;
max_hp = self.tws_hud_maxhealth;
// -2 on both axes so the back frames the bar with 2px padding hp = self.tws_hud_hp;
back = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2); last_width = -1;
back.alpha = 0.5;
back.color = (0, 0, 0);
back SetShader("black", bar_width + 4, level.hud_bar_height + 4);
bar = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x, level.hud_row_bar);
bar.color = level.hud_c_high;
bar SetShader("white", bar_width, level.hud_bar_height);
/*
"87/160" is three elements, not a rebuilt string. SetText allocates
a config string per call; per-tick rebuilds crash the server.
cur is right-aligned so digits grow leftward and the slash + max
never shift.
*/
cur = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_col_health + 26, level.hud_row_bar + 3,
"right", level.hud_font_mid, level.hud_c_high);
cur SetValue(self.health);
slash = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_col_health + 30, level.hud_row_bar + 3,
"center", level.hud_font_mid, level.hud_c_high);
slash SetText("/");
max_hp = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_col_health + 34, level.hud_row_bar + 3,
"left", level.hud_font_mid, level.hud_c_high);
max_hp SetValue(self.maxhealth);
cur.alpha = 0;
slash.alpha = 0;
max_hp.alpha = 0;
elems = [];
elems[elems.size] = back;
elems[elems.size] = bar;
elems[elems.size] = cur;
elems[elems.size] = slash;
elems[elems.size] = max_hp;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
last_width = bar_width;
last_health = -1; last_health = -1;
last_max = -1; last_max = -1;
last_shown = -1;
for (;;) for (;;)
{ {
if (!isDefined(self.maxhealth) || self.maxhealth <= 0) // Display fallbacks must not alter gameplay health values.
self.maxhealth = 100; maximum = 100;
if (isDefined(self.maxhealth) && self.maxhealth > 0)
frac = self.health / self.maxhealth; maximum = self.maxhealth;
current = 0;
if (isDefined(self.health))
current = max(0, self.health);
frac = current / (maximum * 1.0);
if (frac > 1) if (frac > 1)
frac = 1; frac = 1;
if (frac < 0) width = scripts\sp\tws_t5_common::make_overlay_width(frac * bar_width);
frac = 0;
width = int(max(frac * bar_width, 1));
if (width != last_width) if (width != last_width)
{ {
bar SetShader("white", width, level.hud_bar_height); bar SetShader("white", width, level.hud_overlay_bar_height);
last_width = width; last_width = width;
} }
colour = level.hud_c_high; bar_alpha = 1;
if (frac == 0)
bar_alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, bar, bar_alpha);
colour = level.hud_c_high;
if (frac <= 0.25) if (frac <= 0.25)
colour = level.hud_c_low; colour = level.hud_c_low;
else if (frac <= 0.5) else if (frac <= 0.5)
@@ -324,31 +310,27 @@ hud_health_display()
cur.color = colour; cur.color = colour;
slash.color = colour; slash.color = colour;
max_hp.color = colour; max_hp.color = colour;
hp.color = colour;
// numbers only show once you're hurt readout_alpha = 0;
show = 0; if (isDefined(self.health) && current < maximum)
readout_alpha = 1;
scripts\sp\tws_t5_common::set_core_alpha(self, cur, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, slash, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, max_hp, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, hp, readout_alpha);
if (frac < level.hud_health_threshold) // Keep health numbers current even at full health or while hidden.
show = 1; if (current != last_health)
if (show != last_shown)
{ {
cur.alpha = show; cur SetValue(current);
slash.alpha = show; last_health = current;
max_hp.alpha = show;
last_shown = show;
} }
if (self.health != last_health) if (maximum != last_max)
{ {
cur SetValue(self.health); max_hp SetValue(maximum);
last_health = self.health; last_max = maximum;
}
if (self.maxhealth != last_max)
{
max_hp SetValue(self.maxhealth);
last_max = self.maxhealth;
} }
wait (level.hud_rate); wait (level.hud_rate);
+158 -113
View File
@@ -1,4 +1,4 @@
/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */ /* tws_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\_utility; #include 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");
@@ -124,120 +208,18 @@ lb_request(manual)
level endon("game_ended"); level endon("game_ended");
level endon("intermission"); level endon("intermission");
snapshot = lb_cached_record();
if (!isDefined(snapshot))
{
if (manual)
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
return;
}
// A manual replay replaces any welcome announcement still waiting to run. // A manual replay replaces any welcome announcement still waiting to run.
// This also applies when the cache is unavailable.
if (manual) if (manual)
self notify("tws_lb_requested"); self notify("tws_lb_requested");
self lb_show_hud(snapshot); self.tws_lb_last_request = getTime();
} snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
lb_show_hud(snapshot) // Common owns pagination, help priority and the single service window.
{ // Undefined snapshots produce a private unavailable page for manual replay.
// Stop the previous display and its cleanup threads before replacing it. self scripts\sp\tws_t5_common::show_record(snapshot);
self notify("tws_lb_hud_done");
if (isDefined(self.tws_lb_elems))
lb_hud_destroy(self.tws_lb_elems);
self endon("disconnect");
self endon("tws_lb_hud_done");
scripts\sp\tws_t5_common::ensure_config();
// Match .help's position, typography and row spacing below the health bar.
y = level.hud_row_bar + level.hud_bar_height + 20;
elems = [];
title = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
title.alpha = 0;
if (snapshot.round == 0)
title SetText("No recorded high round yet");
else
title SetText("Record: Round " + snapshot.round);
elems[elems.size] = title;
for (i = 0; i < 3; i++)
{
row = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
row.alpha = 0;
elems[elems.size] = row;
}
// Cleanup runs independently of the display and ends when it is replaced.
self.tws_lb_elems = elems;
self thread lb_hud_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission");
offset = 0;
for (;;)
{
// Text changes only once per page, never during the visibility loop.
for (i = 0; i < 3; i++)
{
row = elems[i + 1];
if (offset + i < snapshot.lines.size)
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
else
row SetText("");
}
remaining = 12;
while (remaining > 0)
{
visible = !self lb_hud_help_visible();
for (i = 0; i < elems.size; i++)
elems[i].alpha = visible;
wait 0.1;
// Pause the page while .help occupies the same area.
if (visible)
remaining -= 0.1;
}
offset += 3;
if (offset >= snapshot.lines.size)
break;
}
lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done");
}
lb_hud_help_visible()
{
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
}
lb_hud_disconnect(elems)
{
self endon("tws_lb_hud_done");
self waittill("disconnect");
lb_hud_destroy(elems);
}
lb_hud_end_game(elems, event)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
level waittill(event);
lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done");
}
lb_hud_destroy(elems)
{
for (i = 0; i < elems.size; i++)
{
if (isDefined(elems[i]))
elems[i] Destroy();
}
} }
lb_refresh() lb_refresh()
@@ -480,22 +462,85 @@ lb_holder_lines(names)
{ {
lines = []; lines = [];
line = ""; line = "";
columns = 0;
for (i = 0; i < names.size; i++) for (i = 0; i < names.size; i++)
{ {
if (line != "" && line.size + names[i].size + 2 > 100) name = lb_holder_name(names[i]);
width = lb_holder_width(name);
if (line != "" && columns + width + 2 > 16)
{ {
lines[lines.size] = line; lines[lines.size] = line;
line = ""; line = "";
columns = 0;
} }
if (line != "") if (line != "")
{
line += ", "; line += ", ";
line += names[i]; columns += 2;
}
line += name;
columns += width;
} }
if (line != "") if (line != "")
lines[lines.size] = line; lines[lines.size] = line;
return lines; return lines;
} }
lb_holder_name(value)
{
// The service has 200 virtual units at font scale 1.1. Use a conservative
// 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics.
// A long name gets one bounded entry; no tied holder is dropped.
value = lb_display_text(value, 64);
if (value == "")
value = "Unknown player";
if (lb_holder_width(value) <= 16)
return value;
output = "";
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
width = 1;
if (code >= 128)
width = 2;
if (columns + width > 13)
break;
count = lb_glyph_bytes(code);
output += getSubStr(value, i, i + count);
columns += width;
i += count - 1;
}
return output + "...";
}
lb_holder_width(value)
{
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
if (code >= 128)
columns += 2;
else
columns++;
i += lb_glyph_bytes(code) - 1;
}
return columns;
}
lb_glyph_bytes(code)
{
if (code >= 240)
return 4;
if (code >= 224)
return 3;
if (code >= 192)
return 2;
return 1;
}
lb_display_text(value, limit) lb_display_text(value, limit)
{ {
output = ""; output = "";
+63
View File
@@ -0,0 +1,63 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1")
return;
scripts\sp\tws_t5_common::ensure_config();
level.moderation_ready = false;
scripts\sp\tws_t5_common::register_connect_handler(::moderation_connected);
level thread moderation_loop();
}
moderation_connected()
{
self endon("disconnect");
if (!level.moderation_ready)
return;
moderation_observe(self);
moderationPump();
}
moderation_observe(player)
{
if (!isDefined(player))
return;
guid = player getGuid();
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return;
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
guid = "" + guid;
name = player.name;
if (!isDefined(name))
name = "Unknown player";
player moderationObserve(guid, name);
}
moderation_loop()
{
for (;;)
{
if (GetDvar("tws_moderation_enabled") == "0")
{
moderationStop();
level.moderation_ready = false;
wait 3;
continue;
}
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
if (!level.moderation_ready)
{
wait 3;
continue;
}
players = getPlayers();
for (i = 0; i < players.size; i++)
moderation_observe(players[i]);
moderationPump();
wait 0.5;
}
}
+36
View File
@@ -101,3 +101,39 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
The localhost tests compile the actual HTTP utility against the host libcurl. The localhost tests compile the actual HTTP utility against the host libcurl.
They do not validate Windows linking, its Schannel backend or game VM notification They do not validate Windows linking, its Schannel backend or game VM notification
handling. Those checks require the Windows build and a running Plutonium server. handling. Those checks require the Windows build and a running Plutonium server.
## Bank support (TWS 1.1.0)
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
decisions and strict response validation for the new bank script. Build and
install the DLL, then restart the full process. No upstream pins or external
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
Portable checks: `python3 tools/test_plugin_bank.py`.
## Bank diagnostics (TWS 1.2.0)
`bankVersion()` returns the loaded DLL's TWS component version, independently of
upstream pins and the native loader's unchanged `plugin_version()` value.
`bankResponse()` now also returns `reason`: `ok` on success, otherwise one of
`empty_body`, `body_limit`, `invalid_json`, `schema`, `field_limit`, `state`,
`amount_range`, `ok_state`, `missing_id`, or `zero_transfer`. Invalid responses
clear all parsed fields. Existing acknowledgment acceptance rules are unchanged.
The returned `code` on valid responses remains API data; bank scripts allowlist
it before logging. Callers must never log arbitrary response fields.
Native journal logs name load/write/remove, limit identical failures to one
per 30 seconds, and log recovery with suppressed counts. Exception text, journal
contents and credentials are excluded. The new scripts require this DLL even
when disabled by dvar because `bankVersion()` must link. No new third-party
dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t6",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,98 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/bank.hpp>
#include <array>
#include <chrono>
namespace bank
{
namespace
{
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
{
return {"tws-bank/t6", server, endpoint};
}
// Only fixed operation names are printed, never exception text or paths
// from private configuration. Per-operation state has bounded memory.
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, 3> states;
constexpr const char* names[] = {"load", "write", "remove"};
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_bank] 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;
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("bankversion", []() { return std::string("1.2.0"); });
gsc::function::add("bankload", [](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["decision"] = value.decision;
entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0, false);
return entries;
}
catch (...) { storage_result(0, true); return {}; }
});
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{
try { spool(server, endpoint).store(body); storage_result(1, false); return true; }
catch (...) { storage_result(1, true); return false; }
});
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { spool(server, endpoint).erase(id); storage_result(2, false); return true; }
catch (...) { storage_result(2, true); return false; }
});
gsc::function::add("bankresponse", [](const std::string& response)
{
const auto ack = utils::bank::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["amount"] = ack.amount;
result["balance"] = ack.balance;
return result;
});
}
};
}
REGISTER_COMPONENT(bank::component)
@@ -0,0 +1,161 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "scripting.hpp"
#include "scheduler.hpp"
#include "command.hpp"
#include "game/game.hpp"
#include "game/scripting/array.hpp"
#include <utils/moderation.hpp>
#include <utils/http.hpp>
#include <utils/cryptography.hpp>
#include <atomic>
#include <chrono>
#include <memory>
namespace moderation
{
namespace
{
using runtime = utils::moderation::runtime;
struct transfer
{
std::string session, response;
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
std::atomic<bool> done{false};
bool success = false;
};
std::shared_ptr<runtime> current;
std::shared_ptr<transfer> pending;
std::string endpoint, credential, configured_server;
int configured_generation = 0;
std::chrono::steady_clock::time_point next_poll{}, next_log{};
bool fault = false;
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
void failure()
{
if (std::chrono::steady_clock::now() >= next_log)
{
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
}
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
std::string execute(const std::string& kind, int slot, const std::string& reason)
{
auto active = current;
if (!active) return "failed";
// This function is called only by the server-thread GSC pump. No queue/yield
// separates runtime target validation from synchronous engine dispatch.
if (kind == "announce" || kind == "warn" || kind == "message")
{
const auto text = std::string("j \"") + reason + "\"";
game::SV_GameSendServerCommand(slot, 0, text.c_str());
return "confirmed";
}
if (slot < 0 || slot >= 64) return "failed";
if (kind == "mute" || kind == "unmute")
{
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
return "unknown"; // Engine has no mute-state readback in this adapter.
}
if (kind != "kick") return "failed";
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
}
void pump()
{
if (!current || fault) return;
try
{
if (pending && pending->done.load())
{
auto completed = pending; pending.reset();
if (completed->session == current->session)
{
if (completed->success)
{
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
current->accept(completed->response, now(), age);
}
else failure();
}
}
auto active = current;
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
{
return current == active ? execute(kind, slot, reason) : std::string("failed");
});
if (current != active) return;
if (!pending && std::chrono::steady_clock::now() >= next_poll)
{
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
scheduler::thread_pool.push([work, request, target, token]
{
try
{
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
limits.max_response_bytes = 1024 * 1024;
const auto response = utils::http::get_data(target, request,
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
if (response && response->code == CURLE_OK && response->status == 200)
{ work->response = response->buffer; work->success = true; }
}
catch (...) { work->success = false; }
work->done.store(true);
});
}
}
catch (...) { failure(); }
}
}
class component final : public component_interface
{
public:
void on_startup(plugin::plugin* plugin) override
{
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
scripting::on_shutdown([]
{
// In-flight requests own only native data. Results from the old VM are abandoned.
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
{
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
try
{
auto configured = std::make_shared<runtime>("tws-moderation/t6", server, url, generation,
utils::cryptography::sha256::compute(token, true));
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
}
catch (...) { fault = true; failure(); return false; }
});
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
{
if (!current || fault) return false;
const auto ref = entity.get_entity_reference();
if (ref.classnum != 0 || ref.entnum >= 64) return false;
try { current->observe(ref.entnum, guid, name); return true; }
catch (...) { failure(); return false; }
});
gsc::function::add("moderationstop", []
{
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationpump", pump);
}
};
}
REGISTER_COMPONENT(moderation::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;
};
}
@@ -0,0 +1,187 @@
#include <stdinc.hpp>
#include "bank.hpp"
#include <cstdint>
#include <iomanip>
#include <set>
namespace utils::bank
{
namespace
{
constexpr size_t max_body_size = 0x5000;
constexpr size_t max_file_size = 16 * 1024 * 1024;
constexpr size_t max_reports = 4096;
// Stable, compact filename for arbitrary server names and endpoints.
// The envelope also checks both strings, so a collision fails closed.
std::string scope_name(const std::string& scope)
{
uint64_t hash = 14695981039346656037ull;
for (const unsigned char byte : scope)
hash = (hash ^ byte) * 1099511628211ull;
std::ostringstream result;
result << std::hex << std::setfill('0') << std::setw(16) << hash;
return result.str();
}
}
acknowledgement parse_acknowledgement(const std::string& response)
{
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 result;
if (!data.at("ok").is_boolean()) return invalid("schema");
result.state = data.at("state").get<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
if (result.id.size() > 64 || result.code.size() > 64)
return invalid("field_limit");
if (result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected") return invalid("state");
for (const auto* key : {"amount", "balance"})
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000)
return invalid("amount_range");
result.amount = data.at("amount").get<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (result.state != "rejected")) return invalid("ok_state");
if (result.state != "balance" && result.id.empty()) return invalid("missing_id");
if ((result.state == "prepared" || result.state == "committed") && result.amount == 0)
return invalid("zero_transfer");
result.valid = true;
result.reason = "ok";
return result;
}
catch (...) { return invalid("schema"); }
}
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
: server_(std::move(server)), endpoint_(std::move(endpoint))
{
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
}
report outbox::decode(const std::string& body) const
{
if (body.empty() || body.size() >= max_body_size)
throw std::runtime_error("invalid bank request size");
const auto data = nlohmann::json::parse(body);
if (!data.is_object() || data.at("server") != server_)
throw std::runtime_error("invalid bank scope");
report value;
value.id = data.at("request_id").get<std::string>();
value.decision = data.at("decision").get<std::string>();
if (value.id.empty() || value.id.size() > 64 ||
(value.decision != "cancel" && value.decision != "commit"))
throw std::runtime_error("invalid bank decision");
value.body = body;
return value;
}
std::vector<report> outbox::load() const
{
if (!std::filesystem::exists(file_))
return {};
if (std::filesystem::file_size(file_) > max_file_size)
throw std::runtime_error("bank outbox exceeds size limit");
std::ifstream stream(file_, std::ios::binary);
if (!stream)
throw std::runtime_error("cannot open bank outbox");
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("reports").is_array() || data.at("reports").size() > max_reports)
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
std::vector<report> reports;
std::set<std::string> identities;
for (const auto& body : data.at("reports"))
{
auto value = decode(body.get<std::string>());
if (!identities.emplace(value.id).second)
throw std::runtime_error("duplicate identity in bank outbox");
reports.push_back(std::move(value));
}
return reports;
}
void outbox::save(const std::vector<report>& reports) const
{
if (reports.size() > max_reports)
throw std::runtime_error("bank outbox is full; delivery blocked");
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
{"reports", nlohmann::json::array()}};
for (const auto& value : reports)
data["reports"].push_back(value.body);
const auto encoded = data.dump();
if (encoded.size() > max_file_size)
throw std::runtime_error("bank outbox is full; delivery blocked");
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 bank outbox; old file retained");
stream.close();
if (stream.fail())
throw std::runtime_error("cannot close bank outbox; 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 bank outbox; old file retained");
#else
// Used by the portable tests; the production plugin targets Windows.
std::filesystem::rename(temporary, file_);
#endif
}
void outbox::store(const std::string& body) const
{
const auto incoming = decode(body);
auto reports = load();
for (auto& value : reports)
{
if (value.id != incoming.id)
continue;
auto old_data = nlohmann::json::parse(value.body);
auto new_data = nlohmann::json::parse(body);
old_data.erase("decision");
new_data.erase("decision");
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
throw std::runtime_error("bank identity/decision conflict");
if (value.body != body)
{
value = incoming;
save(reports);
}
return;
}
reports.push_back(incoming);
save(reports);
}
void outbox::erase(const std::string& id) const
{
auto reports = load();
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
{
return value.id == id;
});
if (end == reports.end())
return;
reports.erase(end, reports.end());
save(reports);
}
}
@@ -0,0 +1,32 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace utils::bank
{
struct report { std::string id, decision, body; };
struct acknowledgement
{
bool valid = false;
std::string state, code, id;
// Fixed diagnostic vocabulary; never includes response text.
std::string reason = "schema";
int amount = 0, balance = 0;
};
acknowledgement parse_acknowledgement(const std::string& response);
// One game-thread writer; cancel intent is replaced by commit before points move.
class outbox
{
public:
outbox(std::filesystem::path root, std::string server, std::string endpoint);
std::vector<report> load() const;
void store(const std::string& body) const;
void erase(const std::string& id) const;
private:
std::filesystem::path file_;
std::string server_, endpoint_;
report decode(const std::string& body) const;
void save(const std::vector<report>& reports) const;
};
}
@@ -14,13 +14,17 @@ namespace utils::http
{ {
namespace namespace
{ {
struct response_buffer { std::string bytes; size_t limit; };
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
{ {
const auto buffer = static_cast<std::string*>(userp); const auto buffer = static_cast<response_buffer*>(userp);
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
const auto total_size = size * nmemb; const auto total_size = size * nmemb;
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
try try
{ {
buffer->append(static_cast<char*>(contents), total_size); buffer->bytes.append(static_cast<char*>(contents), total_size);
} }
catch (...) catch (...)
{ {
@@ -68,7 +72,7 @@ namespace utils::http
header_list.reset(appended); header_list.reset(appended);
} }
std::string buffer{}; response_buffer buffer{{}, limits.max_response_bytes};
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get()); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
curl_easy_setopt(curl, CURLOPT_URL, url.data()); curl_easy_setopt(curl, CURLOPT_URL, url.data());
@@ -106,7 +110,7 @@ namespace utils::http
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME); response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME); response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
if (response.code == CURLE_OK) if (response.code == CURLE_OK)
response.buffer = std::move(buffer); response.buffer = std::move(buffer.bytes);
return response; return response;
} }
} }
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <cstddef>
#include <optional> #include <optional>
#include <unordered_map> #include <unordered_map>
@@ -25,6 +26,8 @@ namespace utils::http
{ {
long connect_timeout_ms = 5000; long connect_timeout_ms = 5000;
long timeout_ms = 15000; long timeout_ms = 15000;
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
size_t max_response_bytes = 0;
}; };
using headers = std::unordered_map<std::string, std::string>; using headers = std::unordered_map<std::string, std::string>;
@@ -0,0 +1,349 @@
#include <stdinc.hpp>
#include "moderation.hpp"
#include <chrono>
#include <cctype>
#include <ctime>
#include <iomanip>
#include <random>
#include <set>
namespace utils::moderation
{
namespace
{
constexpr size_t max_file = 16 * 1024 * 1024;
constexpr size_t max_response = 1024 * 1024;
bool identifier(const std::string& value, size_t limit = 128)
{
return !value.empty() && value.size() <= limit &&
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
});
}
std::string bounded_text(const std::string& value, size_t limit)
{
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
// split a codepoint or let one bad name poison the complete JSON roster.
std::string result;
for (size_t i = 0; i < value.size() && result.size() < limit;)
{
const auto ch = static_cast<unsigned char>(value[i]);
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
bool valid = width && i + width <= value.size();
for (size_t j = 1; valid && j < width; ++j)
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
if (valid && width >= 3)
{
const auto second = static_cast<unsigned char>(value[i + 1]);
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
}
if (!valid) { result += '?'; ++i; continue; }
if (result.size() + width > limit) break;
result.append(value, i, width); i += width;
}
return result;
}
std::string player_name(const std::string& value)
{
auto name = bounded_text(value, 255);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
auto visible = name;
// Match ECMAScript trim whitespace so the controller cannot reject an
// otherwise complete roster because one display name is blank.
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
return visible.empty() ? "Unknown player" : name;
}
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
std::string hash_scope(const std::string& text)
{
unsigned long long hash = 14695981039346656037ull;
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
std::ostringstream out; out << std::hex << hash; return out.str();
}
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
std::string field(const json& value, const char* key, size_t max, bool empty = false)
{
require(value.contains(key) && value.at(key).is_string());
auto result = value.at(key).get<std::string>();
require((empty || !result.empty()) && result.size() <= max);
return result;
}
void reason(const json& value) { field(value, "reason", 500, true); }
void sanction(const json& value, long long revision)
{
require(value.is_object());
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
}
void action(const json& value)
{
require(value.is_object() && identifier(field(value, "id", 128)));
auto kind = field(value, "kind", 16);
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
reason(value); timestamp(field(value, "expiresAt", 32));
}
}
long long timestamp(const std::string& value)
{
require(value.size() == 20 || value.size() == 24);
require(value.back() == 'Z' && value[10] == 'T');
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
const auto original = tm;
#ifdef _WIN32
auto result = _mkgmtime64(&tm);
#else
auto result = timegm(&tm);
#endif
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
return result;
}
std::string command_text(const std::string& value)
{
// Untrusted text is never a console delimiter or a quoted command escape.
std::string result;
for (unsigned char ch : bounded_text(value, 240))
{
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
}
return result;
}
std::string canonical_guid(const std::string& value)
{
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
{
const auto first = value.find_first_not_of('0');
return first == std::string::npos ? "0" : value.substr(first);
}
return value;
}
std::string new_session()
{
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
return out.str();
}
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
{
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
if (std::filesystem::exists(file_))
{
require(std::filesystem::file_size(file_) < max_file);
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
{
require(identifier(it.key())); action(it.value().at("action"));
auto state = field(it.value(), "state", 16);
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
}
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
saved_ = std::move(value);
}
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
save(next); // Allocate this boot/map sequence durably before exposing its session.
}
void runtime::save(const json& next)
{
const auto bytes = next.dump(); require(bytes.size() < max_file);
const auto exists = std::filesystem::exists(file_);
require(exists || !allocated_);
if (exists)
{
// A synchronous engine command may replace the map/runtime before the
// older call frame returns. It must never overwrite the new sequence.
require(std::filesystem::file_size(file_) < max_file);
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
}
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
require(stream.good()); stream.close(); require(!stream.fail());
#ifdef _WIN32
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
#else
std::filesystem::rename(temp, file_);
#endif
saved_ = next; allocated_ = true;
}
void runtime::observe(int slot, const std::string& guid, const std::string& name)
{
require(slot >= 0 && slot < 64 && guid_identifier(guid));
auto found = players.find(slot);
if (found == players.end() || found->second.guid != canonical_guid(guid))
{
disconnect(slot);
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
}
else found->second.name = player_name(name);
}
void runtime::disconnect(int slot)
{
auto found = players.find(slot);
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
}
json runtime::request()
{
json roster = json::array(), receipts = json::array(); sent_.clear();
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
{
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
sent_.push_back(it.key());
}
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
}
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
{
const auto received = std::chrono::steady_clock::now();
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
auto revision = value.at("revision").get<long long>();
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
std::set<std::string> ids;
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
ids.clear();
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
if (revision == this->revision() && revision != 0)
{
auto previous = saved_.at("sanctions");
for (auto it = previous.begin(); it != previous.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
auto incoming = value.at("sanctions");
for (auto it = incoming.begin(); it != incoming.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
require(incoming == previous);
}
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
save(next); pending_ = value.at("actions");
const auto accepted = std::chrono::steady_clock::now();
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
std::chrono::milliseconds(transfer_age_ms) + processing;
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
clock_anchor_ = accepted;
}
long long runtime::effective_time(long long local) const
{
if (!server_anchor_.time_since_epoch().count()) return local;
// Never extend deadlines when the host clock lags or jumps backward. The
// full request age is conservative because serverTime is generated during it.
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
return std::max(local, static_cast<long long>(estimate));
}
void runtime::enforce(long long now, const executor& execute)
{
now = effective_time(now);
auto pending = pending_; pending_ = json::array();
for (const auto& item : pending)
{
auto id = item.at("id").get<std::string>();
if (saved_["journal"].contains(id))
{
// A duplicate immutable action is acknowledged, never executed twice.
require(saved_["journal"][id].at("action") == item);
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
}
require(saved_["journal"].size() < 10000);
auto kind = item.at("kind").get<std::string>(); int slot = -1;
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
else if (kind != "announce")
{
for (const auto& [number, target] : players)
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
}
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
if (state == "unknown")
{
// All target validation and dispatch occur on the calling game thread, without yielding.
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
else
{
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
catch (...) { state = "unknown"; }
if (state != "confirmed" && state != "failed") state = "unknown";
}
next = saved_; next["journal"][id]["state"] = state;
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
}
}
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
const auto roster = players;
for (const auto& [slot, target] : roster)
{
now = effective_time(now);
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
bool ban = false, mute = false; std::string why;
for (const auto& policy : saved_.at("sanctions"))
{
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
if (policy.at("kind") == "mute") mute = true;
}
if (ban)
{
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
continue;
}
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
auto owned = saved_.at("ownedMutes");
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
if (mute != was_muted || (!mute && managed))
{
if (mute && !managed)
{
require(owned.size() < 10000); owned.push_back(target.guid);
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
if (!mute && managed)
{
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
}
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <filesystem>
#include <chrono>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include <json.hpp>
namespace utils::moderation
{
using json = nlohmann::json;
struct player { int slot; std::string guid, name, connection; };
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
long long timestamp(const std::string& value);
std::string command_text(const std::string& value);
std::string new_session();
std::string canonical_guid(const std::string& value);
class runtime
{
public:
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
std::string session = new_session();
std::map<int, player> players;
void observe(int slot, const std::string& guid, const std::string& name);
void disconnect(int slot);
json request();
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
void enforce(long long now, const executor& execute);
long long revision() const { return saved_.at("revision").get<long long>(); }
private:
std::filesystem::path file_;
std::string server_, endpoint_, credential_scope_;
int generation_;
unsigned long long serial_ = 0;
bool allocated_ = false;
std::chrono::system_clock::time_point server_anchor_{};
std::chrono::steady_clock::time_point clock_anchor_{};
json saved_, pending_ = json::array();
std::vector<std::string> sent_;
std::map<std::string, bool> muted_;
std::map<std::string, long long> kicked_;
void save(const json& next);
long long effective_time(long long local) const;
};
}
+119 -28
View File
@@ -1,7 +1,7 @@
# T6 port notes # T6 port notes
T6 is ported from T5. Both source trees contain common, HUD, AFK, join, lock, T6 is ported from T5. Both source trees contain common, HUD, AFK, join, lock,
telemetry and leaderboard modules; neither includes bank or database modules. telemetry, leaderboard and bank modules; neither includes direct-database modules.
## Differences retained for T6 and requested fixes ## Differences retained for T6 and requested fixes
@@ -9,15 +9,15 @@ telemetry and leaderboard modules; neither includes bank or database modules.
| --- | --- | --- | | --- | --- | --- |
| Paths | `maps/mp/_utility`, `scripts/zm/tws_t6_common` | T6 namespaces and requested filenames | | Paths | `maps/mp/_utility`, `scripts/zm/tws_t6_common` | T6 namespaces and requested filenames |
| Loading | No T5 `zombiemode` dvar gate; each module has `main()` and `init()` | Zombies-only folder loading; avoid r5346 missing-main warnings | | Loading | No T5 `zombiemode` dvar gate; each module has `main()` and `init()` | Zombies-only folder loading; avoid r5346 missing-main warnings |
| HUD positioning | Fullscreen screen anchors, same 10-unit margin | Requested fix for inset HUD | | HUD positioning | Fullscreen X/Y anchors, direct normalized `640 × 480` coordinates, unboxed core and service area | Bypass safe-area insets; validate Plutonium tag clearance and font fit at each aspect ratio |
| HUD rendering | Precached white shader, local max-health fallback, floating-point ratio, `getPlayers()` | T6 rendering/state handling without changing player max health | | HUD rendering | Precached white shader, local max-health fallback, floating-point ratio, `getPlayers()` | T6 rendering/state handling without changing player max health |
| Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters | | Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters |
| Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen | | Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen |
| AFK | Maintain control freeze, disable guns/grenades, guard player state and stale grace threads | Requested movement/shooting fix; retain `.afk` / `.back` and two-second grace | | AFK | Maintain control freeze, disable guns/grenades, guard player state and stale grace threads | Requested movement/shooting fix; retain `.afk` / `.back` and two-second grace |
| Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` sources from Plutonium's `player.guid` engine property (T5 uses `player getGuid()` method); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | T6 has no `getGuid` method but exposes the persistent identity as `.guid` on the player entity; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity | | Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` comes from the persistent `player.guid` field (T5 uses `player getGuid()` directly); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | Stock T6 connection initialization populates `.guid` before notifying `connected`; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
| Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way | | Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way |
Common retains the T5 HUD constants, helpers and first-spawn dispatcher. Common provides matching T5/T6 HUD layout constants, helpers and first-spawn dispatcher.
HUD again owns the `hud_game_over` notification fan-out. Lock again waits on HUD HUD again owns the `hud_game_over` notification fan-out. Lock again waits on HUD
notifications, applies T5's random password, and clears it on startup/game over. notifications, applies T5's random password, and clears it on startup/game over.
The added password-preservation dvars and independent lock watcher are gone. The added password-preservation dvars and independent lock watcher are gone.
@@ -26,11 +26,62 @@ Common also listens for `.help` through the native `say` notification, independe
of first-spawn initialization so newcomers can view commands while spectating. of first-spawn initialization so newcomers can view commands while spectating.
Each loaded command module registers its command and description with Each loaded command module registers its command and description with
`register_help_command()` during `init()`. Lazy registry initialization preserves `register_help_command()` during `init()`. Lazy registry initialization preserves
registrations regardless of module initialization order. Help uses client HUD registrations regardless of module initialization order. Common owns one
elements below the health bar, sharing the HUD's anchors to avoid overlap with unboxed upper-right service area. Help shows all nine installed commands in
timers, zombie count and health numbers. Text is created once per connection and explicit command/description rows; extra registered commands use pages of nine
reused; each request restarts a 12-second visibility timer. Disconnect cleanup rows. Pages last 12 seconds, with automatic dismissal after the final page and
is independent of that timer. The command handlers retain their existing listeners. a counter only for multi-page lists. `.help` / `.commands` restart at page one.
Help pauses record page time, and a `.leaders` request during help replaces
the pending replay from page one. Service state prevents old requests from
closing newer displays. Disconnect and game-end cleanup are independent of
page timing. Command handlers retain their existing listeners, and help/records
work without the HUD module.
Both engines use explicit help rows; T5 no longer relies on newline rendering,
and T6 has the same row geometry. Common allocates only the active service
view: 19 nonarchived elements for nine-command help (20 with a multi-page
counter), or four for records (five with a multi-page counter). Closing the
service or switching kinds destroys the view; idle holds no elements. Pages
reuse the active elements, separately from controller and pending-request state.
Core + AFK use 10 + 1 archived elements. Hidden elements still occupy slots.
The [T6 player HUD structure](https://github.com/skiff/t6-gsc-helper/blob/50ff6cb7397c9376894a195f48118d1743812d1d/src/game/structs.hpp#L184)
has 31 slots in each bucket; it does not establish current Plutonium stock HUD
usage. Keep the live overlap/element-budget checks alongside the portable checks.
The core is created on connection for waiting spectators with placeholders,
while timer updates retain the existing spawn/round lifecycle. Connection-time
creation must not seed the match clock. The unboxed layout has timers at the
upper left, branding at top center and zombies/health at bottom center. HP
numbers appear to the right of the bar only while injured; AFK sits above the
zombie count. Services never reposition the core. Records are right aligned,
with `Map record | Round N` followed by up to three bounded holder lines, no
footer and no single-page counter. Their 12-second page timing is unchanged.
HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
`SetTextUnlimited`. The `N ZOMBIES` counter is one element centered at the
health track's midpoint with uniform scale `1.25`, starting as `-- ZOMBIES`.
Only changed counts update its text; numeric HP retains `SetValue`. On r5334+,
update the HUD script and restart the map. Older Plutonium clients and servers
need updates and full restarts first. AFK `1.2.0` and leaderboard `1.1.0` remain
compatible; AFK still requires common `1.4.0+`.
Overlay helpers use direct normalized `640 × 480` coordinates. Dynamic health
fills use `make_overlay_width()` like their tracks. Apply `int()` after `max()`
in that helper: native `max()` returns a float, but `SetShader` requires an
integer width. Legacy panel helpers retain their `0.75` horizontal conversion
for existing consumers. Font scale is at least `1.0`. There is no new
native-plugin/backend dependency or full server-process restart requirement
when already using Plutonium r5334+.
HUD owns the exact, case-insensitive `.hud` chat command and registers it in
help only while installed. Common's core wrappers preserve each element's
intended alpha behind a per-player visibility mask; health and AFK changes use
`set_core_alpha()`. Data/timers keep updating while hidden and freeze at game
over. Help, records, stock HUD and AFK protection are unaffected. Visibility
defaults on, survives death/respawn during the connection/map, and resets on
reconnect or map restart. All nine installed commands use one help page,
ordered `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`, `.d`, `.w`.
Bank commands display the full `<amount|all>` syntax.
AFK again listens to the native `say` notification, so it no longer needs plugin AFK again listens to the native `say` notification, so it no longer needs plugin
chat registration. The additional T6 controls are only held while AFK; `.back` chat registration. The additional T6 controls are only held while AFK; `.back`
@@ -49,7 +100,7 @@ The module is `tws_t6_leaderboard.gsc` in T6 and `tws_t5_leaderboard.gsc` in T5.
Common's existing Common's existing
connection listener now also dispatches optional connection handlers after a connection listener now also dispatches optional connection handlers after a
one-frame yield. Leaderboard waits for player readiness and uses a level-owned one-frame yield. Leaderboard waits for player readiness and uses a level-owned
HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep HTTP cache, independent of the first-spawn handlers. T6 spectators keep
`hasSpawned` false, so T6 readiness uses session state and the intro-screen flag; `hasSpawned` false, so T6 readiness uses session state and the intro-screen flag;
T5 sets `hasSpawned` for both playable and spectator spawns. T5 sets `hasSpawned` for both playable and spectator spawns.
@@ -60,9 +111,10 @@ Mode/location combinations follow the
and Electric Cherry's map catalogue. Unknown selections require an explicit API and Electric Cherry's map catalogue. Unknown selections require an explicit API
key instead of falling back to another variant sharing the engine map. key instead of falling back to another variant sharing the engine map.
Validation for this change: 135 fixture/static checks pass using the actual Historical validation for the initial leaderboard change: 135 fixture/static
data-handling functions from both scripts with JavaScript engine-value shims. checks passed using the actual data-handling functions from both scripts with
All seven T6 scripts compile and assemble using a local Intel macOS driver built JavaScript engine-value shims. All seven then-current T6 scripts compiled and
assembled using a local Intel macOS driver built
from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was
not executed here. T5 compilation and live plugin/gameplay checks were not run; not executed here. T5 compilation and live plugin/gameplay checks were not run;
gsc-tool has no T5 backend. The production API could not be reached from this gsc-tool has no T5 backend. The production API could not be reached from this
@@ -118,15 +170,39 @@ at link time. The other four TWS modules can run without telemetry.
## Checks ## Checks
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared ### Newcomer eligibility
references and entry points. It requires PowerShell and gsc-tool. The `.help`
change passed the static entry-point/reference checks, but compilation and live The stock [`_zm_gametype::onspawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_zm_gametype.gsc)
gameplay were not run. Compiler success does not certify runtime linking. can schedule `spawnspectator` after 0.05 seconds while
[`_globallogic_spawn::spawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_spawn.gsc)
emits `spawned_player` at frame end. That event alone does not prove gameplay.
Join `1.0.1` waits 0.1 seconds before checking session state and keeps listening
after setup spectator spawns. `killed_player` also consumes eligibility so a
death during that interval cannot restore newcomer access. The common dispatcher
still starts each registered module once per connection.
Run `node tools/check_join.mjs` for portable lifecycle and request fixtures.
These execute the join script with engine shims, not a GSC VM. Live validation:
join Farm Survival during round 3 and remain spectating, use `.join` once, then
try repeated requests, death, and subsequent rounds. On a fresh connection,
wait for a normal round-start spawn, die, and verify `.join` is rejected.
Also check a round-1 participant, reconnecting, disconnect during setup, and
game over. Repeat on another T6 map; compile with the checker below first.
Local: `tools/check.ps1` compiles all eight files for T6 PC and checks shared
references and entry points. It requires PowerShell and gsc-tool. Run the
[shared HUD checks](../../README.md#hud-validation) for core/service behavior and
command regressions. Compiler success does not certify runtime linking.
Live checks after deploying: Live checks after deploying:
1. Join, late-join, die/respawn, and reconnect: one HUD per player, correct corner 1. Join, late-join, die/respawn, and reconnect: one unboxed core per player,
position, health/Juggernog changes, zombies remaining, timers frozen at game over. upper-left timers, top-center branding, bottom-center zombies/health and
spectator placeholders without an early clock start. Check damage-only HP,
health/Juggernog changes, AFK placement and timers frozen at game over.
Test safe-area settings and different resolutions against stock HUD elements.
Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` remain centered above the
health track, including after `.hud` toggles and game over freezes the count.
2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid 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.
@@ -134,12 +210,27 @@ Live checks after deploying:
4. Telemetry sends the initial occupied round and each new round to the existing 4. Telemetry sends the initial occupied round and each new round to the existing
endpoint. Check server ID/player count, same-round joins and an unreachable API. endpoint. Check server ID/player count, same-round joins and an unreachable API.
5. Repeat on the actual server maps, especially afterlife and special-enemy rounds. 5. Repeat on the actual server maps, especially afterlife and special-enemy rounds.
6. Type `.help` and `.HELP` before first spawn, during play and while AFK: see 6. Type `.help`, `.HELP` and `.commands` before first spawn, during play and
`.help`, `.afk`, `.back` and `.join` with descriptions, only on the requesting while AFK: one page of nine commands with all modules installed, full bank
player's screen below the health bar. Take damage so health numbers appear syntax and no page counter, only on the requesting player's screen. Verify
and verify they do not overlap help. Repeat the command before 12 seconds dismissal after 12 seconds and repeat-to-restart behavior. Add custom
elapse: one list should remain visible until 12 seconds after the latest registered commands to exercise the nine-row page limit and page counter.
request. Reopen after expiry and reconnect; verify no duplicates or leftovers. Remove optional modules and restart the map to confirm their commands are
Remove the AFK or Join module, restart the map and confirm its commands are absent. Restore them and confirm command behavior is unchanged.
absent. Restore the modules and confirm `.afk`, `.back` and `.join` still 7. Interrupt a record with help and check that its remaining page time resumes.
behave as before. Repeat `.leaders` during help; only the latest replay should start from page
one after help closes. Check long names across pages, no records and
unavailable records. Reopen after expiry, disconnect/reconnect and end the
game during a service; verify no duplicates or leftovers. Repeat without HUD.
8. Toggle `.hud` before spawning, while playing/AFK and after game over. Verify
the core and AFK indicator hide privately while help/records remain visible.
Show it after taking damage or changing AFK state while hidden: current
values and intended alpha should return. Confirm updates continue hidden,
death/respawn keeps the choice, and reconnect/map restart restores visibility.
The stock T6 [HUD initialization](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud.gsc)
sets a nominal font height of 12; the corresponding
[font helper](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud_util.gsc)
uses `int(level.fontheight * fontscale)` for its height. The portable HUD checks
use that nominal box to prevent holder-row, help-row and AFK overlaps. Exact
glyph extents still need live validation, especially on T5.
+149 -25
View File
@@ -2,24 +2,29 @@
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts, Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
just like T5. The telemetry token is embedded in the reporter script; no just like T5. The telemetry token is embedded in the reporter script; no
separate token config or database setup is required. separate telemetry token config is required. The optional bank and moderation modules each need their own
private server token.
| File | Role | Dependencies | | File | Role | Dependencies |
| --- | --- | --- | | --- | --- | --- |
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, 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` | Match/round timers, name, zombie count and health bar | Common | | `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 | | `tws_t6_afk.gsc` | Persistent AFK reserve, rewards and central countdown | Common 1.4.0+, telemetry 1.0.0+, plugin 1.4.0+, EC #151 |
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD | | `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils | | `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils | | `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
## Install ## Install
1. Copy all seven `tws_t6_*.gsc` files into 1. Copy the desired modules from the nine `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard. `Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1, and AFK when those modules are installed.
in `Plutonium/plugins/` and restart the server process. The other five scripts 2. For telemetry, leaderboard, bank or moderation, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. Moderation requires
plugin 1.3.0+ before its script is installed; see [moderation setup](../../docs/moderation.md). The other five scripts
do not require that plugin. do not require that plugin.
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in 3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6). the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6).
@@ -29,10 +34,10 @@ If upgrading from the earlier leaderboard filename, **remove
`tws_leaderboard.gsc` from the T6 server script directory** before installing `tws_leaderboard.gsc` from the T6 server script directory** before installing
`tws_t6_leaderboard.gsc`, so both copies do not load. `tws_t6_leaderboard.gsc`, so both copies do not load.
If updating an existing deployment, **remove `tws_t6_db.gsc` and If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files replace the old `tws_t6_bank.gsc` with this release's file. Remove any
will not remove those old scripts. Remove any `exec server.example.cfg` added for `exec server.example.cfg` added for that legacy version; configure the new
the earlier version. Neither a database nor that config file is used anymore. Electric Cherry bank using the private per-server token described below.
Source `.gsc` files load directly under Plutonium's Source `.gsc` files load directly under Plutonium's
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/). [documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
@@ -40,12 +45,98 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
To omit telemetry, remove its script; disabling it at runtime does not remove To omit telemetry, remove its script; disabling it at runtime does not remove
its plugin dependency during linking. The same rule applies to leaderboard. its plugin dependency during linking. The same rule applies to leaderboard.
## Player bank
Bank `2.0.2` requires plugin `1.2.0+` for startup version reporting and bounded
parser diagnostics. Install the DLL and restart the full server process before
loading these scripts. Follow the [diagnostic rollout and live checks](../../README.md#bank-diagnostics--tws-14).
The optional `tws_t6_bank.gsc` adds `.b`, `.d <amount|all>` and
`.w <amount|all>` and registers them in help. It requires common and telemetry
`1.0.0` or newer, the TWS t6 plugin `1.2.0`, Electric Cherry's bank API and
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
including a full process restart for the plugin. Bank `2.0.0+` uses only
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
an EC rebuild must settle legacy journals before the new scripts start.
When the endpoint already matches, only a map restart is required.
Bank `2.0.1` reads the persistent `player.guid` field used by telemetry and
converts it to a string before requests or journal writes. Existing strings
are preserved exactly; missing or invalid identities fail privately without
changing points. Stock T6
[`callback_playerconnect()`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_player.gsc)
populates that field before the `connected` notification. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Core HUD and service window
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. `N ZOMBIES` is one centered element at
the health track's midpoint, using uniform font scale `1.25` so the whole group
stays centered as the digit count changes. Its initial placeholder is
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
The core is created once per connection, including for spectator newcomers
with placeholders until values are available. Creation does not seed the match
clock. It persists across death and respawn. Clocks, health and zombie-count
displays freeze at game over. Health fallbacks only affect the display and
never modify gameplay max health.
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
It defaults visible, keeps your choice across death/respawn on the current
connection/map, and resets visible on reconnect or map restart. Timers and data
continue updating while hidden; game-over values remain frozen. Help, records
and the stock HUD are unaffected. `.hud` requires the HUD module.
Common provides one unboxed upper-right service area below the Plutonium tag.
Records are right aligned, with `Map record | Round N` followed by up to three
holder lines; there is no replay footer or single-page counter. Help displays
the installed command list in pages of nine rows in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
HUD `1.4.2` requires common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
updated HUD script and restart the map on r5334+; no full server-process
restart is required. Older Plutonium revisions need client/server updates and
full restarts first. 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
`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials. `tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials.
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,
@@ -63,30 +154,44 @@ 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 appears below the health bar for 12 seconds, clear of the timers, zombie Help uses the unboxed upper-right service area. All nine installed commands
counter and health display. Typing either command again restarts that display time. fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
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
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 |
| `.afk` | Pause with protection | | `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
| `.afk` | Enter protected AFK using your time reserve |
| `.back` | Return from AFK | | `.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) |
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
AFK and Join 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`. Register new commands alongside omitted, its commands are omitted from `.help`. Commands follow the order above,
their module's initialization so the list stays current. independent of initialization order. Register new commands alongside their
module's initialization so the list stays current.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
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 temporarily hides the record until its own display ends. help pauses the record's remaining page time and resumes it after the final
help page. `.leaders` during help replaces the pending record replay from page one.
## Defaults, matching T5 ## Defaults, matching T5
- HUD values live in `tws_t6_common::common_config()`. That function and - HUD values live in `tws_t6_common::common_config()`. That function and
`ensure_config()` came from T5; they initialize in-script values and do not `ensure_config()` came from T5; they initialize in-script values and do not
load a config file. The requested fullscreen corner alignment is retained. load a config file. Core and service overlays use fullscreen X/Y anchors in
normalized `640 × 480` coordinates to bypass safe-area insets.
- 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.
@@ -132,9 +237,9 @@ It sends four event types over the life of a match, mirroring the T5 script:
Each event payload includes `event`, `server`, `map`, `match`, `seq`, `round` (except `game_start`) Each event payload includes `event`, `server`, `map`, `match`, `seq`, `round` (except `game_start`)
and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives` and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives`
and `score` plus `score_total` per player in stat reports. `game_start` includes and `score` plus `score_total` per player in stat reports. `game_start` includes
only player identity. `guid` reads Plutonium's `player.guid` engine property only player identity. `guid` reads the persistent `player.guid` field populated
(T5 uses the `player getGuid()` method — T6 has no such method, but the field is by T6's stock connection initialization (T5 reads `player getGuid()` directly).
equivalent for identity purposes). `kills`/`headshots`/`downs`/`revives` read `kills`/`headshots`/`downs`/`revives` read
from T6's stock match counters in `player.pers[]`; missing or negative values from T6's stock match counters in `player.pers[]`; missing or negative values
are reported as `0`. `score` is the player's current spendable `player.score`. are reported as `0`. `score` is the player's current spendable `player.score`.
`score_total` reads `player.score_total`, the stock cumulative match score used `score_total` reads `player.score_total`, the stock cumulative match score used
@@ -175,7 +280,10 @@ another mod installed one first.
## Validation ## Validation
To compile all seven scripts for T6 PC with gsc-tool: Run the shared [portable and live HUD checks](../../README.md#hud-validation)
for core placement, service pagination, record interruptions and command regressions.
To compile all nine scripts for T6 PC with gsc-tool:
```powershell ```powershell
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe .\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
@@ -183,3 +291,19 @@ To compile all seven scripts for T6 PC with gsc-tool:
Compilation does not verify engine/plugin linking or gameplay. T6 differences, Compilation does not verify engine/plugin linking or gameplay. T6 differences,
research sources and live checks are in [NOTES.md](NOTES.md). research sources and live checks are in [NOTES.md](NOTES.md).
## Moderation
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
configuration, persistence, action results and deployment checks.
## Persistent AFK reserve
AFK `2.1.0` uses EC authorization and a local central countdown. See the
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
and live checks. Install plugin `1.4.0+` and fully restart the server process.
The countdown remains visible with `.hud` hidden; expiration has no grace period.
Solo and last-active-player entry are allowed, including all players being AFK
together; teammate loss does not force a match end. Upgrading from `2.0.0` adds
no dependencies and requires only copying the script and restarting the map.
+3 -3
View File
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path $compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot $repo = Split-Path -Parent $PSScriptRoot
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name) $files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc') $expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc', 'tws_t6_moderation.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' } if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the nine TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank, moderation.' }
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw $common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value }) $functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
foreach ($file in $files) { foreach ($file in $files) {
@@ -32,4 +32,4 @@ foreach ($file in $files) {
} }
Write-Output "PASS $($file.Name)" Write-Output "PASS $($file.Name)"
} }
Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.' Write-Output 'All nine T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
+880 -65
View File
File diff suppressed because it is too large Load Diff
+528
View File
@@ -0,0 +1,528 @@
/* tws_t6_bank.gsc - shared GUID bank, with durable transfer decisions. */
#include maps\mp\_utility;
#include common_scripts\utility;
main()
{
}
init()
{
if (GetDvar("tws_bank_enabled") == "0")
return;
// Game containers use the public API, independent of the host controller URL.
level.bank_url = "https://ec.thewrightserver.net/api/bank";
level.bank_server = GetDvar("tws_server_id");
level.bank_token = GetDvar("tws_bank_token");
level.bank_logs = [];
level.bank_diagnostic_serial = 0;
credentials = "valid";
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
credentials = "invalid";
println("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
" credentials=" + credentials);
// A private per-server cfg supplies this; never embed bank credentials.
if (level.bank_server == "" || level.bank_token.size < 40 ||
getSubStr(level.bank_token, 0, 8) != "ec_bank_")
{
println("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token; pending=not_loaded");
return;
}
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
if (!isDefined(level.bank_queue))
{
println("[tws_bank] startup journal_load=failure pending=unknown bank=disabled");
return;
}
println("[tws_bank] startup journal_load=success pending=" + level.bank_queue.size);
for (i = 0; i < level.bank_queue.size; i++)
{
level.bank_queue[i]["since"] = getTime();
level.bank_queue[i]["attempts"] = 0;
}
level.bank_read = 0;
level.bank_write = level.bank_queue.size;
level.bank_busy = false;
level.bank_ended = false;
scripts\zm\tws_t6_common::register_help_command(".b", "Show bank balance");
scripts\zm\tws_t6_common::register_help_command(".d <amount|all>", "Deposit points");
scripts\zm\tws_t6_common::register_help_command(".w <amount|all>", "Withdraw points");
level thread bank_end_watcher();
level thread bank_sender();
level thread bank_chat_listener();
}
bank_end_watcher()
{
level waittill_any("end_game", "game_ended", "intermission");
level.bank_ended = true;
}
bank_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
args = strTok(toLower(message), " ");
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
continue;
if (level.bank_busy || level.bank_read < level.bank_write)
{
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
continue;
}
level.bank_busy = true;
level thread bank_request(player, args);
}
}
bank_amount(text)
{
if (text == "all")
return -1;
if (text.size == 0 || text.size > 7)
return 0;
digits = "0123456789";
for (i = 0; i < text.size; i++)
{
found = false;
for (j = 0; j < digits.size; j++)
if (text[i] == digits[j])
found = true;
if (!found)
return 0;
}
amount = int(text);
if (amount < 1 || amount > 1000000)
return 0;
return amount;
}
bank_playing(player)
{
return !level.bank_ended && !is_true(level.intermission) &&
isDefined(player) && isDefined(player.sessionstate) &&
player.sessionstate == "playing" && isAlive(player) &&
!(player maps\mp\zombies\_zm_laststand::player_is_in_laststand()) &&
isDefined(player.score) && player.score >= 0;
}
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
bank_guard(entry)
{
self endon("tws_bank_finished");
self waittill_any("disconnect", "killed_player", "spawned_player");
entry.active = false;
}
bank_say(entry, text)
{
if (entry.active && isDefined(entry.player))
entry.player iPrintLnBold(text);
}
bank_request(player, args)
{
entry = spawnStruct();
entry.player = player;
entry.active = true;
player thread bank_guard(entry);
bank_transfer(entry, args);
if (entry.active && isDefined(player))
player notify("tws_bank_finished");
level.bank_busy = false;
}
bank_guid(player)
{
if (!isDefined(player))
return undefined;
// Use the persistent field populated at connection, as telemetry does.
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return undefined;
// Preserve string identities without numeric casts, including large GUIDs.
guid = "" + guid;
if (guid.size == 0 || guid.size > 64)
return undefined;
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < guid.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (guid[i] == allowed[j])
found = true;
if (!found)
return undefined;
}
return guid;
}
bank_transfer(entry, args)
{
player = entry.player;
guid = bank_guid(player);
if (!isDefined(guid))
{
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
return;
}
data = [];
data["server"] = level.bank_server;
data["guid"] = guid;
if (args[0] == ".b")
{
if (args.size != 1)
{
bank_say(entry, "^3Usage: .b");
return;
}
reply = bank_http("balance", jsonSerialize(data), "");
if (!isDefined(reply) || reply["state"] != "balance")
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
else
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
return;
}
if (args.size != 2)
{
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
return;
}
amount = bank_amount(args[1]);
if (amount == 0)
{
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
return;
}
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
{
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
return;
}
operation = "withdraw";
if (args[0] == ".d")
{
operation = "deposit";
if (amount == -1)
amount = int(player.score);
if (amount <= 0 || amount > 1000000 || player.score < amount)
{
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
return;
}
}
capacity = 1000000 - int(player.score);
if (capacity < 0)
capacity = 0;
if (operation == "withdraw" && amount != -1 && amount > capacity)
{
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
return;
}
id = telemetryNewMatch();
if (!isDefined(id))
{
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
return;
}
data["request_id"] = id;
data["match"] = level.telemetry_match;
data["operation"] = operation;
data["amount"] = amount;
if (amount == -1)
data["amount"] = "all";
data["capacity"] = capacity;
data["decision"] = "cancel";
body = jsonSerialize(data);
pending_since = getTime();
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
if (!bank_store(body, id, "cancel"))
{
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
return;
}
reply = bank_http(operation, body, id);
if (isDefined(reply) && reply["state"] == "prepared")
{
transfer = reply["amount"];
if (entry.active && bank_playing(player) &&
(amount == -1 || amount == transfer) &&
((operation == "deposit" && player.score >= transfer) ||
(operation == "withdraw" && player.score <= 1000000 - transfer)))
{
data["decision"] = "commit";
commit_body = jsonSerialize(data);
// No yield between recording the decision and updating the balance.
// Do not change score_total: bank funds are not earned match points.
if (bank_store(commit_body, id, "commit"))
{
if (operation == "deposit")
player.score -= transfer;
else
player.score += transfer;
player.pers["score"] = player.score;
body = commit_body;
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
}
else
{
data["decision"] = "cancel";
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
}
}
else
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
}
else if (isDefined(reply) && reply["state"] == "rejected")
bank_say(entry, bank_error(reply["code"]));
else
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
pending = [];
pending["id"] = id;
pending["body"] = body;
pending["decision"] = data["decision"];
pending["since"] = pending_since;
pending["attempts"] = 0;
level.bank_queue[level.bank_write] = pending;
level.bank_write++;
}
bank_error(code)
{
if (code == "insufficient_balance" || code == "empty")
return "^3Not enough bank balance. Your points are unchanged.";
if (code == "bank_limit")
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
if (code == "points_limit")
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
if (code == "not_in_match")
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
if (code == "pending")
return "^3A bank transfer is pending on another server. Try again shortly.";
return "^3Bank rejected the transfer. Your points are unchanged.";
}
// Only locally generated identities/configured server names pass through here.
// Never use this to print response fields: those use fixed vocabularies below.
bank_log_token(value)
{
if (!isDefined(value) || typeof(value) != "string" || value.size == 0 || value.size > 128)
return "unavailable";
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < value.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (value[i] == allowed[j])
found = true;
if (!found)
return "invalid";
}
return value;
}
bank_response_code(code)
{
// Unknown values may contain secrets or console controls, even if short.
codes = strTok("ok insufficient_balance empty bank_limit points_limit not_in_match pending invalid_request unauthorized forbidden not_found conflict disabled", " ");
for (i = 0; i < codes.size; i++)
if (isDefined(code) && code == codes[i])
return codes[i];
return "unknown";
}
bank_response_reason(reason)
{
reasons = strTok("empty_body body_limit invalid_json schema field_limit state amount_range ok_state missing_id zero_transfer", " ");
for (i = 0; i < reasons.size; i++)
if (isDefined(reason) && reason == reasons[i])
return reasons[i];
return "unknown";
}
bank_diagnostic_number(diagnostics, key)
{
if (!isDefined(diagnostics) || typeof(diagnostics) != "array" || !isDefined(diagnostics[key]))
return "unavailable";
value = diagnostics[key];
if ((typeof(value) != "int" && typeof(value) != "float") || value < 0 || value > 2147483647)
return "unavailable";
return "" + int(value);
}
bank_http_details(diagnostics)
{
detail = "";
keys = strTok("http_status curl_code dns_ms connect_ms tls_ms total_ms", " ");
for (i = 0; i < keys.size; i++)
detail += " " + keys[i] + "=" + bank_diagnostic_number(diagnostics, keys[i]);
return detail;
}
// Fixed keys bound memory. Correlation IDs/timings never form the signature.
// Report first failure, reason/status/code changes, 30s reminders and recovery.
bank_log(key, signature, detail, failed)
{
now = getTime();
previous = level.bank_logs[key];
suppressed = 0;
recovery = "";
if (isDefined(previous))
{
suppressed = previous["suppressed"];
if (failed && previous["signature"] == signature && now >= previous["time"] && now - previous["time"] < 30000)
{
previous["suppressed"]++;
level.bank_logs[key] = previous;
return;
}
if (previous["failed"] && !failed)
recovery = " recovery=true";
}
println("[tws_bank] " + detail + recovery + " suppressed=" + suppressed);
current = [];
current["signature"] = signature;
current["time"] = now;
current["failed"] = failed;
current["suppressed"] = 0;
level.bank_logs[key] = current;
}
bank_store(body, id, decision)
{
saved = bankStore(level.bank_server, level.bank_url, body);
outcome = "success";
if (!saved)
outcome = "storage_failure";
bank_log("write_" + decision, outcome, "journal_write decision=" + decision +
" request_id=" + bank_log_token(id) + " outcome=" + outcome, !saved);
return saved;
}
bank_http_outcome(action, id, correlation, reason, code, diagnostics, failed)
{
level.bank_http_signature = reason + ":" + code + ":" +
bank_diagnostic_number(diagnostics, "http_status") + ":" + bank_diagnostic_number(diagnostics, "curl_code");
level.bank_http_detail = "correlation=" + correlation + " action=" + action +
" request_id=" + bank_log_token(id) + " outcome=" + reason + " code=" + code + bank_http_details(diagnostics);
// Settlement adds progress and cleanup outcome before logging this attempt.
if (action != "commit" && action != "cancel")
bank_log(action, level.bank_http_signature, level.bank_http_detail, failed);
}
bank_http(action, body, id)
{
level.bank_diagnostic_serial++;
correlation = "bank-" + getTime() + "-" + level.bank_diagnostic_serial;
headers = [];
headers["Content-Type"] = "application/json";
headers["Authorization"] = "Bearer " + level.bank_token;
options = [];
options["method"] = "POST";
options["headers"] = headers;
options["body"] = body;
options["connect_timeout_ms"] = 5000;
options["timeout_ms"] = 15000;
request = http::request(level.bank_url + "/" + action, options);
if (!isDefined(request))
{
bank_http_outcome(action, id, correlation, "request_creation", "none", undefined, true);
return undefined;
}
request waittill("done", response, success, error, diagnostics);
reason = "ok";
if (!isDefined(success) || !success)
reason = "transport_failure";
else if (bank_diagnostic_number(diagnostics, "http_status") == "unavailable")
reason = "missing_diagnostics";
else if (diagnostics["http_status"] != 200)
reason = "http_rejection";
else if (!isDefined(response))
reason = "missing_response";
if (reason != "ok")
{
bank_http_outcome(action, id, correlation, reason, "none", diagnostics, true);
return undefined;
}
reply = bankResponse(response);
if (!isDefined(reply) || !isDefined(reply["valid"]) || !reply["valid"])
{
reason = "unknown";
if (isDefined(reply))
reason = bank_response_reason(reply["reason"]);
bank_http_outcome(action, id, correlation, "invalid_ack_" + reason, "none", diagnostics, true);
return undefined;
}
if (reply["request_id"] != id)
{
bank_http_outcome(action, id, correlation, "request_id_mismatch", "none", diagnostics, true);
return undefined;
}
if (reply["state"] == "rejected")
reason = "application_rejection";
else if ((action == "balance" && reply["state"] != "balance") ||
((action == "deposit" || action == "withdraw") && reply["state"] != "prepared") ||
((action == "commit" || action == "cancel") && !bank_resolved(action, reply["state"])))
reason = "unexpected_state";
bank_http_outcome(action, id, correlation, reason, bank_response_code(reply["code"]), diagnostics, reason != "ok");
return reply;
}
bank_resolved(decision, state)
{
if (decision == "commit")
return state == "committed";
return state == "cancelled" || state == "rejected";
}
bank_sender()
{
delay = 1;
for (;;)
{
if (level.bank_busy || level.bank_read >= level.bank_write)
{
wait 0.1;
continue;
}
entry = level.bank_queue[level.bank_read];
entry["attempts"]++;
level.bank_queue[level.bank_read] = entry;
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
duration = int((getTime() - entry["since"]) / 1000);
progress = " decision=" + entry["decision"] + " attempt=" + entry["attempts"] +
" pending_seconds=" + duration;
outcome = "pending";
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
{
// Cleanup failures never reapply points; settlement is idempotent.
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
{
bank_log("settlement", "resolved", level.bank_http_detail + progress +
" journal_remove=success settlement=resolved next_retry_seconds=0", false);
level.bank_queue[level.bank_read] = undefined;
level.bank_read++;
if (level.bank_read == level.bank_write)
{
level.bank_queue = [];
level.bank_read = 0;
level.bank_write = 0;
}
delay = 1;
continue;
}
outcome = "journal_remove_failure";
}
bank_log("settlement", level.bank_http_signature + ":" + outcome, level.bank_http_detail + progress +
" settlement=" + outcome + " next_retry_seconds=" + delay, true);
wait delay;
delay *= 2;
if (delay > 30)
delay = 30;
}
}
+597 -59
View File
@@ -13,6 +13,9 @@ init()
common_config(); common_config();
register_help_command(".help", "List available commands"); register_help_command(".help", "List available commands");
level thread service_game_over_via("end_game");
level thread service_game_over_via("game_ended");
level thread service_game_over_via("intermission");
level thread help_chat_listener(); level thread help_chat_listener();
level thread on_player_connect_dispatch(); level thread on_player_connect_dispatch();
} }
@@ -22,41 +25,84 @@ common_config()
if (isDefined(level.hud_x)) if (isDefined(level.hud_x))
return; return;
// Small inset from the physical screen corner (fullscreen HUD coordinates). // Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10; level.hud_x = 10;
level.hud_y = 10; level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
// The panel renderers below use dedicated row/column offsets instead.
level.hud_row_match = level.hud_y; level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15; level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32; level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54; level.hud_row_count = level.hud_y + 54;
level.hud_row_bar = level.hud_y + 84;
level.hud_font_small = 1.4;
level.hud_font_large = 2.0;
level.hud_font_mid = 1.6;
level.hud_bar_width = 120;
level.hud_bar_height = 8;
level.hud_col_count = level.hud_x + 90; level.hud_col_count = level.hud_x + 90;
level.hud_col_name = level.hud_x + 60; level.hud_col_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + level.hud_bar_width + 12; level.hud_col_health = level.hud_x + 120 + 12;
level.hud_health_threshold = 1.0;
level.hud_panel_right = 10;
level.hud_panel_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
level.hud_service_height = 124;
level.hud_pad = 10;
level.hud_row_header = 10;
level.hud_row_timers = 34;
level.hud_row_stats = 59;
level.hud_row_bar = 80;
level.hud_row_afk = 86;
level.hud_font_small = 1.1;
level.hud_font_large = 1.6;
level.hud_font_mid = 1.25;
level.hud_font_afk = 1.0;
level.hud_bar_width = 200;
level.hud_bar_height = 4;
level.hud_rate = 0.1; level.hud_rate = 0.1;
level.hud_c_panel = (0.07, 0.10, 0.08);
// green/amber/red ramp is health-only level.hud_panel_alpha = 0.72;
level.hud_c_label = (0.85, 0.85, 0.85); level.hud_c_label = (0.72, 0.77, 0.74);
level.hud_c_dim = (0.55, 0.55, 0.55); level.hud_c_dim = (0.65, 0.70, 0.67);
level.hud_c_count = (1, 1, 1); level.hud_c_count = (0.93, 0.96, 0.94);
level.hud_c_name = (0.4, 0.9, 0.5); level.hud_c_name = (0.65, 0.85, 0.70);
level.hud_c_high = (0.2, 1, 0.4); level.hud_c_high = (0.65, 0.85, 0.70);
level.hud_c_mid = (1, 0.8, 0); level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2); level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2); level.hud_c_afk = (1, 0.8, 0.2);
// numbers fade in below this fraction of max hp // Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
level.hud_health_threshold = 1.0; level.hud_overlay_timer_x = 6;
level.hud_overlay_match_y = 8;
level.hud_overlay_round_y = 30;
level.hud_overlay_brand_x = 320;
level.hud_overlay_brand_y = 8;
level.hud_overlay_count_x = 300;
level.hud_overlay_count_y = 440;
level.hud_overlay_count_label_x = 306;
level.hud_overlay_count_label_y = 446;
level.hud_overlay_bar_x = 242;
level.hud_overlay_bar_y = 466;
level.hud_overlay_bar_width = 156;
level.hud_overlay_bar_height = 4;
level.hud_overlay_health_y = 460;
level.hud_overlay_health_x = 414;
level.hud_overlay_slash_x = 418;
level.hud_overlay_maxhealth_x = 445;
level.hud_overlay_hp_x = 449;
level.hud_overlay_afk_x = 320;
level.hud_overlay_afk_y = 421;
level.hud_service_left = 432;
level.hud_service_right = 633;
level.hud_service_description_x = 538;
level.hud_service_title_y = 54;
level.hud_service_help_y = 80;
level.hud_service_help_pitch = 24;
level.hud_service_help_rows = 9;
level.hud_service_record_y = 36;
level.hud_service_holders_y = 60;
level.hud_service_holders_pitch = 18;
if (!isDefined(level.tws_spawn_handlers)) if (!isDefined(level.tws_spawn_handlers))
level.tws_spawn_handlers = []; level.tws_spawn_handlers = [];
@@ -112,58 +158,408 @@ help_chat_listener()
} }
} }
// A single per-player controller owns service priority, pages and expiry.
show_help() show_help()
{ {
// Cache the text per connection; repeated SetText calls consume config strings. if (isDefined(level.tws_service_ended))
if (!isDefined(self.tws_help_elems)) return;
{ self service_ensure();
elems = []; state = self.tws_service;
// Use the same anchors as the HUD, with room below the health numbers. service_advance(state, getTime());
y = level.hud_row_bar + level.hud_bar_height + 20; // Round-start notices are timely hints, not requests to resume after help.
title = make_text_elem(self, level.hud_x, y, if (isDefined(state.record) && state.record.kind == "reminder")
"left", level.hud_font_mid, level.hud_c_afk); state.record = undefined;
title SetText("Commands"); state.generation++;
elems[elems.size] = title; state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
}
// An undefined snapshot is the explicit unavailable view for manual requests.
show_record(snapshot)
{
if (isDefined(level.tws_service_ended))
return;
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
count = 0;
if (isDefined(snapshot))
count = snapshot.lines.size;
state.generation++;
state.record = service_request("record", service_page_count(count, 3), state.generation);
state.record.snapshot = snapshot;
service_render(state);
}
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows)
{
return int((max(count, 1) + rows - 1) / rows);
}
service_request(kind, pages, generation)
{
request = spawnStruct();
request.kind = kind;
request.generation = generation;
request.page = 0;
request.pages = pages;
request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request;
}
service_current(state)
{
if (isDefined(state.help))
return state.help;
return state.record;
}
service_advance(state, now)
{
elapsed = max(0, now - state.updated_at);
state.updated_at = now;
request = service_current(state);
if (!isDefined(request) || state.closed)
return;
// Only the visible request consumes time. Help leaves the record untouched.
request.remaining -= elapsed;
if (request.remaining > 0)
return;
request.page++;
request.remaining = 12000;
if (request.page >= request.pages)
{
if (request.kind == "help")
state.help = undefined;
else
state.record = undefined;
}
}
// Stable order is independent of module initialization order; absent commands
// stay absent. Unknown future commands follow the built-in ones in registry order.
help_rank(command)
{
if (command == ".help") return 0;
if (command == ".hud") return 1;
if (command == ".afk") return 2;
if (command == ".back") return 3;
if (command == ".join") return 4;
if (command == ".leaders") return 5;
if (command == ".b") return 6;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
return 9;
}
help_entries()
{
entries = [];
if (!isDefined(level.tws_help_commands))
return entries;
for (rank = 0; rank <= 9; rank++)
{
for (i = 0; i < level.tws_help_commands.size; i++) for (i = 0; i < level.tws_help_commands.size; i++)
{ {
entry = level.tws_help_commands[i]; entry = level.tws_help_commands[i];
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18, if (help_rank(entry.command) == rank)
"left", level.hud_font_small, level.hud_c_count); entries[entries.size] = entry;
elem SetText("^3" + entry.command + "^7 - " + entry.description);
elems[elems.size] = elem;
} }
self.tws_help_elems = elems;
self thread destroy_on_disconnect(elems);
} }
return entries;
for (i = 0; i < self.tws_help_elems.size; i++)
{
elem = self.tws_help_elems[i];
elem.alpha = 1;
}
// Only the expiry timer is replaced; the say listener and cleanup stay alive.
self notify("tws_help_refresh");
self thread hide_help_after_delay();
} }
hide_help_after_delay() help_command_label(command)
{
return command;
}
help_description(entry)
{
rank = help_rank(entry.command);
if (rank == 0) return "Show commands";
if (rank == 1) return "Toggle core HUD";
if (rank == 2) return "Protect & freeze";
if (rank == 3) return "Return to play";
if (rank == 4) return "Join as newcomer";
if (rank == 5) return "View records";
if (rank == 6) return "Bank balance";
if (rank == 7) return "Deposit";
if (rank == 8) return "Withdraw";
return entry.description;
}
service_ensure()
{
if (isDefined(self.tws_service))
return;
ensure_config();
state = spawnStruct();
state.elems = [];
state.commands = help_entries();
state.help = undefined;
state.record = undefined;
state.closed = false;
state.updated_at = getTime();
state.rendered = false;
state.generation = 0;
state.last_generation = 0;
state.last_page = -1;
state.record_text = [];
state.help_page = -1;
state.rows = [];
state.command_rows = [];
state.description_rows = [];
state.view_kind = undefined;
self.tws_service = state;
self thread service_loop(state);
self thread service_disconnect(state);
self thread service_end_game(state);
}
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
service_view_create(state, request)
{
service_view_destroy(state);
state.view_kind = request.kind;
state.view_paged = request.pages > 1;
if (state.view_paged)
state.page_counter = service_text(self, state, level.hud_service_right,
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
if (request.kind == "help")
{
state.help_title = service_text(self, state, level.hud_service_left,
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
rows = state.commands.size;
if (rows > level.hud_service_help_rows)
rows = level.hud_service_help_rows;
// Explicit lines also work on T5, which can collapse multiline strings.
for (i = 0; i < rows; i++)
{
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
state.command_rows[i] = service_text(self, state, level.hud_service_left,
y, "left", 1.0, "", level.hud_c_count);
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
y, "left", 1.0, "", level.hud_c_label);
}
return;
}
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
if (isDefined(state.page_counter))
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
}
service_view_destroy(state)
{
for (i = 0; i < state.elems.size; i++)
{
if (isDefined(state.elems[i]))
state.elems[i] Destroy();
}
state.elems = [];
state.view_kind = undefined;
state.view_paged = undefined;
state.page_counter = undefined;
state.help_title = undefined;
state.command_rows = [];
state.description_rows = [];
state.record_title = undefined;
state.rows = [];
state.page_text = undefined;
state.record_header_text = undefined;
state.record_text = [];
state.help_page = -1;
}
service_text(player, state, x, y, align, scale, text, colour)
{
elem = make_overlay_text(player, x, y, align, scale, colour);
elem.alpha = 0;
elem.archived = false;
if (text != "")
elem SetText(text);
state.elems[state.elems.size] = elem;
return elem;
}
service_render(state)
{
if (state.closed)
return;
request = service_current(state);
page = -1;
generation = 0;
if (isDefined(request))
{
page = request.page;
generation = request.generation;
}
if (state.rendered && state.last_generation == generation && state.last_page == page)
return;
state.rendered = true;
state.last_generation = generation;
state.last_page = page;
if (!isDefined(request))
{
service_view_destroy(state);
return;
}
paged = request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
service_view_create(state, request);
for (i = 0; i < state.elems.size; i++)
state.elems[i].alpha = 0;
if (paged)
{
page_text = "" + (page + 1) + " / " + request.pages;
if (!isDefined(state.page_text) || state.page_text != page_text)
{
state.page_counter SetText(page_text);
state.page_text = page_text;
}
state.page_counter.alpha = 1;
}
if (request.kind == "help")
{
state.help_title.alpha = 1;
for (i = 0; i < state.command_rows.size; i++)
{
index = page * level.hud_service_help_rows + i;
if (index >= state.commands.size)
continue;
if (state.help_page != page)
{
entry = state.commands[index];
state.command_rows[i] SetText(help_command_label(entry.command));
state.description_rows[i] SetText(help_description(entry));
}
state.command_rows[i].alpha = 1;
state.description_rows[i].alpha = 1;
}
state.help_page = page;
return;
}
header = "Map record unavailable";
snapshot = request.snapshot;
if (isDefined(snapshot))
{
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record | Round " + snapshot.round;
}
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
state.record_title SetText(header);
state.record_header_text = header;
}
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++)
{
index = page * 3 + i;
if (index < snapshot.lines.size)
{
text = snapshot.lines[index];
if (!isDefined(state.record_text[i]) || state.record_text[i] != text)
{
state.rows[i] SetText(text);
state.record_text[i] = text;
}
state.rows[i].alpha = 1;
}
}
}
service_loop(state)
{ {
self endon("disconnect"); self endon("disconnect");
self endon("tws_help_refresh"); level endon("tws_service_game_over");
for (;;)
wait 12;
for (i = 0; i < self.tws_help_elems.size; i++)
{ {
elem = self.tws_help_elems[i]; service_advance(state, getTime());
elem.alpha = 0; service_render(state);
wait 0.1;
} }
} }
// Shared dispatcher: one HUD instance and account load per connection. service_disconnect(state)
{
self waittill("disconnect");
service_destroy(state);
}
service_end_game(state)
{
self endon("disconnect");
level waittill("tws_service_game_over");
service_destroy(state);
}
service_destroy(state)
{
if (state.closed)
return;
state.closed = true;
state.help = undefined;
state.record = undefined;
service_view_destroy(state);
}
service_game_over_via(event)
{
level endon("tws_service_game_over");
level waittill(event);
level.tws_service_ended = true;
level notify("tws_service_game_over");
}
// The only connected listener; modules use the shared registries.
on_player_connect_dispatch() on_player_connect_dispatch()
{ {
for (;;) for (;;)
@@ -213,7 +609,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x; elem.x = x;
elem.y = y; elem.y = y;
elem.foreground = 1; elem.foreground = 1;
elem.fontScale = scale; // Sub-1.0 text can render oversized on clients; keep a safe minimum.
elem.fontScale = max(1.0, scale);
elem.alpha = 1; elem.alpha = 1;
elem.color = colour; elem.color = colour;
@@ -240,6 +637,147 @@ make_shader_elem(player, x, y)
return elem; return elem;
} }
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
make_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x, y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, x, y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
return elem;
}
make_overlay_width(width)
{
// Native max() returns a float; shader dimensions must be integers.
return int(max(1, width));
}
make_core_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_overlay_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
// engine safe area. Horizontal conversion preserves the original size at 16:9;
// other aspects retain the right-edge anchor with proportional horizontal size.
make_panel_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_panel_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_panel_width(width), height);
return elem;
}
// Native shader widths are integers; dynamic fills use the same conversion.
make_panel_width(width)
{
// max() returns a float even for integer inputs; cast its final result.
return int(max(1, width * level.hud_panel_x_scale));
}
// Core elements share a visibility mask; services keep their independent alpha.
make_core_text(player, x, y, align_x, scale, colour)
{
elem = make_panel_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
register_core_elem(player, elem)
{
live = [];
if (isDefined(player.tws_core_hud_elems))
{
for (i = 0; i < player.tws_core_hud_elems.size; i++)
{
old = player.tws_core_hud_elems[i];
if (isDefined(old))
live[live.size] = old;
}
}
live[live.size] = elem;
player.tws_core_hud_elems = live;
set_core_alpha(player, elem, elem.alpha);
}
set_core_alpha(player, elem, alpha)
{
if (!isDefined(elem))
return;
elem.tws_core_alpha = alpha;
elem.alpha = alpha;
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
elem.alpha = 0;
}
toggle_core_hud()
{
hidden = false;
if (isDefined(self.tws_hud_hidden))
hidden = self.tws_hud_hidden;
self.tws_hud_hidden = !hidden;
if (!isDefined(self.tws_core_hud_elems))
return;
live = [];
for (i = 0; i < self.tws_core_hud_elems.size; i++)
{
elem = self.tws_core_hud_elems[i];
if (!isDefined(elem))
continue;
set_core_alpha(self, elem, elem.tws_core_alpha);
live[live.size] = elem;
}
self.tws_core_hud_elems = live;
}
destroy_on_disconnect(elems) destroy_on_disconnect(elems)
{ {
self waittill("disconnect"); self waittill("disconnect");
+130 -148
View File
@@ -1,4 +1,4 @@
/* tws_t6_hud.gsc - configurable HUD stack: timers, zombie count, health bar. */ /* tws_t6_hud.gsc - minimal edge HUD, toggleable with .hud. */
#include maps\mp\_utility; #include maps\mp\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -11,25 +11,108 @@ 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(".hud", "Toggle core HUD");
level thread round_watcher(); level thread round_watcher();
level thread game_over_watcher(); level thread game_over_watcher();
level thread hud_chat_listener();
scripts\zm\tws_t6_common::register_connect_handler(::hud_connect);
scripts\zm\tws_t6_common::register_spawn_handler(::hud_start); scripts\zm\tws_t6_common::register_spawn_handler(::hud_start);
} }
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\zm\tws_t6_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
{
self endon("disconnect");
if (isDefined(self.tws_hud_elems))
return;
self.tws_hud_elems = [];
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
// Center the entire readout on the track, regardless of the digit count.
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
self.tws_hud_count = self hud_text(count_x,
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
// Independent numeric elements avoid a new config string on every tick.
// The readout appears only after a valid damaged-health sample.
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_health, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_slash, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_hp, 0);
track = scripts\zm\tws_t6_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
(0.18, 0.22, 0.19), 0.8, 10);
self.tws_hud_elems[self.tws_hud_elems.size] = track;
self.tws_hud_bar = scripts\zm\tws_t6_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
level.hud_c_high, 0, 11);
self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(self.tws_hud_elems);
}
hud_text(x, y, align, scale, colour, text)
{
elem = scripts\zm\tws_t6_common::make_core_overlay_text(self, x, y, align, scale, colour);
elem SetText(text);
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
return elem;
}
hud_start() hud_start()
{ {
self endon("disconnect"); self endon("disconnect");
if (isDefined(level.tws_ended) && level.tws_ended)
self hud_connect();
if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended))
return; return;
self.tws_hud_bound = true;
self thread hud_timer_row(); self thread hud_timer_row();
self thread hud_zombie_counter(); self thread hud_zombie_counter();
self thread hud_health_display(); self thread hud_health_display();
} }
// Same game-over notification fan-out as T5. // Race the map-dependent game-over notifications once at level scope.
game_over_watcher() game_over_watcher()
{ {
level thread game_over_via("end_game"); level thread game_over_via("end_game");
@@ -45,7 +128,7 @@ game_over_via(notify_name)
level notify("hud_game_over"); level notify("hud_game_over");
} }
// Poll stock round state; the first player spawn seeds pre-set round 1. // Pre-set round 1 is seeded on first spawn; only observed transitions seed here.
round_watcher() round_watcher()
{ {
level endon("hud_game_over"); level endon("hud_game_over");
@@ -78,7 +161,7 @@ hud_timer_row()
{ {
self endon("disconnect"); self endon("disconnect");
// fallback seed for the round-1-already-active case (round_watcher misses it) // A visible HUD for a waiting newcomer must not start a pre-set round 1.
if (!isDefined(level.match_start_time) && if (!isDefined(level.match_start_time) &&
isDefined(level.round_number) && isDefined(level.round_number) &&
level.round_number >= 1) level.round_number >= 1)
@@ -88,58 +171,14 @@ hud_timer_row()
level notify("hud_match_started"); level notify("hud_match_started");
} }
match = scripts\zm\tws_t6_common::make_text_elem( round = self.tws_hud_round;
self, level.hud_x, level.hud_row_match,
"left", level.hud_font_small, level.hud_c_dim);
match.alpha = 0; // revealed by match_timer_bind when round 1 opens
round = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_round,
"left", level.hud_font_small, level.hud_c_dim);
round SetTimerUp(0); round SetTimerUp(0);
if (isDefined(level.round_start_time)) if (isDefined(level.round_start_time))
round SetTimerUp(max(0, (getTime() - level.round_start_time) / 1000.0)); round SetTimerUp(max(0, (getTime() - level.round_start_time) / 1000.0));
// name only shows in co-op — your own name is noise in solo
name = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_name,
"left", level.hud_font_small, level.hud_c_name);
if (isDefined(self.name))
name SetText(self.name);
else
name SetText(self.playername);
name.alpha = 0;
elems = [];
elems[elems.size] = match;
elems[elems.size] = round;
elems[elems.size] = name;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
self thread reset_timer_each_round(round); self thread reset_timer_each_round(round);
self thread match_timer_bind(match); self thread match_timer_bind(self.tws_hud_match);
self thread freeze_timers_on_game_over(match, round); self thread freeze_timers_on_game_over(self.tws_hud_match, round);
coop = -1;
for (;;)
{
is_coop = 0;
players = getPlayers();
if (players.size > 1)
is_coop = 1;
if (is_coop != coop)
{
name.alpha = is_coop;
coop = is_coop;
}
wait (1);
}
} }
reset_timer_each_round(timer) reset_timer_each_round(timer)
@@ -156,26 +195,20 @@ reset_timer_each_round(timer)
} }
} }
// T6 timer seed includes elapsed time for late joiners. // T6 accepts elapsed seconds, including time before a late joiner's spawn.
match_timer_bind(elem) match_timer_bind(elem)
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(level.match_start_time)) if (!isDefined(level.match_start_time))
level waittill("hud_match_started"); level waittill("hud_match_started");
if (isDefined(elem)) if (isDefined(elem))
{
elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0)); elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0));
elem.alpha = 1;
}
} }
/* // Preserve authoritative frozen clocks when the core is shown after game over.
Level-scope trigger only — per-player death/health polling is intercepted
by the zombiemode scripts. Freeze value math relies on the timers being
anchored at first spawn / first round transition, not level init.
*/
freeze_timers_on_game_over(match_elem, round_elem) freeze_timers_on_game_over(match_elem, round_elem)
{ {
self endon("disconnect"); self endon("disconnect");
@@ -191,22 +224,9 @@ freeze_timers_on_game_over(match_elem, round_elem)
hud_zombie_counter() hud_zombie_counter()
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
label = scripts\zm\tws_t6_common::make_text_elem( count = self.tws_hud_count;
self, level.hud_x, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_label);
label SetText("Zombies");
count = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_count, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_count);
count SetValue(0);
elems = [];
elems[elems.size] = label;
elems[elems.size] = count;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
last = -1; last = -1;
for (;;) for (;;)
@@ -219,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;
} }
@@ -230,80 +251,45 @@ hud_zombie_counter()
hud_health_display() hud_health_display()
{ {
self endon("disconnect"); self endon("disconnect");
level endon("hud_game_over");
bar_width = level.hud_bar_width; bar_width = level.hud_overlay_bar_width;
bar = self.tws_hud_bar;
// -2 on both axes so the back frames the bar with 2px padding cur = self.tws_hud_health;
back = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2); slash = self.tws_hud_slash;
back.alpha = 0.5; max_hp = self.tws_hud_maxhealth;
back.color = (0, 0, 0); hp = self.tws_hud_hp;
back SetShader("white", bar_width + 4, level.hud_bar_height + 4); last_width = -1;
bar = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x, level.hud_row_bar);
bar.color = level.hud_c_high;
bar SetShader("white", bar_width, level.hud_bar_height);
/*
"87/160" is three elements, not a rebuilt string. SetText allocates
a config string per call; per-tick rebuilds crash the server.
cur is right-aligned so digits grow leftward and the slash + max
never shift.
*/
cur = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 26, level.hud_row_bar + 3,
"right", level.hud_font_mid, level.hud_c_high);
cur SetValue(self.health);
slash = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 30, level.hud_row_bar + 3,
"center", level.hud_font_mid, level.hud_c_high);
slash SetText("/");
max_hp = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 34, level.hud_row_bar + 3,
"left", level.hud_font_mid, level.hud_c_high);
max_hp SetValue(100);
cur.alpha = 0;
slash.alpha = 0;
max_hp.alpha = 0;
elems = [];
elems[elems.size] = back;
elems[elems.size] = bar;
elems[elems.size] = cur;
elems[elems.size] = slash;
elems[elems.size] = max_hp;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
last_width = bar_width;
last_health = -1; last_health = -1;
last_max = -1; last_max = -1;
last_shown = -1;
for (;;) for (;;)
{ {
// Display fallbacks must not alter gameplay health values.
maximum = 100; maximum = 100;
if (isDefined(self.maxhealth) && self.maxhealth > 0) if (isDefined(self.maxhealth) && self.maxhealth > 0)
maximum = self.maxhealth; maximum = self.maxhealth;
frac = self.health / (maximum * 1.0); current = 0;
if (isDefined(self.health))
current = max(0, self.health);
frac = current / (maximum * 1.0);
if (frac > 1) if (frac > 1)
frac = 1; frac = 1;
if (frac < 0) width = scripts\zm\tws_t6_common::make_overlay_width(frac * bar_width);
frac = 0;
width = int(max(frac * bar_width, 1));
if (width != last_width) if (width != last_width)
{ {
bar SetShader("white", width, level.hud_bar_height); bar SetShader("white", width, level.hud_overlay_bar_height);
last_width = width; last_width = width;
} }
colour = level.hud_c_high; bar_alpha = 1;
if (frac == 0)
bar_alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, bar, bar_alpha);
colour = level.hud_c_high;
if (frac <= 0.25) if (frac <= 0.25)
colour = level.hud_c_low; colour = level.hud_c_low;
else if (frac <= 0.5) else if (frac <= 0.5)
@@ -313,25 +299,21 @@ hud_health_display()
cur.color = colour; cur.color = colour;
slash.color = colour; slash.color = colour;
max_hp.color = colour; max_hp.color = colour;
hp.color = colour;
// numbers only show once you're hurt readout_alpha = 0;
show = 0; if (isDefined(self.health) && current < maximum)
readout_alpha = 1;
scripts\zm\tws_t6_common::set_core_alpha(self, cur, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, slash, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, max_hp, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, hp, readout_alpha);
if (frac < level.hud_health_threshold) // Keep health numbers current even at full health or while hidden.
show = 1; if (current != last_health)
if (show != last_shown)
{ {
cur.alpha = show; cur SetValue(current);
slash.alpha = show; last_health = current;
max_hp.alpha = show;
last_shown = show;
}
if (self.health != last_health)
{
cur SetValue(self.health);
last_health = self.health;
} }
if (maximum != last_max) if (maximum != last_max)
+39 -4
View File
@@ -20,9 +20,44 @@ init()
level thread join_chat_listener(); level thread join_chat_listener();
} }
// Never reset this on death or round change. Spectator spawns do not count. // T6 first emits spawned_player during setup, then moves late connectors
// into spectator mode after 0.05 seconds. Observe the settled state instead.
// The common spawn dispatcher runs only once, so keep listening ourselves.
join_spawn() join_spawn()
{ {
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
self thread join_track_death();
for (;;)
{
self.tws_join_spawn_pending = true;
wait 0.1;
if (isDefined(self.sessionstate) && self.sessionstate == "playing")
self.tws_join_has_played = true;
self.tws_join_spawn_pending = false;
if (is_true(self.tws_join_has_played))
return;
self waittill("spawned_player");
}
}
// A real death during the settling window still consumes newcomer eligibility.
// Never clear this flag on death, a round change, or a subsequent spawn.
join_track_death()
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
self waittill("killed_player");
self.tws_join_has_played = true; self.tws_join_has_played = true;
} }
@@ -51,8 +86,7 @@ join_request()
level endon("game_ended"); level endon("game_ended");
level endon("intermission"); level endon("intermission");
// The spawn handler flag is set on first playable spawn only; spectator // Setup spawns do not consume eligibility; actual play and .join do.
// spawns do not trigger it. Belt-and-suspenders against re-triggering.
if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used)) if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used))
{ {
self iPrintLnBold("^3.join is only for newcomers who have not played yet"); self iPrintLnBold("^3.join is only for newcomers who have not played yet");
@@ -62,7 +96,8 @@ join_request()
// Fail closed while the stock connection setup is still in progress. // Fail closed while the stock connection setup is still in progress.
// T6 stock does not populate self.initialized the way T5 SP zombiemode // T6 stock does not populate self.initialized the way T5 SP zombiemode
// does; use sessionstate as the readiness signal. // does; use sessionstate as the readiness signal.
if (!isDefined(self.sessionstate) || self.sessionstate != "spectator") if (is_true(self.tws_join_spawn_pending) ||
!isDefined(self.sessionstate) || self.sessionstate != "spectator")
{ {
self iPrintLnBold("^3.join is only available while waiting to enter the game"); self iPrintLnBold("^3.join is only available while waiting to enter the game");
return; return;
+158 -113
View File
@@ -1,4 +1,4 @@
/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */ /* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\mp\_utility; #include 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");
@@ -127,120 +211,18 @@ lb_request(manual)
level endon("game_ended"); level endon("game_ended");
level endon("intermission"); level endon("intermission");
snapshot = lb_cached_record();
if (!isDefined(snapshot))
{
if (manual)
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
return;
}
// A manual replay replaces any welcome announcement still waiting to run. // A manual replay replaces any welcome announcement still waiting to run.
// This also applies when the cache is unavailable.
if (manual) if (manual)
self notify("tws_lb_requested"); self notify("tws_lb_requested");
self lb_show_hud(snapshot); self.tws_lb_last_request = getTime();
} snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
lb_show_hud(snapshot) // Common owns pagination, help priority and the single service window.
{ // Undefined snapshots produce a private unavailable page for manual replay.
// Stop the previous display and its cleanup threads before replacing it. self scripts\zm\tws_t6_common::show_record(snapshot);
self notify("tws_lb_hud_done");
if (isDefined(self.tws_lb_elems))
lb_hud_destroy(self.tws_lb_elems);
self endon("disconnect");
self endon("tws_lb_hud_done");
scripts\zm\tws_t6_common::ensure_config();
// Match .help's position, typography and row spacing below the health bar.
y = level.hud_row_bar + level.hud_bar_height + 20;
elems = [];
title = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
title.alpha = 0;
if (snapshot.round == 0)
title SetText("No recorded high round yet");
else
title SetText("Record: Round " + snapshot.round);
elems[elems.size] = title;
for (i = 0; i < 3; i++)
{
row = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
row.alpha = 0;
elems[elems.size] = row;
}
// Cleanup runs independently of the display and ends when it is replaced.
self.tws_lb_elems = elems;
self thread lb_hud_disconnect(elems);
self thread lb_hud_end_game(elems, "end_game");
self thread lb_hud_end_game(elems, "game_ended");
self thread lb_hud_end_game(elems, "intermission");
offset = 0;
for (;;)
{
// Text changes only once per page, never during the visibility loop.
for (i = 0; i < 3; i++)
{
row = elems[i + 1];
if (offset + i < snapshot.lines.size)
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
else
row SetText("");
}
remaining = 12;
while (remaining > 0)
{
visible = !self lb_hud_help_visible();
for (i = 0; i < elems.size; i++)
elems[i].alpha = visible;
wait 0.1;
// Pause the page while .help occupies the same area.
if (visible)
remaining -= 0.1;
}
offset += 3;
if (offset >= snapshot.lines.size)
break;
}
lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done");
}
lb_hud_help_visible()
{
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
}
lb_hud_disconnect(elems)
{
self endon("tws_lb_hud_done");
self waittill("disconnect");
lb_hud_destroy(elems);
}
lb_hud_end_game(elems, event)
{
self endon("disconnect");
self endon("tws_lb_hud_done");
level waittill(event);
lb_hud_destroy(elems);
self.tws_lb_elems = undefined;
self notify("tws_lb_hud_done");
}
lb_hud_destroy(elems)
{
for (i = 0; i < elems.size; i++)
{
if (isDefined(elems[i]))
elems[i] Destroy();
}
} }
lb_refresh() lb_refresh()
@@ -529,22 +511,85 @@ lb_holder_lines(names)
{ {
lines = []; lines = [];
line = ""; line = "";
columns = 0;
for (i = 0; i < names.size; i++) for (i = 0; i < names.size; i++)
{ {
if (line != "" && line.size + names[i].size + 2 > 100) name = lb_holder_name(names[i]);
width = lb_holder_width(name);
if (line != "" && columns + width + 2 > 16)
{ {
lines[lines.size] = line; lines[lines.size] = line;
line = ""; line = "";
columns = 0;
} }
if (line != "") if (line != "")
{
line += ", "; line += ", ";
line += names[i]; columns += 2;
}
line += name;
columns += width;
} }
if (line != "") if (line != "")
lines[lines.size] = line; lines[lines.size] = line;
return lines; return lines;
} }
lb_holder_name(value)
{
// The service has 200 virtual units at font scale 1.1. Use a conservative
// 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics.
// A long name gets one bounded entry; no tied holder is dropped.
value = lb_display_text(value, 64);
if (value == "")
value = "Unknown player";
if (lb_holder_width(value) <= 16)
return value;
output = "";
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
width = 1;
if (code >= 128)
width = 2;
if (columns + width > 13)
break;
count = lb_glyph_bytes(code);
output += getSubStr(value, i, i + count);
columns += width;
i += count - 1;
}
return output + "...";
}
lb_holder_width(value)
{
columns = 0;
for (i = 0; i < value.size; i++)
{
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
if (code >= 128)
columns += 2;
else
columns++;
i += lb_glyph_bytes(code) - 1;
}
return columns;
}
lb_glyph_bytes(code)
{
if (code >= 240)
return 4;
if (code >= 224)
return 3;
if (code >= 192)
return 2;
return 1;
}
lb_display_text(value, limit) lb_display_text(value, limit)
{ {
output = ""; output = "";
+66
View File
@@ -0,0 +1,66 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\mp\_utility;
#include common_scripts\utility;
// T6 loader probes main and init; initialize only once.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
level.moderation_ready = false;
scripts\zm\tws_t6_common::register_connect_handler(::moderation_connected);
level thread moderation_loop();
}
moderation_connected()
{
self endon("disconnect");
if (!level.moderation_ready)
return;
moderation_observe(self);
moderationPump();
}
moderation_observe(player)
{
if (!isDefined(player))
return;
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return;
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
guid = "" + guid;
name = player.name;
if (!isDefined(name))
name = "Unknown player";
player moderationObserve(guid, name);
}
moderation_loop()
{
for (;;)
{
if (GetDvar("tws_moderation_enabled") == "0")
{
moderationStop();
level.moderation_ready = false;
wait 3;
continue;
}
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
if (!level.moderation_ready)
{
wait 3;
continue;
}
players = getPlayers();
for (i = 0; i < players.size; i++)
moderation_observe(players[i]);
moderationPump();
wait 0.5;
}
}
+271
View File
@@ -0,0 +1,271 @@
// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
function compile(source) {
const strings=[];
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
if(token.startsWith('/')) return '';
strings.push(token); return `__str_${strings.length-1}__`;
});
const compiled = [];
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
for(let match; (match=headers.exec(source));) {
let end=headers.lastIndex, depth=1;
while(depth && end<source.length) {
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
}
assert.equal(depth,0);
let body=source.slice(headers.lastIndex,end-1);
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
body=body.replace(/#.?(?=__str_)/g,'');
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
body=body.replace(/player getGuid\(\)/g,'player.guid');
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
body=body.replace(/level notify\(/g,'level.notify(');
body=body.replace(/::(afk_\w+)/g,'$1');
body=body.replace(/http::request/g,'httpPost');
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
if(match[1]==='afk_register_dogs')continue;
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
}
return compiled.join('\n');
}
function engine(game, options={}) {
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
let now=100000, wall=100000, serial=0;
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
const players=[],zombies=[];
const c=vm.createContext({level,
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
telemetryNewMatch:()=>`id-${++serial}`,
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
afkRemove:(_s,_u,id)=>saved.delete(id),
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
httpComplete:request=>{options.during?.(request,c);const d=request.data;
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
afkResponse:JSON.parse,
});
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
function player(guid='18446744073709551615') {
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
p.getPlayerAngles=()=>p.angles;
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
players.push(p);p.afk_spawn();return p;
}
const p=player(), other=player('other');
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
};
}
let checks=0;
for(const game of ['t5','t6']) {
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
});
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
});
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
});
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
assert.equal(e.p.afk_remaining(),1799000);
});
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
assert.equal(e.p.sessionstate,'playing');
});
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
});
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
});
test('manual command at zero cannot create a grace period',()=>{
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
});
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
});
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
}
});
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false);
assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability('));
});
test('solo and last active players can enter with unavailable or pending teammates',()=>{
for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){
const e=engine(game);
if(flags===null)e.players.splice(1,1);
else if(flags.pending)e.other.tws_afk_state.pending=true;
else Object.assign(e.other,flags);
e.enter();e.advance(1000);e.tick();
assert(e.p.hud_afk_on);assert.equal(e.p.afk_remaining(),1799000);
assert.equal(e.stops.length,0);assert.equal(e.p.sessionstate,'playing');
assert(!e.log.some(x=>x[0]==='notify'));
}
});
test('teammate death or disconnect during authorization does not cancel entry',()=>{
for(const at of ['reserve','start'])for(const change of ['death','disconnect']){
const e=engine(game,{during:request=>{
if(request.action!==at)return;
if(change==='death')e.other.alive=false;
else e.players.splice(1,1);
}});
e.enter();e.tick();assert(e.p.hud_afk_on);assert.equal(e.stops.length,0);
}
});
test('all players can be AFK and teammate loss does not force a match end',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
e.advance(1000);e.tick();e.other.afk_tick().next();
assert(e.p.hud_afk_on);assert(e.other.hud_afk_on);assert.equal(e.stops.length,0);
e.other.alive=false;e.other.afk_tick().next();e.tick();
assert(e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'death');
e.players.splice(1,1);e.advance(1000);e.tick();assert(e.p.hud_afk_on);
assert(!e.log.some(x=>x[0]==='notify'));
assert(!e.source.includes('afk_team_watcher'));
assert(!/level\s+notify\("end_game"\)/.test(e.source));
});
test('solo protection expires normally and manual return preserves bounded grace',()=>{
for(const manual of [false,true]){
const e=engine(game,{authorized:3});e.players.splice(1,1);e.enter();
e.advance(1000);assert.equal(e.p.afk_damage(null,null,10),0);
if(manual){e.p.afk_exit();assert.equal(e.stops.at(-1).reason,'manual');}
e.advance(2000);e.tick();
assert(!e.p.hud_afk_on);assert(!e.p.tws_afk_protected);assert(!e.p.ignoreme);
assert(!e.p.tws_afk_controls_locked);assert.equal(e.p.afk_damage(null,null,10),-1);
if(!manual)assert.equal(e.stops.at(-1).reason,'expired');
}
});
test('stock match end still releases and settles every AFK player',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
const watcher=e.c.afk_end_watcher();watcher.next();watcher.next();
assert(e.level.afk_ended);assert.equal(e.stops.length,2);
for(const p of e.players){assert(!p.hud_afk_on);assert(!p.tws_afk_protected);assert(p.tws_afk_state.cancelled);}
assert(e.stops.every(s=>s.reason==='match_end'));
});
test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
for(let i=0;i<40;i++)watcher.next(e.p);
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
});
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
assert(!e.source.includes('.score'));
});
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
});
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
});
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
});
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
});
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
for(const at of ['reserve','start']){
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
}
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
});
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
});
}
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);
+392
View File
@@ -0,0 +1,392 @@
// Actual GSC transfer logic under portable VM/HTTP/disk shims; no game runtime.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { loadEcBank } from './tests/ec_bank_contract.mjs';
const root = new URL('../', import.meta.url);
const publicEndpoint = 'https://ec.thewrightserver.net/api/bank';
const privateToken = 'ec_bank_' + 'a'.repeat(43);
const ecIndex = process.argv.indexOf('--ec-root');
const realEc = ecIndex < 0 ? undefined : loadEcBank(process.argv[ecIndex + 1]);
// EC src/lib/server/fleet/bank.ts token/transfer contract, also checked against
// that actual implementation with --ec-root (no HTTP or production database).
function assertContract(action, data) {
const token = value => typeof value === 'string' && value.length > 0 && value.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value);
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min && value <= 1000000;
assert.equal(data.server, 'server');
assert(token(data.guid), 'EC requires a nonempty string GUID token');
if (action === 'balance') return;
assert(token(data.request_id)); assert(token(data.match));
assert(['deposit','withdraw'].includes(data.operation));
assert(integer(data.amount, 1) || (data.operation === 'withdraw' && data.amount === 'all'));
assert(integer(data.capacity));
assert(['commit','cancel'].includes(data.decision));
if (action === 'deposit' || action === 'withdraw') assert.equal(action, data.operation);
else assert.equal(action, data.decision);
}
function functions(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const found = new Map();
const headers = /^(init|bank_\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(protectedSource));) {
let end = headers.lastIndex, depth = 1;
while (depth && end < protectedSource.length) {
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
assert.equal(depth, 0);
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost');
body = body.replace(/player getGuid\(\)/g, 'getGuid(player)');
body = body.replace(/typeof\(/g, 'gscType(');
body = body.replace(/#(?=__literal_)/g, '');
body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::register_help_command/g, 'registerHelpCommand');
body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify(');
body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];');
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;');
body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;');
body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
body = body.replace(/player maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g, 'player.downed').replace(/wait ([^;]+);/g, 'yield $1;');
const generator = match[1] === 'bank_sender';
found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
function engine(game, options = {}) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_bank.gsc`, root), 'utf8');
const excluded = new Set(['bank_chat_listener','bank_end_watcher','bank_guard','bank_say','bank_request']);
const functionsByName = functions(source);
if (game === 't6') assert(!/\bgetGuid\s*\(/i.test(source), 'T6 bank must use the persistent field, as telemetry does');
const identity = Object.hasOwn(options, 'guid') ? options.guid : '18446744073709551615';
const player = { sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true };
if (game === 't6') player.guid = identity;
const entry = { player, active: true };
const recovered = (options.recovered ?? []).map(report => ({...report}));
const saved = recovered.map(report => JSON.parse(report.body)), writes = [], sent = [], messages = [], order = [], loaded = [], dvarsRead = [], logs = [];
let serial = 0, now = 1000;
const dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars };
function assertScope(server, url) {
assert.equal(server, dvars.tws_server_id);
assert.equal(url, publicEndpoint);
}
const c = vm.createContext({
level: { telemetry_match: 'match', telemetry_started: true },
GetDvar: name => { dvarsRead.push(name); return dvars[name] ?? ''; },
getSubStr: (value, start, end) => value.substring(start, end), registerHelpCommand: () => {},
isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive,
int: Math.trunc, getTime: () => now, strTok: (text, delimiter) => text.split(delimiter),
bankVersion: () => "1.2.0",
...(game === 't5' ? {getGuid: p => { assert.equal(p, player); return identity; }} : {}),
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : value && typeof value === 'object' ? 'array' : typeof value,
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
bank_say: (_entry, message) => messages.push(message),
telemetryNewMatch: () => `${options.idPrefix ?? 'request'}-${++serial}`,
bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return options.loadFailure ? undefined : recovered; },
bankStore: (server, url, body) => {
assertScope(server, url);
const data = JSON.parse(body); assertContract(data.decision, data);
writes.push(data); order.push(data.decision);
if (options.storageFailure === data.decision) return false;
saved.splice(0, saved.length, data); return true;
},
bankRemove: (server, url) => { assertScope(server, url); if (options.removeFailure) return false; saved.length = 0; return true; },
httpPost: (url, data) => {
assertContract(url.split('/').at(-1), JSON.parse(data.body));
order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {};
},
httpComplete: () => {
options.during?.(c, entry);
const action = sent.at(-1).url.split('/').at(-1);
if (options.receiver) {
const response = options.receiver(action, JSON.parse(sent.at(-1).body));
return [JSON.stringify(response.body), !options.timeout, '', {http_status: response.status}];
}
const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared';
const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : JSON.parse(sent.at(-1).body).request_id, amount: options.amount ?? 2000, balance: options.balance ?? 5000, code: options.code ?? 'ok' };
return [options.missingResponse ? undefined : JSON.stringify(reply), !options.timeout, privateToken, options.missingDiagnostics ? undefined : options.diagnostics ?? { http_status: options.status ?? 200, curl_code: options.timeout ? 28 : 0, dns_ms:1, connect_ms:2, tls_ms:3, total_ms:options.timeout ? 15000 : 4 }];
},
bankResponse: data => options.malformed ? { valid: false, reason: options.reason ?? "invalid_json" } : {valid:true, ...JSON.parse(data)},
print: message => logs.push(message), println: message => logs.push(message)
});
vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
c.init();
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, logs, advance: ms => { now += ms; }, run: args => c.bank_transfer(entry,args) };
}
let checks = 0;
function journalEntries(engine) {
// GSC associative arrays do not serialize as JS arrays. Copy the native
// bankLoad shape while retaining the exact serialized decision body.
return Array.from(engine.c.level.bank_queue, entry => ({id:entry.id, body:entry.body, decision:entry.decision}));
}
for (const game of ['t5','t6']) {
function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } }
test('engine identities remain exact strings in balance, prepare, both decisions and restart replay', () => {
for (const guid of [2741626, 0, '2741626', '0002741626', '18446744073709551615']) {
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
for (const timeout of [false, true]) {
const e = engine(game, {guid, timeout}); e.run(command);
assert.equal(e.sent.length, 1);
for (const data of [...e.writes, ...e.sent.map(request => JSON.parse(request.body))]) assert.equal(data.guid, String(guid));
if (command[0] === '.b') { assert.equal(e.writes.length, 0); continue; }
assert.equal(e.writes[0].decision, 'cancel');
assert.equal(e.saved[0].decision, timeout ? 'cancel' : 'commit');
const recovered = journalEntries(e);
const restarted = engine(game, {guid:undefined, recovered});
restarted.c.bank_sender().next();
assert.equal(restarted.sent[0].body, recovered[0].body);
assert.equal(JSON.parse(restarted.sent[0].body).guid, String(guid));
assert.equal(restarted.saved.length, 0);
assert.equal(restarted.player.score, 3000);
}
}
}
});
test('missing or invalid identities reject before HTTP, journal writes or point changes', () => {
for (const guid of [undefined, null, '', ' ', 'bad guid', 'x'.repeat(65), 'x\n', true, {}, [], 1.5]) {
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
const e = engine(game, {guid}); e.run(command);
assert.match(e.messages[0], /player identity unavailable/);
assert.equal(e.sent.length, 0); assert.equal(e.writes.length, 0);
assert.equal(e.c.level.bank_write, 0); assert.equal(e.player.score, 3000);
assert.equal(e.player.score_total, 4000);
}
}
const e = engine(game); e.entry.player = undefined; e.run(['.b']);
assert.match(e.messages[0], /player identity unavailable/); assert.equal(e.sent.length, 0);
});
test('startup and every request ignore obsolete endpoint overrides and retain instance authentication', () => {
for (const url of ['', 'https://controller.internal/api/bank', 'http://old-controller/api/bank', 'https://untrusted.example/api/bank']) {
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
const e = engine(game, { dvars: { tws_bank_url: url } });
e.run(command);
if (command[0] !== '.b') e.c.bank_sender().next();
assert.equal(e.loaded.length, 1);
assert(!e.dvarsRead.includes('tws_bank_url'));
assert.equal(e.sent[0].url, publicEndpoint + '/' + ({'.b':'balance','.d':'deposit','.w':'withdraw'}[command[0]]));
if (command[0] !== '.b') assert.equal(e.sent[1].url, publicEndpoint + '/commit');
for (const request of e.sent) {
assert.equal(request.headers.Authorization, 'Bearer ' + privateToken);
assert.equal(JSON.parse(request.body).server, 'server');
}
}
}
});
test('missing or invalid instance credentials and disabled banks do not load journals', () => {
for (const dvars of [{tws_server_id:''}, {tws_bank_token:''}, {tws_bank_token:'wrong_' + 'a'.repeat(43)}, {tws_bank_enabled:'0'}]) {
assert.equal(engine(game, {dvars}).loaded.length, 0);
}
});
test('startup reports versions, scope, credential validation and journal count safely', () => {
const e = engine(game);
assert.match(e.logs.join('\n'), /script=2\.0\.3 plugin=1\.2\.0/);
assert(e.logs.some(line => line.includes(`endpoint=${publicEndpoint} server=server credentials=valid`)));
assert(e.logs.some(line => line.includes('journal_load=success pending=0')));
const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}});
assert(invalid.logs.some(line => line.includes('credentials=invalid')));
assert(!invalid.logs.join('').includes('secret-invalid-token'));
const load = engine(game, {loadFailure:true});
assert.match(load.logs.join('\n'), /journal_load=failure pending=unknown bank=disabled/);
assert.equal(load.c.level.bank_write, undefined);
});
test('requests classify failures and preserve the API identity contract', () => {
for (const [options, outcome] of [
[{}, 'ok'], [{noHandle:true}, 'request_creation'], [{timeout:true}, 'transport_failure'],
[{status:401}, 'http_rejection'], [{missingDiagnostics:true}, 'missing_diagnostics'],
[{missingResponse:true}, 'missing_response'], [{malformed:true}, 'invalid_ack_invalid_json'],
[{malformed:true,reason:'schema'}, 'invalid_ack_schema'], [{wrongId:true}, 'request_id_mismatch'],
[{state:'rejected', code:'pending'}, 'application_rejection'], [{state:'committed'}, 'unexpected_state']
]) {
const e = engine(game, options); e.run(['.d','2000']);
const line = e.logs.find(line => line.includes('action=deposit'));
assert(line, JSON.stringify(options)); assert(line.includes(`outcome=${outcome} `), line);
assert.match(line, /correlation=bank-1000-1 action=deposit request_id=request-1/);
assert.match(line, /http_status=\w+ curl_code=\w+ dns_ms=\w+ connect_ms=\w+ tls_ms=\w+ total_ms=\w+/);
}
const e = engine(game); e.run(['.b']); e.run(['.b']);
const lines = e.logs.filter(line => line.includes('action=balance'));
assert.equal(lines.length, 2);
assert.match(lines[0], /correlation=bank-1000-1/); assert.match(lines[1], /correlation=bank-1000-2/);
for (const request of e.sent) assert(!Object.hasOwn(JSON.parse(request.body), 'request_id'));
assert.equal(e.saved.length, 0);
});
test('diagnostics exclude secrets, arbitrary codes, bodies and malformed timing fields', () => {
for (const diagnostics of [undefined, {}, 'secret', {http_status:200, curl_code:privateToken, dns_ms:-1, connect_ms:{}, tls_ms:1e30, total_ms:'raw-body'}]) {
const e = engine(game, {diagnostics, state:'rejected', code:privateToken}); e.run(['.d','2000']);
const text = e.logs.join('\n');
for (const secret of [privateToken, 'Authorization', 'Bearer ', 'raw-body', '18446744073709551615']) assert(!text.includes(secret));
}
const e = engine(game, {malformed:true, reason:privateToken}); e.run(['.b']);
assert.match(e.logs.at(-1), /invalid_ack_unknown/); assert(!e.logs.join('').includes(privateToken));
for (const code of [privateToken, 'oops\ninjected', '^1red', 'x'.repeat(1000), 'custom_code']) {
assert.equal(e.c.bank_response_code(code), 'unknown');
}
for (const diagnostics of [undefined, {}, [], false, 'secret', {http_status:'200'}, {http_status:-1}])
assert.equal(e.c.bank_diagnostic_number(diagnostics, 'http_status'), 'unavailable');
});
test('failures are limited by reason, with summaries, changed status and immediate recovery', () => {
const options = {status:401}, e = engine(game, options);
e.run(['.b']); e.run(['.b']);
assert.equal(e.logs.filter(line => line.includes('action=balance')).length, 1);
e.advance(30000); e.run(['.b']); assert.match(e.logs.at(-1), /suppressed=1/);
options.status = 503; e.run(['.b']); assert.match(e.logs.at(-1), /http_status=503/);
options.status = 200; e.run(['.b']); assert.match(e.logs.at(-1), /outcome=ok.*recovery=true/);
});
test('settlement reports retry progress, reason changes, storage failures and eventual recovery', () => {
const options = {}, e = engine(game, options); e.run(['.d','2000']);
options.timeout = true;
const sender = e.c.bank_sender();
assert.equal(sender.next().value, 1);
assert.match(e.logs.at(-1), /decision=commit attempt=1 pending_seconds=0 settlement=pending next_retry_seconds=1/);
const count = e.logs.length;
e.advance(1000); assert.equal(sender.next().value, 2); assert.equal(e.logs.length, count);
options.timeout = false; options.removeFailure = true;
e.advance(2000); assert.equal(sender.next().value, 4);
assert.match(e.logs.at(-1), /attempt=3 pending_seconds=3 settlement=journal_remove_failure next_retry_seconds=4.*suppressed=1/);
options.removeFailure = false; e.advance(4000); sender.next();
assert.match(e.logs.at(-1), /attempt=4 pending_seconds=7 journal_remove=success settlement=resolved next_retry_seconds=0 recovery=true/);
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 1000);
for (const decision of ['cancel','commit']) {
const failed = engine(game, {storageFailure:decision}); failed.run(['.d','2000']);
assert(failed.logs.some(line => line.includes(`journal_write decision=${decision} request_id=request-1 outcome=storage_failure`)));
}
});
test('strict positive integer syntax', () => {
const {c} = engine(game);
for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0);
assert.equal(c.bank_amount('2000'),2000); assert.equal(c.bank_amount('ALL'),0); assert.equal(c.bank_amount('all'),-1);
});
test('deposit journals cancellation then commit before moving points', () => {
const e=engine(game); e.run(['.d','2000']);
assert.equal(e.player.score,1000); assert.equal(e.player.score_total,4000); assert.equal(e.player.pers.score,1000);
assert.deepEqual(e.order,['cancel','http','commit']); assert.equal(e.saved[0].decision,'commit');
assert.equal(e.saved[0].guid,'18446744073709551615');
assert.equal(e.sent[0].headers.Authorization,'Bearer ' + privateToken);
assert.equal(e.sent[0].timeout_ms,15000);
});
test('all captures deposit points and resolves withdrawal amount', () => {
const e=engine(game,{amount:3000}); e.run(['.d','all']); assert.equal(e.player.score,0);
assert.equal(JSON.parse(e.sent[0].body).amount,3000);
const w=engine(game); w.run(['.w','all']); assert.equal(w.player.score,5000);
assert.equal(JSON.parse(w.sent[0].body).amount,'all');
});
test('failure responses never change points and retain cancellation', () => {
for (const options of [{timeout:true},{noHandle:true},{status:503},{status:401},{status:429},{missingDiagnostics:true},{malformed:true},{wrongId:true},{state:'rejected',code:'insufficient_balance'}]) {
const e=engine(game,options); e.run(['.w','2000']); assert.equal(e.player.score,3000);
assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.c.level.bank_queue[0].decision,'cancel');
}
});
test('storage failures never send unsaved work or move points', () => {
for (const storageFailure of ['cancel','commit']) {
const e=engine(game,{storageFailure}); e.run(['.d','2000']); assert.equal(e.player.score,3000);
if(storageFailure==='cancel') assert.equal(e.sent.length,0); else assert.equal(e.saved[0].decision,'cancel');
}
});
test('spending, death, disconnect, map end and point cap changes cancel prepared work', () => {
for(const during of [(_c,e)=>{e.player.score=100;},(_c,e)=>{e.player.alive=false;},(_c,e)=>{e.active=false;},(c)=>{c.level.bank_ended=true;},(_c,e)=>{e.player.downed=true;}]) {
const e=engine(game,{during}); e.run(['.d','2000']); assert.equal(e.saved[0].decision,'cancel');
}
const e=engine(game,{during:(_c,e)=>{e.player.score=999999;}}); e.run(['.w','2000']); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.player.score,999999);
});
test('invalid requests and unavailable match fail before HTTP', () => {
for(const args of [['.d'],['.w','1000001'],['.d','4000'],['.b','2000'],['.w','1000000']]) {
const e=engine(game); e.run(args); assert.equal(e.sent.length,0); assert.equal(e.player.score,3000);
}
const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0);
});
test('balance reads do not journal or alter points', () => {
const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.equal(e.messages[0], '^3Bank: ^75000 ^3points');
});
test('empty balance displays zero and withdrawing all reports insufficient balance', () => {
const e = engine(game, {amount:0, balance:0}); e.run(['.b']);
assert.equal(e.messages[0], '^3Bank: ^70 ^3points');
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
const w = engine(game, {amount:0, balance:0, state:'rejected', code:'insufficient_balance'}); w.run(['.w','all']);
assert.match(w.messages[0], /Not enough bank balance/);
assert.equal(w.player.score, 3000); assert.equal(w.saved[0].decision, 'cancel');
w.c.bank_sender().next();
assert.equal(w.sent[1].url, publicEndpoint + '/cancel'); assert.equal(w.saved.length, 0);
});
test('recovered commit and cancel decisions retry at the public endpoint without applying points', () => {
for (const decision of ['commit','cancel']) {
const body = JSON.stringify({server:'server', guid:'18446744073709551615', request_id:'recovered', match:'old-match', operation:'withdraw', amount:'all', capacity:999370, decision});
const options = {timeout:true, dvars:{tws_bank_url:'https://controller.internal/api/bank'}, recovered:[{id:'recovered', body, decision}]};
const e = engine(game, options), sender = e.c.bank_sender();
sender.next();
assert.equal(e.saved.length, 1); assert.equal(e.player.score, 3000);
options.timeout = false; sender.next();
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
assert.equal(e.sent.length, 2);
for (const request of e.sent) {
assert.equal(request.url, publicEndpoint + '/' + decision);
assert.equal(request.body, body);
}
}
});
test('recovered settlements cannot reapply points and retry cleanup safely', () => {
const e=engine(game); e.run(['.w','2000']); const score=e.player.score;
const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score);
const failed=engine(game,{removeFailure:true}); failed.run(['.d','2000']); const gen=failed.c.bank_sender(); gen.next(); gen.next();
assert.equal(failed.player.score,1000); assert.equal(failed.saved[0].decision,'commit');
});
test('conflicting settlement states retain their journal', () => {
const {c}=engine(game);
assert(!c.bank_resolved('commit','cancelled')); assert(!c.bank_resolved('cancel','committed'));
assert(c.bank_resolved('cancel','rejected')); assert(c.bank_resolved('commit','committed'));
});
if (realEc) test('actual EC rejects numeric GUIDs and settles normalized script payloads exactly once', () => {
for (const guid of [2741626, '2741626', '0002741626', '18446744073709551615']) {
const ec = realEc(String(guid));
try {
// Reproduce the live rejection before any EC transaction exists.
const numeric = {server:'server', guid:2741626, match:'match', request_id:'old-cancel', operation:'withdraw', amount:'all', capacity:999370, decision:'cancel'};
for (const action of ['balance','withdraw','cancel','commit']) {
const rejected = ec.request(action, numeric);
assert.equal(rejected.status, 400); assert.equal(rejected.body.code, 'invalid_request');
assert.equal(rejected.body.request_id, '');
}
assert.equal(ec.requests().length, 0);
const e = engine(game, {guid, receiver:ec.request});
e.run(['.b']); assert.equal(e.messages[0], '^3Bank: ^70 ^3points');
e.run(['.w','all']); assert.match(e.messages.at(-1), /Not enough bank balance/);
assert.equal(e.player.score, 3000); assert.equal(e.saved[0].decision, 'cancel');
e.c.bank_sender().next(); assert.equal(e.saved.length, 0);
e.run(['.d','2000']); assert.equal(e.player.score, 1000);
assert.equal(e.saved[0].decision, 'commit');
const recovered = journalEntries(e);
const options = {guid:undefined, receiver:ec.request, recovered, removeFailure:true};
const replay = engine(game, options), sender = replay.c.bank_sender();
sender.next(); sender.next(); // EC commits once even when cleanup fails.
assert.equal(replay.saved.length, 1); assert.equal(replay.player.score, 3000);
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}]);
options.removeFailure = false; sender.next(); assert.equal(replay.saved.length, 0);
for (const request of replay.sent) assert.equal(request.body, recovered[0].body);
const withdrawal = engine(game, {guid, receiver:ec.request, idPrefix:'withdraw'});
withdrawal.run(['.w','all']); assert.equal(withdrawal.player.score, 5000);
withdrawal.c.bank_sender().next(); assert.equal(withdrawal.saved.length, 0);
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}, {guid:String(guid), amount:-2000}]);
const cancel = engine(game, {guid, receiver:ec.request, idPrefix:'cancel', during:(_c, entry) => {entry.player.alive = false;}});
cancel.run(['.d','2000']); assert.equal(cancel.saved[0].decision, 'cancel');
const restart = engine(game, {guid:undefined, receiver:ec.request, recovered:journalEntries(cancel)});
restart.c.bank_sender().next(); assert.equal(restart.saved.length, 0);
assert.equal(cancel.player.score, 3000); assert.equal(ec.ledger().length, 2);
assert.equal(ec.requests().find(row => row.request_id === 'cancel-1').state, 'cancelled');
assert(ec.requests().every(row => row.guid === String(guid)));
} finally { ec.close(); }
}
});
}
console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`);
console.log(realEc ? 'PASS actual EC bank service and SQLite integration.' : 'Actual EC integration skipped; pass --ec-root /path/to/electric-cherry to include it.');
+269
View File
@@ -0,0 +1,269 @@
// Execute selected core HUD functions with engine-value shims.
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
let checks = 0;
function check(name, fn) { fn(); checks++; console.log(name); }
function body(source, name) {
const match = new RegExp(`^${name}\\(([^\\n]*)\\)\\s*\\n\\{`, 'm').exec(source);
assert(match, name);
let at = match.index + match[0].length, end = at, depth = 1;
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
return [match[1], source.slice(at, end - 1)];
}
function install(context, source, names) {
for (const name of names) {
const [args, original] = body(source, name);
let code = original.replace(/\/\/[^\n]*/g, '');
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+|--)/g)].map(match => match[1]))];
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
.replace(/maps\\mp\\zombies\\_zm_utility::get_current_zombie_count/g, 'get_enemy_count')
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
.replace(/self (\w+)\(/g, 'self.$1(')
.replace(/(\w+) (SetText|SetTextUnlimited|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
.replace(/level waittill\(([^;]*)\);/g, 'yield { event: $1 };')
.replace(/\.size\b/g, '.length')
.replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]')
.replace(/\bwait\s+([^;]+);/g, 'yield $1;');
vm.runInContext(`function${code.includes('yield ') ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context);
}
}
function engine(game, source, afk, hidden = false) {
const queued = [], elems = [], actions = [];
let now = 123456;
const self = { active: true };
if (hidden) self.tws_hud_hidden = true;
for (const name of ['enableInvulnerability', 'disableInvulnerability', 'freezeControls',
'disableWeapons', 'disableOffhandWeapons', 'enableWeapons', 'enableOffhandWeapons', 'iPrintLnBold']) {
self[name] = (...args) => actions.push([name, ...args]);
}
function element(args, alpha = 1) {
const result = { args, alpha, writes: [],
SetText(value) { this.text = value; this.writes.push(['text', value]); },
SetTextUnlimited(value) { this.text = value; this.writes.push(['unlimitedText', value]); },
SetValue(value) { this.value = value; this.writes.push(['value', value]); },
SetShader(...args) { this.shader = args; },
SetTimerUp(value) { this.timer = value; this.writes.push(['timer', value]); },
};
elems.push(result);
return result;
}
function register(player, elem) {
(player.tws_core_hud_elems ??= []).push(elem);
common.set_core_alpha(player, elem, elem.alpha);
return elem;
}
const common = {
make_core_overlay_text: (...args) => register(args[0], element(args)),
make_core_overlay_shader: (...args) => register(args[0], element(args, args[6])),
make_overlay_width: width => Math.trunc(Math.max(1, width)),
set_core_alpha(player, elem, alpha) {
if (!elem) return;
elem.tws_core_alpha = alpha;
elem.alpha = player.tws_hud_hidden ? 0 : alpha;
},
toggle_core_hud() {
self.tws_hud_hidden = !self.tws_hud_hidden;
for (const elem of self.tws_core_hud_elems ?? []) common.set_core_alpha(self, elem, elem.tws_core_alpha);
},
player_active: player => player.active,
format_mmss: ms => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`,
};
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2, zombie_team: 'axis',
hud_overlay_timer_x: 6, hud_overlay_match_y: 8, hud_overlay_round_y: 30,
hud_overlay_brand_x: 320, hud_overlay_brand_y: 8,
hud_overlay_count_x: 300, hud_overlay_count_y: 440,
hud_overlay_count_label_x: 306, hud_overlay_count_label_y: 446,
hud_overlay_bar_x: 242, hud_overlay_bar_y: 466,
hud_overlay_bar_width: 156, hud_overlay_bar_height: 4,
hud_overlay_health_y: 460, hud_overlay_health_x: 414, hud_overlay_slash_x: 418,
hud_overlay_maxhealth_x: 445, hud_overlay_hp_x: 449,
hud_overlay_afk_x: 320, hud_overlay_afk_y: 421,
hud_c_count: 'white', hud_c_dim: 'dim', hud_c_afk: 'yellow',
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
const context = vm.createContext({ self, level, common, queued, getTime: () => now,
get_enemy_count: () => level.fixture_enemies ?? 0,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
};
}
for (const game of ['t5', 't6']) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_hud.gsc`, root), 'utf8');
const afk = readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`, root), 'utf8');
const make = hidden => engine(game, source, afk, hidden);
check(`${game}: native .hud chat and shared lifecycle retain engine-specific clock seeds`, () => {
assert(source.includes('register_help_command(".hud", "Toggle core HUD")'));
assert(source.includes('register_connect_handler(::hud_connect)'));
assert(source.includes('register_spawn_handler(::hud_start)'));
assert(!source.includes('waittill("connected"'));
const chat = body(source, 'hud_chat_listener')[1];
assert(chat.includes('level waittill("say", message, player)'));
assert(chat.includes('toLower(message) == ".hud"'));
assert(chat.includes('player thread hud_toggle()'), 'disconnect scope stays in the player worker');
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
assert(!source.includes('tws_hud_hidden ='), 'spawn/construction must not reset the preference');
for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind', 'hud_zombie_counter'])
assert(body(source, name)[1].includes('level endon("hud_game_over")'));
const bind = body(source, 'match_timer_bind')[1];
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
assert(afk.includes('NewClientHudElem(self)'), 'protection countdown is an independent critical HUD');
assert(!afk.includes('set_core_alpha'), 'hiding the optional HUD must not hide the protection deadline');
});
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
assert.equal(e.elems.length, 10);
assert.equal(e.self.tws_core_hud_elems.length, 10);
e.context.hud_connect(); assert.equal(e.elems.length, 10);
assert.equal(e.queued.length, 1); assert.equal(e.queued[0][0], 'common.destroy_on_disconnect');
assert.equal(e.queued[0][1].length, e.elems.length);
const textAt = (text, x, y, align, scale, colour) => {
const elem = e.elems.find(item => item.text === text);
assert(elem, text); assert.deepEqual(elem.args.slice(1), [x, y, align, scale, colour]);
};
assert.deepEqual(e.self.tws_hud_match.args.slice(1), [6, 8, 'left', 1.4, 'white']);
assert.deepEqual(e.self.tws_hud_round.args.slice(1), [6, 30, 'left', 1.25, 'dim']);
textAt('TWS ZOMBIES | discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
textAt('-- ZOMBIES', 320, 440, 'center', 1.25, 'white');
assert(!e.elems.some(elem => elem.text === 'ZOMBIES'), 'count and label share one alignment');
textAt('HP', 449, 460, 'left', 1, 'white');
for (const [key, x, align] of [['health', 414, 'right'], ['slash', 418, 'left'], ['maxhealth', 445, 'right']]) {
const elem = e.self[`tws_hud_${key}`];
assert.deepEqual(elem.args.slice(1), [x, 460, align, 1, 'white']);
assert.equal(elem.alpha, 0); assert.equal(elem.tws_core_alpha, 0);
}
assert.equal(e.self.tws_hud_hp.alpha, 0);
const shaders = e.elems.filter(elem => elem.args.length === 8);
assert.equal(shaders.length, 2, 'only the health track and fill allocate shaders');
for (const elem of shaders) assert.deepEqual(elem.args.slice(1, 5), [242, 466, 156, 4]);
assert.equal(shaders[0].alpha, .8); assert.equal(shaders[1].alpha, 0);
assert(!e.elems.some(elem => elem.text === 'MATCH' || elem.text === 'ROUND'));
});
check(`${game}: toggling before first spawn preserves hidden state and binds workers once`, () => {
const e = make(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_hidden, true);
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.queued.length, 1, 'toggle only queues disconnect cleanup');
e.context.hud_start(); e.context.hud_start();
assert.equal(e.elems.length, 10); assert.equal(e.self.tws_hud_hidden, true);
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
e.context.hud_timer_row();
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
const threads = e.queued.length;
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 10);
});
check(`${game}: complete zombie text stays centered through digit changes and hidden updates without config-string churn`, () => {
const e = make();
// Center on the configured track, including non-default and fractional midpoints.
e.level.hud_overlay_bar_x = 210;
e.level.hud_overlay_bar_width = 155;
e.context.hud_connect();
const count = e.self.tws_hud_count;
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
const run = e.context.hud_zombie_counter();
for (const value of [0, 6, 9, 10, 99, 100, 999, 1000, 6]) {
e.level.fixture_enemies = value;
run.next();
assert.equal(count.text, `${value} ZOMBIES`);
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
const writes = count.writes.length;
run.next(); assert.equal(count.writes.length, writes, 'unchanged counts do not rewrite text');
}
e.level.zombie_total = 94; run.next();
assert.equal(count.text, '100 ZOMBIES', 'remaining spawn count remains included');
e.context.hud_toggle();
e.level.zombie_total = -4; run.next();
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 0);
e.context.hud_toggle();
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 1);
assert.equal(count.writes.filter(([kind]) => kind === 'text').length, 1,
'only the fixed placeholder uses a normal config string');
assert(count.writes.slice(1).every(([kind]) => kind === 'unlimitedText'));
assert.equal(e.elems.length, 10, 'updates and toggles never allocate replacement elements');
});
check(`${game}: health numbers appear only while damaged, follow the bar colour, and avoid config-string churn`, () => {
const e = make(); e.context.hud_connect(); const { self } = e;
const numbers = [self.tws_hud_health, self.tws_hud_slash, self.tws_hud_maxhealth, self.tws_hud_hp];
const run = e.context.hud_health_display(); run.next();
assert(numbers.every(elem => elem.alpha === 0), 'undefined health does not flash a damage readout');
assert.equal(self.health, undefined); assert.equal(self.maxhealth, undefined);
self.health = 100; run.next();
assert.equal(self.tws_hud_health.value, 100); assert.equal(self.tws_hud_maxhealth.value, 100);
assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 156);
const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes);
self.health = 35; run.next();
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'yellow'));
assert.equal(self.tws_hud_health.value, 35); assert.equal(self.tws_hud_bar.shader[1], 54);
self.maxhealth = 250; self.health = 50; run.next();
assert.equal(self.tws_hud_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 31);
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'red'));
assert.equal(self.tws_hud_bar.color, 'red');
self.health = 0; run.next(); assert.equal(self.tws_hud_bar.alpha, 0); assert.equal(self.tws_hud_health.value, 0);
assert(numbers.every(elem => elem.alpha === 1));
self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green');
assert(numbers.every(elem => elem.alpha === 0 && elem.tws_core_alpha === 0));
self.health = 500; run.next(); assert.equal(self.tws_hud_bar.shader[1], 156);
assert(numbers.every(elem => elem.alpha === 0), 'overheal must not show the damage readout');
self.maxhealth = 0; self.health = 100; run.next();
assert.equal(self.maxhealth, 0, 'invalid maximum is display-only fallback');
assert.equal(self.tws_hud_maxhealth.value, 100); assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1);
});
check(`${game}: hidden health updates remain private and restore the current bar state`, () => {
const e = make(true), other = make();
e.context.hud_connect(); other.context.hud_connect();
e.self.health = 100; const run = e.context.hud_health_display(); run.next();
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.self.tws_hud_bar.tws_core_alpha, 1);
e.self.health = 0; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.value, 0); assert.equal(e.self.tws_hud_bar.alpha, 0);
assert.equal(e.elems[0].alpha, 1); assert.equal(e.elems.at(-2).alpha, .8);
e.context.hud_toggle(); e.self.health = 50; run.next();
assert.equal(e.self.tws_hud_bar.alpha, 0); assert.equal(e.self.tws_hud_bar.shader[1], 78);
e.context.hud_toggle();
assert.equal(e.self.tws_hud_bar.alpha, 1); assert.equal(e.self.tws_hud_health.value, 50);
assert.equal(other.self.tws_hud_hidden, undefined); assert.equal(other.self.tws_hud_health.alpha, 0);
assert.equal(e.elems.length, 10); assert.equal(e.queued.length, 1);
e.context.hud_toggle(); e.self.health = 100; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.alpha, 0, 'healing while hidden conceals the readout when shown');
assert.equal(e.self.tws_hud_hp.alpha, 0); assert.equal(e.self.tws_hud_bar.alpha, 1);
});
check(`${game}: toggling frozen clocks does not restart or reseed them`, () => {
const e = make(); e.context.hud_start(); e.context.hud_timer_row();
const match = e.self.tws_hud_match, round = e.self.tws_hud_round;
const freeze = e.context.freeze_timers_on_game_over(match, round);
assert.equal(freeze.next().value.event, 'hud_game_over');
e.context.hud_toggle(); e.setTime(248456); e.level.tws_ended = true; freeze.next();
assert.equal(match.text, '2:05'); assert.equal(round.text, '2:05');
const before = JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]);
e.setTime(999999); e.context.hud_toggle(); e.context.hud_start();
assert.equal(match.alpha, 1); assert.equal(round.alpha, 1);
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);
});
}
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
+702
View File
@@ -0,0 +1,702 @@
// Execute the actual common GSC service functions with HUD/event shims.
// These fixtures do not emulate the GSC VM, fonts or HUD pool limits. Geometry
// checks model the 640x480 fullscreen placement transform, not a live renderer.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import vm from 'node:vm';
import './check_core_hud.mjs';
const root = new URL('../', import.meta.url);
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via', 'destroy_on_disconnect']);
function translate(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm;
const result = [];
for (let match; (match = headers.exec(protectedSource));) {
const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'show_record_reminder', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
assert(end < protectedSource.length, `unclosed ${name}`);
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '')
.replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]')
.replace(/\.size\b/g, '.length')
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
.replace(/self thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);')
.replace(/(self|level) waittill\(([^;]+)\);/g, 'yield { entity: $1, event: $2 };')
.replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);')
.replace(/self (\w+)\(/g, '$1(')
.replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };')
.replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetTextUnlimited|SetValue|SetShader|Destroy)\(/g, '$1.$2(')
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return result.join('\n');
}
const commands = [
['.help', 'List available commands'], ['.afk', 'Pause with protection'],
['.back', 'Return from AFK'], ['.join', 'Join mid-round (newcomers only)'],
['.leaders', 'Show the map record again'], ['.b', 'Show bank balance'],
['.d <amount|all>', 'Deposit points'], ['.w <amount|all>', 'Withdraw points'],
['.x', 'Extra command'],
];
function engine(game, count = 8, withHud = false) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
let now = 0, running, peakLive = 0;
const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = [];
const players = new Set([self]);
function resume(task) {
const prior = running;
running = task;
const result = task.generator.next();
task.done = result.done;
task.wait = result.value;
task.at = result.value?.milliseconds !== undefined ? now + result.value.milliseconds : Infinity;
running = prior;
}
function startThread(fn, args) {
const task = { generator: fn(...args), ends: [] };
tasks.push(task);
resume(task);
}
function emit(entity, event) {
const listeners = tasks.filter(t => !t.done);
for (const task of listeners) if (task.ends.some(([e, n]) => e === entity && n === event)) task.done = true;
for (const task of listeners) if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
}
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;
}
function newElement(owner) {
assert(players.has(owner), 'every HUD element must be owned by a known client');
const element = {
owner,
alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0,
SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
SetTextUnlimited(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; },
SetShader(shader, width, height) {
assert(!this.destroyCalls);
assert(Number.isInteger(width) && Number.isInteger(height), 'SetShader requires integer dimensions');
Object.assign(this, { shader, width, height });
},
Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; },
};
elements.push(element);
peakLive = Math.max(peakLive, elements.filter(elem => !elem.destroyCalls).length);
return element;
}
const c = vm.createContext({
self, level, NewClientHudElem: newElement, getTime: () => now,
spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null && !v.destroyCalls,
max: Math.max, int: Math.trunc,
getSubStr: (text, begin, end) => { assert(end <= text.length); return text.slice(begin, end); },
endon: (entity, event) => running?.ends.push([entity, event]), startThread, emit,
});
vm.runInContext(translate(source), c);
c.common_config();
for (const [command, description] of commands.slice(0, count).reverse()) c.register_help_command(command, description);
if (withHud) c.register_help_command('.hud', 'Toggle core HUD');
for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]);
return { game, c, self, level, elements, tasks, advance, source,
createCore() {
assert.equal(game, 't5', 'combined fixture currently covers T5 construction');
for (const module of ['hud', 'afk'])
vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c);
c.hud_connect(); c.afk_hud_create();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
get live() { return elements.filter(e => !e.destroyCalls); },
get peakLive() { return peakLive; },
get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); },
get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); },
get helpCommands() {
return Array.from(self.tws_service.command_rows).filter(row => row.alpha > 0).map(row => row.text);
},
get helpDescriptions() {
return Array.from(self.tws_service.description_rows).filter(row => row.alpha > 0).map(row => row.text);
},
};
}
const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines });
function assertCounter(e, page, pages) {
const counter = e.state.page_counter;
if (pages === 1) assert.equal(counter, undefined, 'single-page services do not allocate a counter');
else {
assert.equal(counter.alpha, 1);
assert.equal(counter.text, `${page} / ${pages}`);
}
}
function assertTextOnly(e) {
assert(e.state.elems.every(elem => elem.shader === undefined && elem.archived === false),
'service elements are private, non-archived text with no background shaders');
}
for (const game of ['t5', 't6']) {
check(`${game} installed HUD joins all nine commands in one view`, () => {
const e = engine(game, 8, true);
e.help();
assert.deepEqual(e.helpCommands,
['.help', '.hud', '.afk', '.back', '.join', '.leaders', '.b', '.d <amount|all>', '.w <amount|all>']);
assert.deepEqual(e.helpDescriptions,
['Show commands', 'Toggle core HUD', 'Protect & freeze', 'Return to play', 'Join as newcomer',
'View records', 'Bank balance', 'Deposit', 'Withdraw']);
assert.equal(e.state.help.pages, 1);
assertCounter(e, 1, 1);
assert.equal(e.live.length, 19);
assertTextOnly(e);
e.advance(12000);
assert.equal(e.live.length, 0);
const withoutHud = engine(game);
withoutHud.help();
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
});
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
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 bar = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0, 11);
const label = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
label.SetText('48:16');
const other = e.addPlayer();
const otherLabel = e.c.make_core_overlay_text(other, 6, 8, 'left', 1.4, [1, 1, 1]);
e.record(snapshot()); e.help();
const serviceAlpha = Array.from(e.state.elems, elem => elem.alpha);
const help = e.state.help, record = e.state.record;
e.c.toggle_core_hud();
assert.equal(e.self.tws_hud_hidden, true);
assert([track, bar, label].every(elem => elem.alpha === 0));
assert.equal(otherLabel.alpha, 1);
assert.equal(other.tws_hud_hidden, undefined);
e.c.set_core_alpha(e.self, bar, 1);
const afk = e.c.make_core_overlay_text(e.self, 320, 421, 'center', 1.0, [1, 1, 1]);
assert.equal(afk.alpha, 0, 'late core elements inherit the hidden state');
e.c.set_core_alpha(e.self, afk, 0);
const count = e.elements.length;
e.c.toggle_core_hud();
assert.deepEqual([track.alpha, bar.alpha, label.alpha, afk.alpha], [0.8, 1, 1, 0]);
assert.equal(label.text, '48:16', 'toggle does not rewrite displayed data');
assert.equal(e.elements.length, count);
assert.deepEqual(Array.from(e.state.elems, elem => elem.alpha), serviceAlpha);
assert.equal(e.state.help, help); assert.equal(e.state.record, record);
e.c.toggle_core_hud();
e.c.set_core_alpha(e.self, bar, 0);
e.c.set_core_alpha(e.self, afk, 1);
assert.equal(afk.alpha, 0);
e.c.toggle_core_hud();
assert.equal(bar.alpha, 0);
assert.equal(afk.alpha, 1);
label.Destroy();
e.c.toggle_core_hud();
assert(!e.self.tws_core_hud_elems.includes(label), 'toggle prunes destroyed elements');
assert.equal(label.destroyCalls, 1, 'registry never owns element destruction');
});
check(`${game} hiding before construction survives late core creation and prunes dead registrations`, () => {
const e = engine(game);
e.c.toggle_core_hud();
const old = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(old.alpha, 0);
old.Destroy();
const fresh = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(fresh.alpha, 0);
assert.equal(e.self.tws_core_hud_elems.length, 1);
e.c.toggle_core_hud();
assert.equal(fresh.alpha, 1);
assert.equal(e.level.match_start_time, undefined);
assert.equal(e.level.round_start_time, undefined);
const reconnected = e.addPlayer();
const newLabel = e.c.make_core_overlay_text(reconnected, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(newLabel.alpha, 1, 'a new player defaults to visible');
});
for (const count of [0, 1, 4, 8, 9]) check(`${game} ${count} registry entries order and dismiss`, () => {
const e = engine(game, count);
assert.equal(e.elements.length, 0, 'service is lazy and independent of core');
e.help();
const s = e.state;
assert.equal(s.help.pages, 1);
assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0]));
assertCounter(e, 1, 1);
assert.deepEqual(e.helpCommands, Array.from(s.commands, entry => e.c.help_command_label(entry.command)));
assert.deepEqual(e.helpDescriptions, Array.from(s.commands, entry => e.c.help_description(entry)));
assert.equal(e.live.length, count * 2 + 1);
assertTextOnly(e);
e.advance(12000);
assert.equal(s.help, undefined);
assert.equal(e.live.length, 0, 'idle must release even hidden elements');
});
check(`${game} overflow help pages reuse rows and repeated help resets expiry`, () => {
const e = engine(game, 9, true);
e.help();
assert.equal(e.state.help.pages, 2);
assert.equal(e.live.length, 20);
assertCounter(e, 1, 2);
const rows = [...e.state.command_rows, ...e.state.description_rows];
const count = e.elements.length, texts = e.textCalls;
e.help();
assert.equal(e.textCalls, texts, 'repeating current page does not reassign text');
e.advance(12000);
assert.equal(e.state.help.page, 1);
assert.deepEqual(e.helpCommands, ['.x']);
assertCounter(e, 2, 2);
assert.deepEqual(Array.from(e.state.command_rows, row => row.alpha), [1, 0, 0, 0, 0, 0, 0, 0, 0]);
e.help();
assert.equal(e.state.help.page, 0);
e.advance(11900);
e.help();
e.advance(100);
assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement');
assert.deepEqual([...e.state.command_rows, ...e.state.description_rows], rows);
e.advance(11900);
assertCounter(e, 2, 2);
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.elements.length, count);
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(15000);
assert.equal(e.state.record.page, 1);
const remaining = e.state.record.remaining;
e.help();
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.record.remaining, remaining);
assert.equal(e.state.rows[0].text, 'Four');
assert.equal(e.live.length, 5);
const resumedTexts = e.textCalls;
e.c.service_render(e.state);
assert.equal(e.textCalls, resumedTexts, 'subsequent ticks must reuse the rendered view');
e.advance(remaining);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} latest record replaces pending one without disturbing help`, () => {
const e = engine(game);
e.help();
e.advance(3000);
const help = e.state.help;
e.record(snapshot(['Old']));
e.record(snapshot(['Newest']));
assert.equal(e.state.help, help);
assert.equal(e.state.record.page, 0);
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.rows.length, 0, 'pending records allocate no holder elements');
e.advance(9000);
assert.equal(e.state.rows[0].text, 'Newest');
assert.equal(e.state.record.remaining, 12000);
assertCounter(e, 1, 1);
e.advance(11900);
e.record(snapshot(['Newest']));
const texts = e.textCalls;
e.advance(100);
assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts);
});
check(`${game} automatic reminders use two elements and expire after six seconds`, () => {
const e = engine(game);
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true);
const request = e.state.record;
assert.equal(request.kind, 'reminder');
assert.equal(request.pages, 1);
assert.equal(request.remaining, 6000);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.rows.length, 1);
assert.equal(e.state.rows[0].text, '5 rounds to tie');
assert.equal(e.live.length, 2);
assert.equal(e.visible.length, 2);
assertCounter(e, 1, 1);
assertTextOnly(e);
const calls = e.textCalls, allocated = e.elements.length;
e.advance(5900);
assert.equal(e.state.record, request);
assert.equal(request.remaining, 100);
assert.equal(e.reminder({ round: 83 }, 'Newer target'), false);
assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint');
assert.equal(request.remaining, 100);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, allocated);
e.advance(100);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true);
assert.equal(e.state.record.remaining, 6000);
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 2);
});
check(`${game} automatic reminders never replace or queue behind help and full records`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(1000);
const record = e.state.record;
const generation = e.state.generation, allocations = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000);
assert.equal(e.state.generation, generation);
assert.equal(e.elements.length, allocations);
e.help();
e.advance(1000);
const help = e.state.help, withHelp = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.help, help);
assert.equal(e.state.record, record);
assert.equal(e.elements.length, withHelp);
e.advance(11000);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time');
e.advance(23000);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0, 'skipped hints do not appear later');
e.help();
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, undefined, 'help alone must not queue a hint');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
const reminderElements = [...e.live];
e.advance(2000);
e.help();
assert.equal(e.state.record, undefined);
assert(reminderElements.every(elem => elem.destroyCalls === 1));
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help');
e.reminder({ round: 82 }, '4 rounds to tie');
const replaced = [...e.live];
e.advance(5900);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
assert.equal(e.state.record.kind, 'record');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.record.pages, 2);
assert.equal(e.live.length, 5);
assert(replaced.every(elem => elem.destroyCalls === 1));
e.advance(100);
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
assert.equal(e.state.record.remaining, 11900);
e.advance(11900);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.rows[0].text, 'Four');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
const e = engine(game);
for (const bad of [undefined, {}, { round: 0 }, { round: -1 }])
assert.equal(e.reminder(bad, '5 rounds to tie'), false);
for (const message of [undefined, ''])
assert.equal(e.reminder({ round: 82 }, message), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect'])
check(`${game} ${event} cleans a reminder and blocks reopening`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
e.advance(1000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert.equal(e.state.closed, true);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false);
e.advance(12000);
assert.equal(e.elements.length, 2);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game);
e.record(undefined);
assert.equal(e.state.record_title.alpha, 1);
assert.equal(e.state.record_title.text, 'Map record unavailable');
assert.equal(e.live.length, 4);
assertCounter(e, 1, 1);
const first = [...e.live];
e.record({ round: 0, lines: [] });
assert.equal(e.state.record_title.text, 'No recorded high round yet');
assert.deepEqual(e.live, first);
assertCounter(e, 1, 1);
e.record(snapshot(['A', 'B', 'C', 'D']));
assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first');
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.live.length, 5);
const paged = [...e.live];
assertCounter(e, 1, 2);
e.advance(12000);
assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]);
assert.equal(e.state.rows[0].text, 'D');
assertCounter(e, 2, 2);
assert.deepEqual(e.live, paged, 'page changes reuse the same allocation');
e.record(snapshot(['Single page']));
assert(paged.every(elem => elem.destroyCalls === 1));
assertCounter(e, 1, 1);
assert.equal(e.state.rows[0].text, 'Single page');
assert.equal(e.live.length, 4);
assert.equal(e.peakLive, 5, 'new allocations never overlap the released view');
assertTextOnly(e);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => {
const e = engine(game);
e.record(snapshot()); e.help(); e.advance(2000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert(e.state.closed);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record, undefined);
assert(e.elements.every(elem => elem.destroyCalls === 1));
const count = e.elements.length;
e.advance(60000);
if (event !== 'disconnect') {
e.help(); e.record(snapshot()); e.event('disconnect');
assert.equal(e.elements.length, count, 'game end blocks new service requests');
}
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} ending before first service blocks lazy creation`, () => {
const e = engine(game);
e.end('end_game'); e.help(); e.record(snapshot());
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
const e = engine(game);
e.record(snapshot());
const calls = e.textCalls, count = e.elements.length;
e.advance(11000);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, count);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.commands[3].command, '.join');
assert.match(e.c.help_description(e.state.commands[3]), /newcomer/);
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
assertTextOnly(e);
});
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
const e = engine(game, 8, true);
e.record(snapshot(['One', 'Two', 'Three']));
const s = e.state;
assert.deepEqual([s.record_title.x, s.record_title.y, s.record_title.alignX], [633, 36, 'right']);
assert.deepEqual(Array.from(s.rows, row => [row.x, row.y, row.alignX]),
[[633, 60, 'right'], [633, 78, 'right'], [633, 96, 'right']]);
const records = [...s.elems];
e.help();
assert.deepEqual([s.help_title.x, s.help_title.y], [432, 54]);
for (const [i, row] of s.command_rows.entries()) {
assert.equal(row.x, 432); assert.equal(row.y, 80 + i * 24);
assert.equal(s.description_rows[i].x, 538);
assert.equal(s.description_rows[i].y, row.y);
}
for (const [width, height] of [[1920, 1080], [1280, 720], [1600, 1200], [3440, 1440]]) {
const project = elem => ({ x: elem.x * width / 640, y: elem.y * height / 480 });
for (const elem of [...records, ...s.elems]) {
assert.equal(elem.horzAlign, 'fullscreen');
assert.equal(elem.vertAlign, 'fullscreen');
const point = project(elem);
assert(point.x > width / 2 && point.x < width);
assert(point.y < height * 0.6, 'leave scores and ammo clear');
}
assert.equal(width - project(records[0]).x, 7 * width / 640);
if (width === 1920) assert(project(records[0]).y > 27, 'clear the reference Plutonium tag');
}
});
check(`${game} new and legacy shader helpers retain integer dimensions`, () => {
const e = engine(game);
// Native GSC max returns VAR_FLOAT even for integral values. Boxed
// numbers retain that distinction here; the explicit int() unboxes it.
e.c.max = (...args) => new Number(Math.max(...args));
const legacy = e.c.make_panel_shader(e.self, 0, 0, 220, 100,
e.level.hud_c_panel, e.level.hud_panel_alpha, 0);
assert.equal(legacy.width, 165);
assert.equal(legacy.alpha, 0.72);
assert.deepEqual(Array.from(legacy.color), [0.07, 0.10, 0.08]);
const overlay = e.c.make_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0.8, 10);
assert.deepEqual([overlay.x, overlay.y, overlay.width, overlay.height], [242, 466, 156, 4]);
for (const [design, panelWidth, overlayWidth] of [[200, 150, 200], [40, 30, 40], [65.9, 49, 65], [0, 1, 1], [0.1, 1, 1]]) {
assert.equal(e.c.make_panel_width(design), panelWidth);
assert.equal(e.c.make_overlay_width(design), overlayWidth);
legacy.SetShader('white', e.c.make_panel_width(design), 4);
overlay.SetShader('white', e.c.make_overlay_width(design), 4);
}
assert.throws(() => overlay.SetShader('white', e.c.max(1, 156), 4), /requires integer dimensions/);
const oldCore = e.c.make_core_text(e.self, 10, 10, 'left', 1.1, [1, 1, 1]);
const oldShader = e.c.make_core_shader(e.self, 10, 80, 200, 4, [1, 1, 1], 0.5, 10);
e.c.toggle_core_hud();
assert.equal(oldCore.alpha, 0); assert.equal(oldShader.alpha, 0);
e.c.toggle_core_hud();
assert.equal(oldCore.alpha, 1); assert.equal(oldShader.alpha, 0.5);
});
check(`${game} explicit service rows fit nominal font bounds without newline dependence`, () => {
const e = engine(game, 8, true);
e.record(snapshot(['One', 'Two', 'Three']));
const height = elem => Math.trunc(12 * elem.fontScale);
let s = e.state;
assert(s.record_title.y + height(s.record_title) <= s.rows[0].y);
for (let i = 1; i < s.rows.length; i++)
assert(s.rows[i - 1].y + height(s.rows[i - 1]) <= s.rows[i].y);
e.help();
const rows = [...s.command_rows, ...s.description_rows];
assert.equal(new Set(rows).size, 18);
assert.equal(new Set(s.command_rows.map(row => row.y)).size, 9);
assert(rows.every(row => row.alpha === 1 && !/[\r\n]/.test(row.text)));
for (let i = 0; i < s.command_rows.length; i++) {
const command = s.command_rows[i], description = s.description_rows[i];
assert.equal(command.y, description.y);
if (i > 0) assert(s.command_rows[i - 1].y + height(s.command_rows[i - 1]) < command.y);
assert(command.y + height(command) < 320);
}
assert(e.elements.filter(elem => elem.fontScale !== undefined).every(elem => elem.fontScale >= 1.0));
assert.equal(e.c.make_text_elem(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 1.6, [1, 1, 1]).fontScale, 1.6);
});
check(`${game} help rebuilds after records, caching each new view without allocation overlap`, () => {
const e = engine(game, 8, true);
e.help();
const rows = [...e.state.command_rows, ...e.state.description_rows];
const calls = e.textCalls;
e.help(); e.advance(11000);
assert.equal(e.textCalls, calls);
e.record(snapshot()); e.advance(1000);
assert(rows.every(row => row.destroyCalls === 1));
assert.equal(e.live.length, 4);
e.help();
assert.equal(e.live.length, 19);
assert(e.state.command_rows.every(row => !rows.includes(row)));
assert.equal(e.peakLive, 19);
const recreatedCalls = e.textCalls;
e.help();
assert.equal(e.textCalls, recreatedCalls);
e.event('disconnect');
assert(e.elements.every(row => row.destroyCalls === 1));
});
for (const event of ['disconnect', 'end_game', 'game_ended', 'intermission'])
check(`${game} ${event} after idle cannot double-destroy or recreate a released view`, () => {
const e = engine(game);
e.record(snapshot()); e.advance(12000);
assert.equal(e.live.length, 0);
const allocated = e.elements.length;
if (event === 'disconnect') e.event(event); else e.end(event);
assert(e.state.closed);
e.advance(60000);
if (event !== 'disconnect') { e.help(); e.record(snapshot()); e.event('disconnect'); }
assert.equal(e.elements.length, allocated);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} script entrypoints and common references resolve`, () => {
const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1]));
const folder = new URL(`${game}/scripts/`, root);
const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc'));
assert.equal(scripts.length, 9);
let connected = 0;
for (const file of scripts) {
const source = readFileSync(new URL(file, folder), 'utf8');
assert.match(source, /^init\(\)/m);
if (game === 't6') {
assert.match(source, /^main\(\)/m);
assert(!/scripts\\sp\\|maps\\_utility|#"zombiemode"/.test(source));
}
connected += (source.match(/waittill\("connected"/g) ?? []).length;
for (const ref of source.matchAll(/tws_t[56]_common::(\w+)/g)) assert(functions.has(ref[1]), `${file} missing ${ref[1]}`);
}
assert.equal(connected, 1);
});
}
function eSource(game) { return readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); }
check('T5 service transitions preserve actual core element identities, styles, data and visibility', () => {
const e = engine('t5', 8, true);
e.createCore();
const core = Array.from(e.self.tws_core_hud_elems);
assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES');
e.self.tws_hud_health.SetValue(100);
e.self.tws_hud_maxhealth.SetValue(100);
e.c.set_core_alpha(e.self, e.self.tws_hud_bar, 1);
const state = () => core.map(elem => ({ alpha: elem.alpha, text: elem.text, value: elem.value,
x: elem.x, y: elem.y, width: elem.width, height: elem.height, shader: elem.shader,
color: elem.color, fontScale: elem.fontScale, alignX: elem.alignX,
textCalls: elem.textCalls, valueCalls: elem.valueCalls, destroyCalls: elem.destroyCalls }));
const initial = state();
e.advance(3000);
e.record(snapshot(['Jeff', 'Lokester', 'Moniialx']));
const controller = e.state, tasks = e.tasks.length;
assert.equal(e.live.length, 16);
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.state, controller);
assert.equal(e.state.closed, false, 'idle releases the view, not its controller');
assert.equal(e.state.elems.length, 0);
assert.equal(e.live.length, 12);
assert.deepEqual(state(), initial);
for (let i = 0; i < 4; i++) {
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
e.c.toggle_core_hud();
assert(core.every(elem => elem.alpha === 0));
const allocations = e.elements.length;
e.record(snapshot(['Latest']));
assert.equal(e.elements.length, allocations, 'a pending record must not allocate another view');
e.advance(12000);
assert.equal(e.state.rows[0].text, 'Latest');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.live.length, 16);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.live.length, 12);
assert.equal(e.state.elems.length, 0);
assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers');
assert.deepEqual(state(), initial);
}
assert.equal(e.peakLive, 31);
assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core);
e.event('disconnect');
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check('paired service requests, rendering and lifecycle remain identical', () => {
const sections = game => {
const source = eSource(game);
const service = source.slice(source.indexOf('show_help()\n{'), source.indexOf('on_player_connect_dispatch()\n{'));
assert(service.length > 500, 'parity comparison must include actual service functions');
return service;
};
assert.deepEqual(sections('t5'), sections('t6'));
});
console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);
+227
View File
@@ -0,0 +1,227 @@
// Execute the actual T6 join functions with a deterministic event/timer shim.
// This checks script decisions, not GSC compilation or live engine behavior.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../t6/scripts/tws_t6_join.gsc', import.meta.url), 'utf8');
const functions = [];
for (const name of ['join_spawn', 'join_track_death', 'join_request']) {
const match = new RegExp(`^${name}\\(\\)\\s*\\n\\{`, 'm').exec(source);
// The original implementation has no death observer; allow regression runs.
if (!match && name === 'join_track_death') continue;
assert(match, `missing ${name}`);
let end = match.index + match[0].length, depth = 1;
const start = end;
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = source.slice(start, end - 1).replace(/\/\/[^\n]*/g, '');
body = body
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
.replace(/self thread (\w+)\(\);/g, 'startThread($1);')
.replace(/self waittill\(([^;]+)\);/g, 'yield { entity: self, event: $1 };')
.replace(/\bwait ([\d.]+);/g, 'yield { seconds: $1 };')
.replace(/self iPrintLnBold\(/g, 'messages.push(')
.replace(/self \[\[level.spawnplayer\]\]\(\);/g, 'spawnPlayer();')
.replace(/maps\\mp\\zombies\\_zm::spectator_respawn/g, 'stockRespawn')
.replace(/!join_has_active_player\(\)/g, '!activePlayer')
.replace(/\.size\b/g, '.length');
functions.push(`function* ${name}() { let points; ${body} }`);
}
function engine() {
let now = 0, current;
const tasks = [], messages = [];
const self = { sessionstate: 'playing', spectator_respawn: {} };
const level = { round_number: 3, spawnplayer: true };
let spawns = 0;
function resume(task) {
const previous = current;
current = task;
const result = task.generator.next();
task.done = result.done;
task.wait = result.value;
if (task.wait?.seconds !== undefined) task.at = now + task.wait.seconds;
else task.at = Infinity;
current = previous;
}
function startThread(fn) {
const task = { generator: fn(), ends: [] };
tasks.push(task);
resume(task);
}
function event(entity, event) {
const listeners = tasks.filter((task) => !task.done);
for (const task of listeners) {
if (task.ends.some(([e, name]) => e === entity && name === event)) task.done = true;
}
for (const task of listeners) {
if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
}
}
function advance(seconds) {
const until = now + seconds;
for (;;) {
const next = tasks.filter((task) => !task.done && task.at <= until)
.sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
resume(next);
}
now = until;
}
const c = vm.createContext({
self, level, messages, activePlayer: true, stockRespawn: () => {},
isDefined: (value) => value !== undefined && value !== null,
is_true: (value) => value === true || value === 1,
endon: (entity, event) => current.ends.push([entity, event]),
startThread,
getstructarray: () => [{}], randomInt: () => 0,
spawnPlayer: () => {
spawns++;
self.sessionstate = 'playing';
event(self, 'spawned_player');
},
});
vm.runInContext(functions.join('\n'), c);
return { self, level, c, messages, tasks, advance,
start: () => startThread(c.join_spawn),
request: () => startThread(c.join_request),
event: (name) => event(self, name),
end: (name) => event(level, name),
get spawns() { return spawns; },
};
}
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
function newcomer() {
const e = engine();
e.start(); // Common's once-per-connection callback, at spawned_player.
e.advance(0.05);
e.self.sessionstate = 'spectator'; // Stock delayed setup transition.
e.event('spawned_spectator');
e.advance(0.1);
return e;
}
check('Farm round 3 setup spawn leaves a newcomer eligible for exactly one join', () => {
const e = newcomer();
assert(!e.self.tws_join_has_played);
e.request();
assert.equal(e.spawns, 1);
assert.equal(e.self.tws_join_used, true);
e.request();
e.self.sessionstate = 'spectator';
e.event('killed_player');
e.level.round_number++;
e.request();
assert.equal(e.spawns, 1);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('setup transition blocks requests until settled', () => {
const e = engine();
e.start();
e.advance(0.05);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /waiting to enter/);
e.advance(0.1);
e.request();
assert.equal(e.spawns, 1);
});
check('a normal round-start spawn closes eligibility even after later death', () => {
const e = newcomer();
e.level.round_number++;
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('round-1 participants cannot join again after death', () => {
const e = engine();
e.level.round_number = 1;
e.start();
e.advance(0.11);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.equal(e.self.tws_join_has_played, true);
});
for (const initial of [true, false]) {
check(`death during ${initial ? 'initial' : 'later'} spawn settling consumes eligibility`, () => {
const e = initial ? engine() : newcomer();
if (initial) e.start();
else {
e.self.sessionstate = 'playing';
e.event('spawned_player');
}
e.advance(0.02);
e.event('killed_player');
e.self.sessionstate = 'spectator';
e.advance(0.11);
e.request();
assert.equal(e.self.tws_join_has_played, true);
assert.equal(e.spawns, 0);
});
}
check('repeated spectator setup events keep listening for an actual spawn', () => {
const e = newcomer();
e.event('spawned_player');
e.advance(0.11);
assert(!e.self.tws_join_has_played);
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
});
for (const name of ['disconnect', 'end_game', 'game_ended', 'intermission']) {
check(`${name} stops observers during setup and while waiting`, () => {
for (const setup of [true, false]) {
const e = setup ? engine() : newcomer();
if (setup) e.start();
if (name === 'disconnect') e.event(name);
else e.end(name);
e.advance(1);
e.event('killed_player');
assert(!e.self.tws_join_has_played);
assert(e.tasks.every((task) => task.done));
}
});
}
for (const [name, set] of [
['intermission', (e) => { e.level.intermission = true; }],
['game ended', (e) => { e.level.gameended = true; }],
['no active player', (e) => { e.c.activePlayer = false; }],
['missing round', (e) => { delete e.level.round_number; }],
['missing spawn callback', (e) => { delete e.level.spawnplayer; }],
]) {
check(`${name} still rejects joining`, () => {
const e = newcomer();
set(e);
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /not ready/);
});
}
check('reconnecting starts independent eligibility', () => {
const first = newcomer();
first.request();
first.event('disconnect');
const second = newcomer();
second.request();
assert.equal(second.spawns, 1);
});
console.log(`PASS ${checks} T6 join lifecycle/request checks (engine shims; live validation required)`);
+293 -9
View File
@@ -1,4 +1,4 @@
// Execute the actual data-only GSC functions with small engine-value shims. // Execute actual leaderboard GSC functions with small data, player and clock shims.
// This is a fixture check, not a GSC VM, linker or gameplay test. // 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_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy', 'lb_chat_listener', 'lb_request', 'lb_hud_disconnect']); 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) {
@@ -111,6 +186,193 @@ for (const game of ['t5', 't6']) {
c.level.tws_lb_cache = undefined; c.level.tws_lb_cache = undefined;
assert.equal(queries.length, 0); assert.equal(queries.length, 0);
}); });
check(`${game} common owns the record service window`, () => {
const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common';
assert(source.includes(`self ${commonPath}::show_record(snapshot);`));
assert(!source.includes('lb_show_hud'));
assert(!source.includes('lb_hud_'));
assert(!source.includes('tws_help_elems'));
assert(!source.includes('iPrintLnBold'));
assert(!source.includes('make_text_elem'));
assert(!source.includes('tws_t5_hud') && !source.includes('tws_t6_hud'));
});
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
const calls = [];
c.self = { notify: (event) => calls.push(['notify', event]) };
c.recordService = (snapshot) => calls.push(['record', snapshot]);
const request = all.get('lb_request')
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
.replace(/\bself notify\(/g, 'self.notify(')
.replace(/\bself scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(');
vm.runInContext(request, c);
for (const cached of [undefined, { saved_at: 1, round: 52, lines: ['Alice'] }]) {
c.level.tws_lb_cache = cached;
c.lb_request(false);
assert.deepEqual(calls, []);
c.lb_request(true);
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', undefined]]);
calls.length = 0;
}
const snapshot = { saved_at: 1000000, round: 0, lines: [] };
c.level.tws_lb_cache = snapshot;
c.lb_request(false);
assert.deepEqual(calls, [['record', snapshot]]);
calls.length = 0;
c.lb_request(true);
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
c.level.tws_lb_cache = undefined;
});
for (const [record, expected] of [
[50, [30, 35, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51]],
[53, [33, 38, 43, 45, 47, 48, 49, 50, 51, 52, 53, 54]],
]) check(`${game} record ${record} reminders accelerate relative to the target`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 1; round <= record + 10; round++) e.round(round);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
assert.equal(e.shown[0].message, '20 rounds away from matching it');
assert.equal(e.shown.at(-3).message, '1 round away from matching it');
assert.equal(e.shown.at(-2).message, 'Record round reached!');
assert.equal(e.shown.at(-1).message, 'Recorded high round passed!');
assert.equal(e.context.level.tws_lb_chase_finished, true);
assert.equal(e.queries.length, 0);
});
check(`${game} low records avoid round-one history and handle short approaches`, () => {
for (const record of [0, 1, 2, 6]) {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 0; round <= record + 4; round++) e.round(round);
const expected = record < 2 ? [] : Array.from({ length: record + 1 }, (_, i) => i + 1);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
}
});
check(`${game} absent, expired and empty caches skip reminders without fetching`, () => {
const e = roundRuntime(game);
e.readyPlayer();
for (const cached of [undefined, { round: 0, saved_at: e.now }, { round: 1, saved_at: e.now },
{ round: 50, saved_at: e.now - 900001 }]) {
e.context.level.tws_lb_cache = cached;
e.round(30); e.round(51);
assert.equal(e.context.level.tws_lb_chase_finished, undefined);
}
assert.equal(e.attempts.length, 0);
const stillUsable = e.cache(50, e.now - 900000);
e.round(30);
assert.equal(e.shown.length, 1);
assert.equal(e.shown[0].snapshot, stillUsable);
assert.equal(e.queries.length, 0);
});
check(`${game} the next round uses the latest cache without a mid-round announcement`, () => {
const e = roundRuntime(game);
e.readyPlayer();
const first = e.cache(50);
e.round(35);
const raised = e.cache(60);
assert.equal(e.shown.length, 1);
e.round(40);
const corrected = e.cache(55);
e.round(45);
assert.deepEqual(e.shown.map(notice => notice.snapshot), [first, raised, corrected]);
assert.deepEqual(e.shown.map(notice => notice.message), [
'15 rounds away from matching it', '20 rounds away from matching it', '10 rounds away from matching it',
]);
assert.equal(e.queries.length, 0);
});
check(`${game} overtaking finishes this match's chase even after cache changes or a busy client`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.round(50); e.round(51); e.round(52);
e.cache(70);
e.round(55); e.round(70); e.round(71);
assert.deepEqual(e.shown.map(notice => notice.round), [50, 51]);
const busy = roundRuntime(game);
const player = busy.readyPlayer({ busy: true });
busy.cache(50); busy.round(51);
assert.equal(busy.attempts.length, 1);
assert.equal(busy.shown.length, 0);
player.busy = false;
busy.round(52);
assert.equal(busy.attempts.length, 1, 'a skipped milestone cannot become a delayed celebration');
});
check(`${game} each client must be ready and thirty seconds past welcome or manual replay`, () => {
const e = roundRuntime(game);
const playing = e.readyPlayer();
const spectator = e.readyPlayer({ sessionstate: 'spectator' });
e.readyPlayer({ tws_lb_last_request: undefined });
e.readyPlayer({ tws_lb_last_request: e.now - 29999 });
for (const sessionstate of [undefined, 'connecting', 'dead'])
e.readyPlayer({ sessionstate });
e.cache(50); e.round(45);
assert.deepEqual(e.shown.map(notice => notice.player), [playing, spectator]);
assert.equal(e.context.lb_player_ready({ ...playing, tws_lb_last_request: e.now - 30000 }), true);
});
check(`${game} welcome attempts and manual requests reset the reminder cooldown`, () => {
const e = roundRuntime(game);
const player = e.readyPlayer({ tws_lb_last_request: undefined });
e.request(player, false);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 0, 'missing initial cache still releases the welcome readiness gate');
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1);
assert.equal(e.context.lb_player_ready(player), true);
e.cache(50); e.round(45);
e.request(player, true);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 1);
assert.equal(e.notifications[0].event, 'tws_lb_requested');
e.round(46);
assert.equal(e.shown.length, 1);
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1); e.round(47);
assert.deepEqual(e.shown.map(notice => notice.round), [45, 47]);
});
check(`${game} reminder readiness follows the engine's spectator/introscreen rules`, () => {
const e = roundRuntime(game), c = e.context;
const player = e.readyPlayer({ sessionstate: 'spectator', hasSpawned: false });
if (game === 't5') {
assert.equal(c.lb_player_ready(player), false);
player.hasSpawned = true;
assert.equal(c.lb_player_ready(player), true);
delete player.hasSpawned;
assert.equal(c.lb_player_ready(player), true);
} else {
assert.equal(c.lb_player_ready(player), true, 'T6 spectator newcomers keep hasSpawned false');
c.level.passed_introscreen = false;
assert.equal(c.lb_player_ready(player), false);
c.level.passed_introscreen = true;
assert.equal(c.lb_player_ready(player), true);
}
});
check(`${game} watcher waits for real starts and suppresses repeats, decreases and skipped starts`, () => {
const e = roundRuntime(game), observed = [];
e.context.lb_round_start = round => observed.push(round);
e.context.level.round_number = 30;
e.startWatcher();
assert.deepEqual(observed, [], 'loading mid-round must not synthesize an announcement');
e.context.level.round_number = 31;
assert.deepEqual(observed, [], 'a counter change before the actual start must not announce');
for (const round of [undefined, -1, 31, 31, 29, 30, 31, 34, 34]) e.startRoundEvent(round);
assert.deepEqual(observed, [31, 34], 'a skipped round emits only the newly observed start');
const fresh = roundRuntime(game), initial = [];
fresh.context.lb_round_start = round => initial.push(round);
fresh.startRoundEvent(0); fresh.startRoundEvent(1); fresh.startRoundEvent(1);
assert.deepEqual(initial, [1]);
assert(source.includes('level thread lb_round_watcher();'));
assert(!all.get('lb_round_watcher').includes('hud_round_changed'));
assert(!all.get('lb_round_watcher').includes('tws_t5_hud'));
assert(!all.get('lb_round_watcher').includes('tws_t6_hud'));
});
for (const event of ['end_game', 'game_ended', 'intermission'])
check(`${game} ${event} stops the independent round watcher`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.context.level.round_number = 44; e.startWatcher(); e.startRoundEvent(45);
assert.deepEqual(e.endEvents, ['end_game', 'game_ended', 'intermission']);
e.end(event); e.startRoundEvent(46);
assert.deepEqual(e.shown.map(notice => notice.round), [45]);
});
check(`${game} unicode and URL encoding`, () => { 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é 🧟');
@@ -193,11 +455,33 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_refresh(), undefined); assert.equal(c.lb_refresh(), undefined);
c.level.tws_lb_override = ''; c.level.tws_lb_override = '';
}); });
check(`${game} all holders fit bounded messages`, () => { check(`${game} all holders fit bounded service rows`, () => {
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`); const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
const lines = Array.from(c.lb_holder_lines(names)); const lines = Array.from(c.lb_holder_lines(names));
assert(lines.every((line) => line.length <= 100)); assert(lines.every((line) => c.lb_holder_width(line) <= 16));
assert.deepEqual(lines.join(', ').split(', '), names); assert.deepEqual(lines.join(', ').split(', '), names);
assert.deepEqual(Array.from(c.lb_holder_lines([])), []);
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']);
});
check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => {
const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5),
'abcdefghijklééé', '^1Alice\n^7 René 🧟', '^1\n\t'];
for (const sample of samples) {
const value = c.lb_holder_name(bytes(sample));
assert(c.lb_holder_width(value) <= 16, sample);
assert(c.lb_utf8_valid(value), sample);
assert(!text(value).includes('\uFFFD'), sample);
assert(!/[\x00-\x1f\x7f^]/.test(value), sample);
assert(value.length > 0, sample);
}
assert.equal(c.lb_holder_name('M'.repeat(64)), 'M'.repeat(13) + '...');
assert.equal(text(c.lb_holder_name(bytes('🧟'.repeat(16)))), '🧟'.repeat(6) + '...');
assert.equal(c.lb_holder_name('^1\n\t'), 'Unknown player');
assert.equal(c.lb_holder_width(bytes('René 🧟')), 8);
const names = Array(100).fill(bytes('同じ名前'.repeat(5)));
const rows = Array.from(c.lb_holder_lines(names));
assert.equal(rows.length, names.length, 'identical long names still represent every holder');
assert(rows.every((line) => c.lb_holder_width(line) <= 16 && c.lb_utf8_valid(line)));
}); });
if (game === 't6') { if (game === 't6') {
const variants = [ const variants = [
@@ -230,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);
} }
+40
View File
@@ -0,0 +1,40 @@
// Exercise the real GSC identity adapter under a small VM shim; native policy tests are separate.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
for (const game of ['t5','t6']) {
const source=readFileSync(new URL(`../${game}/scripts/tws_${game}_moderation.gsc`,import.meta.url),'utf8');
assert.equal((source.match(/level waittill\("connected"/g)??[]).length,0,'must use the shared connection dispatcher');
assert(source.includes('register_connect_handler(::moderation_connected)'));
assert(!source.includes('executeCommand'),'all effects belong to the native synchronous adapter');
const start=source.indexOf('moderation_observe(player)\n{');
const end=source.indexOf('\nmoderation_loop()',start);
let body=source.slice(start,end).replace('moderation_observe(player)','function observe(player)');
body=body.replace(' guid =',' var guid =').replace(' name =',' var name =');
body=body.replace('player getGuid()','getGuid(player)').replaceAll('typeof(', 'kind(').replace('player moderationObserve(guid, name)', 'record(player,guid,name)');
const calls=[];
const c=vm.createContext({isDefined:v=>v!==null&&v!==undefined,kind:v=>typeof v==='number'?(Number.isInteger(v)?'int':'float'):typeof v,
getGuid:p=>p.identity,record:(...args)=>calls.push(args)});
vm.runInContext(body,c);
const player=identity=>({identity,guid:identity,name:'same name'});
c.observe(player('18446744073709551615')); assert.equal(calls.at(-1)[1],'18446744073709551615');
c.observe(player(2741626)); assert.equal(calls.at(-1)[1],'2741626');
c.observe(player('0000123')); assert.equal(calls.at(-1)[1],'0000123');
const count=calls.length; for(const value of [undefined,null,1.5,{},[]])c.observe(player(value)); assert.equal(calls.length,count);
c.observe(undefined); assert.equal(calls.length,count);
c.observe({...player('abc'),name:undefined}); assert.equal(calls.at(-1)[2],'Unknown player');
let loop=source.slice(source.indexOf('moderation_loop()\n{'))
.replace('moderation_loop()', 'function* loop()').replaceAll(/wait ([0-9.]+);/g, 'yield $1;')
.replace('players = getPlayers();', 'var players = getPlayers();').replace('for (i = 0;', 'for (var i = 0;').replace('players.size', 'players.length');
let enabled=true, configured=false, stopped=0, pumped=0, observed=0;
const level={moderation_ready:false};
const runtime=vm.createContext({level,GetDvar:key=>key==='tws_moderation_enabled'?(enabled?'1':'0'):'configured',GetDvarInt:()=>1,
moderationConfigure:()=>configured,moderationStop:()=>++stopped,moderationPump:()=>++pumped,getPlayers:()=>[{guid:'123'}],moderation_observe:()=>++observed});
vm.runInContext(loop,runtime); const poll=runtime.loop();
assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false);
configured=true; assert.equal(poll.next().value,0.5); assert.equal(level.moderation_ready,true);
assert.equal(pumped,1); assert.equal(observed,1);
enabled=false; assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false); assert.equal(stopped,1);
enabled=true; assert.equal(poll.next().value,0.5); assert.equal(pumped,2);
console.log(`PASS ${game}: actual moderation identity, retry/disable/resume loop and shared dispatcher integration`);
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
template <typename F> void add(const char* name, F callback)
{
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
{
if constexpr (std::is_invocable_v<F>) return callback();
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
};
}
""")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
lines = result.stdout.splitlines()
for operation in ("load", "write", "remove"):
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Test native bank 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-bank-") 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/bank.cpp"), str(ROOT / "tools/tests/bank_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 return callback(args.at(0), args.at(1), args.at(2));
};
}
""")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/bank.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/bank_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/bank.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)
+7 -2
View File
@@ -66,9 +66,9 @@ def main():
base = f"http://127.0.0.1:{http.server_address[1]}" base = f"http://127.0.0.1:{http.server_address[1]}"
env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"} env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"}
def probe(path, connect="1000", total="1000"): def probe(path, connect="1000", total="1000", cap=None):
url = path if "://" in path else base + path url = path if "://" in path else base + path
completed = subprocess.run([str(executable), url, connect, total], env=env, capture_output=True, text=True, check=True, timeout=8) completed = subprocess.run([str(executable), url, connect, total] + ([] if cap is None else [str(cap)]), env=env, capture_output=True, text=True, check=True, timeout=8)
return json.loads(completed.stdout) return json.loads(completed.stdout)
normal = probe("/200", "-", "-") normal = probe("/200", "-", "-")
@@ -84,6 +84,11 @@ def main():
assert timed_out["body_size"] == 0, timed_out assert timed_out["body_size"] == 0, timed_out
if status: assert timed_out["first_byte_ms"] > 0, timed_out if status: assert timed_out["first_byte_ms"] > 0, timed_out
assert probe("/200")["curl_code"] == 0 # The next request completes after the stall. assert probe("/200")["curl_code"] == 0 # The next request completes after the stall.
exact = probe("/200", cap=11)
assert exact["curl_code"] == 0 and exact["body_size"] == 11, exact
bounded = probe("/200", cap=10)
assert bounded["curl_code"] == 23 and bounded["body_size"] == 0, bounded
assert probe("/200")["body_size"] == 11
slow = probe("/slow") slow = probe("/slow")
assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow
tls_url = f"https://127.0.0.1:{tls.server_address[1]}/" tls_url = f"https://127.0.0.1:{tls.server_address[1]}/"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Compile both real native moderation cores with ASan/UBSan (no Windows/VM emulation)."""
import argparse
import os
import re
from pathlib import Path
import shutil
import subprocess
import tempfile
ROOT=Path(__file__).resolve().parents[1]
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--json-header', help='Pinned single header; {game} expands to t5/t6')
a=p.parse_args()
with tempfile.TemporaryDirectory(prefix='tws-moderation-tests-') as tmp:
temp=Path(tmp)
for game in ('t5','t6'):
plugin=ROOT/game/'plugins'/f'tws-{game}-gsc-utils'
header=Path(a.json_header.format(game=game)) if a.json_header else plugin/'deps/json/single_include/nlohmann/json.hpp'
if not header.is_file(): p.error(f'Missing {header}; restore pinned dependencies or pass --json-header')
shutil.copyfile(header,temp/'json.hpp')
(temp/'stdinc.hpp').write_text('#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n')
exe=temp/game
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(ROOT/'tools/tests/moderation_cases.cpp'),'-o',str(exe)],check=True)
subprocess.run([str(exe),str(temp/f'{game}-data')],check=True)
subprocess.run([str(exe),str(temp/f'{game}-restart'),'write'],check=True)
subprocess.run([str(exe),str(temp/f'{game}-restart'),'read'],check=True)
component=re.sub(r'^#include[^\n]*\n','',(plugin/'src/component/moderation.cpp').read_text(),flags=re.MULTILINE)
(temp/'component.cpp').write_text('#include "moderation_component_stub.hpp"\n#include "moderation.hpp"\n#include <iomanip>\n'+component+(ROOT/'tools/tests/moderation_component_cases.cpp').read_text())
shutil.copyfile(ROOT/'tools/tests/moderation_component_stub.hpp',temp/'moderation_component_stub.hpp')
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(temp/'component.cpp'),'-o',str(temp/'component')],check=True)
component_dir=temp/f'{game}-component'; component_dir.mkdir()
subprocess.run([str(temp/'component')],cwd=component_dir,check=True)
print(f'PASS {game}: moderation native policy, journal and process restart',flush=True)
+158
View File
@@ -0,0 +1,158 @@
#include "afk.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
using utils::afk::journal;
using utils::afk::parse_acknowledgement;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
std::string reserve(const std::string& id)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
}
std::string request(const std::string& id, const std::string& action)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
}
template <typename F> void must_fail(F fn)
{
bool failed = false;
try { fn(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const journal queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.prepare(reserve("pending"));
queue.store(request("pending-start","start"));
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
queue.prepare(reserve("active")); queue.authorize("active",120);
const auto now = journal::clock::now();
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
}
else if (mode == "recover")
{
// Separate process: the same match ID cannot recreate a live clock.
assert(queue.remaining("active") == -1);
must_fail([&]{queue.begin("active");});
queue.recover("match");
const auto values = queue.load();
assert(values.size() == 5);
int recovered = 0;
for (const auto& value : values)
{
if (value.action != "settle") continue;
auto body = nlohmann::json::parse(value.body);
assert(value.id.size() == 25);
if (body.at("session_id") == "active")
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
++recovered;
}
assert(recovered == 3);
queue.recover("new-match"); // Stable requests are unchanged across restarts.
assert(queue.load().size() == values.size());
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
for (const auto& value : values) queue.erase(value.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
assert(queue.load().size() == 1);
must_fail([&]{queue.store(request("one","event"));});
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
must_fail([&]{queue.store(changed.dump());});
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
assert(other_server.load().empty() && other_endpoint.load().empty());
queue.erase("one");
queue.store(request("first-event","event"));
auto snapshot = nlohmann::json::parse(request("second-event","event"));
snapshot["seq"] = 2; snapshot["revives"] = 2;
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
assert(queue.load().size() == 1);
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
must_fail([&]{queue.store(regressed.dump());});
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
queue.erase("second-event");
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
assert(queue.load().size() == 1);
must_fail([&]{queue.begin("normal");});
must_fail([&]{queue.authorize("normal",601);});
queue.authorize("normal",60); queue.authorize("normal",60);
must_fail([&]{queue.authorize("normal",120);});
const auto now = journal::clock::now();
queue.begin("normal",now); queue.begin("normal",now+20s);
assert(queue.remaining("normal",now+1250ms) == 58750);
assert(queue.remaining("normal",now+59999ms) == 1);
assert(queue.remaining("normal",now+60s) == 0);
assert(queue.remaining("normal",now+61s) == 0);
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
assert(queue.checkpoint("normal",now+59001ms) == 59);
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
auto settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
assert(queue.checkpoint("expire",now+60s) == 60);
queue.stop("expire","expired",now+61s);
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
queue.erase(queue.load()[0].id);
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
queue.erase(queue.load()[0].id);
// Interrupted temp writes preserve the prior durable snapshot.
const auto file = fs::directory_iterator(root)->path();
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
assert(queue.remaining("failure",now+30s) == 0);
must_fail([&]{queue.checkpoint("failure",now+30s);});
must_fail([&]{queue.begin("failure",now+30s);});
fs::remove(file.string()+".tmp");
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
assert(parse_acknowledgement(valid.dump()).valid);
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
fs::remove(file);
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
}
+35
View File
@@ -0,0 +1,35 @@
// Compile the actual GSC registration adapter against portable VM interfaces.
#include <fstream>
int main()
{
afk::component component; component.on_startup(nullptr);
const auto call = [](const std::string& name, std::vector<std::string> args = {})
{ return gsc::function::functions.at(name)(args); };
assert(call("afkversion").as<std::string>() == "1.0.0");
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
std::filesystem::path file;
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
if (entry.path().extension() == ".json") file=entry.path();
assert(!file.empty());
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
for (int i=0;i<2;++i)
{
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
}
std::filesystem::remove(file);
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
}
+136
View File
@@ -0,0 +1,136 @@
#include "bank.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
namespace fs = std::filesystem;
using utils::bank::outbox;
using utils::bank::parse_acknowledgement;
const std::string server = "test-server", endpoint = "https://example.test/api/bank";
std::string body(const std::string& id, const std::string& decision)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},
{"match","match"},{"request_id",id},{"operation","deposit"},
{"amount",2000},{"capacity",1000000},{"decision",decision}}.dump();
}
template <typename F> void must_fail(F action)
{
bool failed = false;
try { action(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const outbox queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.store(body("uncertain-prepare","cancel"));
queue.store(body("applied-points","cancel"));
queue.store(body("applied-points","commit"));
}
else if (mode == "recover")
{
const auto entries = queue.load();
assert(entries.size() == 2);
assert(entries[0].decision == "cancel" && entries[1].decision == "commit");
for (const auto& entry : entries) queue.erase(entry.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
assert(queue.load().empty());
queue.store(body("one","cancel"));
queue.store(body("one","cancel"));
assert(queue.load().size() == 1);
queue.store(body("one","commit"));
queue.store(body("one","commit"));
assert(queue.load()[0].decision == "commit");
must_fail([&]{queue.store(body("one","cancel"));});
auto changed = nlohmann::json::parse(body("one","commit"));
changed["amount"] = 100;
must_fail([&]{queue.store(changed.dump());});
assert(queue.load()[0].body == body("one","commit"));
const outbox isolated(root,"another-server",endpoint);
assert(isolated.load().empty());
const outbox another_endpoint(root,server,endpoint+"/other");
assert(another_endpoint.load().empty());
// An interrupted temp write cannot replace the committed decision.
const auto file = fs::directory_iterator(root)->path();
{ std::ofstream stream(file.string()+".tmp"); stream << "incomplete"; }
assert(queue.load()[0].decision == "commit");
fs::remove(file.string()+".tmp");
// A failed replacement preserves cancellation and must stop points moving.
queue.erase("one");
queue.store(body("two","cancel"));
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.store(body("two","commit"));});
assert(queue.load()[0].decision == "cancel");
fs::remove(file.string()+".tmp");
queue.erase("two");
queue.erase("two");
assert(queue.load().empty());
auto valid = nlohmann::json{{"ok",true},{"state","prepared"},{"request_id","one"},
{"code","ok"},{"amount",2000},{"balance",3000}};
const auto ack = parse_acknowledgement(valid.dump());
assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one");
auto empty = valid;
empty["state"] = "balance"; empty["request_id"] = "";
empty["amount"] = 0; empty["balance"] = 0;
const auto zero = parse_acknowledgement(empty.dump());
assert(zero.valid && zero.amount == 0 && zero.balance == 0);
empty["ok"] = false; empty["state"] = "rejected";
empty["request_id"] = "one"; empty["code"] = "insufficient_balance";
assert(parse_acknowledgement(empty.dump()).valid);
for (const auto& field : {"amount","balance"})
for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)})
{
auto value = valid; value[field] = bad;
assert(!parse_acknowledgement(value.dump()).valid);
}
auto bad = valid; bad["ok"] = false;
assert(!parse_acknowledgement(bad.dump()).valid);
bad = valid; bad["request_id"] = "";
assert(!parse_acknowledgement(bad.dump()).valid);
bad = valid; bad["state"] = "unknown";
assert(!parse_acknowledgement(bad.dump()).valid);
for (const auto& text : {"", "<html>bad gateway</html>", "{", "null", "{\"ok\":true}"})
assert(!parse_acknowledgement(text).valid);
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
// Every validation failure exposes a fixed reason and clears response data.
const auto invalid = [](const std::string& text, const std::string& reason)
{
const auto result = parse_acknowledgement(text);
assert(!result.valid && result.reason == reason);
assert(result.state.empty() && result.code.empty() && result.id.empty());
};
assert(ack.reason == "ok");
invalid("", "empty_body");
invalid(std::string(0x5000, 'x'), "body_limit");
invalid("secret-token-not-json", "invalid_json");
invalid("null", "schema");
invalid("{}", "schema");
bad = valid; bad["code"] = std::string(65, 'x'); invalid(bad.dump(), "field_limit");
bad = valid; bad["state"] = "secret-state"; invalid(bad.dump(), "state");
bad = valid; bad["amount"] = -1; invalid(bad.dump(), "amount_range");
bad = valid; bad["ok"] = false; invalid(bad.dump(), "ok_state");
bad = valid; bad["request_id"] = ""; invalid(bad.dump(), "missing_id");
bad = valid; bad["amount"] = 0; invalid(bad.dump(), "zero_transfer");
bad = valid; bad["code"] = "private-token\nAuthorization: secret";
// Compatibility: valid but unknown API codes remain available to callers;
// scripts permit only fixed known codes in diagnostics.
assert(parse_acknowledgement(bad.dump()).valid);
{ std::ofstream stream(file); stream << "corrupt"; }
must_fail([&]{queue.load();});
must_fail([&]{queue.store(body("three","cancel"));});
must_fail([&]{queue.erase("three");});
fs::remove(file);
std::cout << "bank decisions, corruption, cancellation, response guards passed\n";
}
+38
View File
@@ -0,0 +1,38 @@
// Executed with the actual registration adapter and portable engine interfaces.
#include <fstream>
int main()
{
bank::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("bankversion").as<std::string>() == "1.2.0");
auto ack = call("bankresponse", {"private-secret-invalid-json"}).as<scripting::array>();
assert(!ack["valid"].as<bool>());
assert(ack["reason"].as<std::string>() == "invalid_json");
assert(ack["code"].as<std::string>().empty());
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
for (int i = 0; i < 2; ++i)
assert(!call("bankstore", {"server", "endpoint", "private-secret Authorization"}).as<bool>());
const std::string body = R"({"server":"server","request_id":"one","decision":"cancel"})";
assert(call("bankstore", {"server", "endpoint", body}).as<bool>());
const auto root = bank::spool("server", "endpoint");
assert(root.load().size() == 1);
// Locate only the test process's temporary outbox.
std::filesystem::path file;
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-bank"))
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("bankload", {"server", "endpoint"}).get_raw().type == game::SCRIPT_NONE);
assert(!call("bankremove", {"server", "endpoint", "one"}).as<bool>());
}
std::filesystem::remove(file);
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
assert(call("bankremove", {"server", "endpoint", "one"}).as<bool>());
}
+57
View File
@@ -0,0 +1,57 @@
// Optional integration with a local EC checkout. Execute its real bank service
// and bank migrations against disposable SQLite, with minimal fleet fixtures.
// No EC configuration, credentials, HTTP routes or production DB are loaded.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import vm from 'node:vm';
export function loadEcBank(directory) {
assert(directory, '--ec-root requires an EC checkout with installed dependencies');
const requireEc = createRequire(resolve(directory, 'package.json'));
const ts = requireEc('typescript');
const Database = requireEc('better-sqlite3');
function compile(file) {
const source = readFileSync(resolve(directory, 'src/lib/server', file), 'utf8');
return new vm.Script(ts.transpileModule(source, {
compilerOptions: {module:ts.ModuleKind.CommonJS, target:ts.ScriptTarget.ES2022}
}).outputText, {filename:file});
}
const service = compile('fleet/bank.ts');
const bankMigration = compile('db/bank.ts');
const credentialMigration = compile('db/bank-credentials.ts');
return guid => {
const database = new Database(':memory:');
function execute(script) {
const context = vm.createContext({
exports: {}, process: {env:{}},
require: name => {
assert.equal(name, '../db/index.js', 'unexpected EC runtime dependency');
return {db:() => database};
}
});
script.runInContext(context);
return context.exports;
}
database.exec(`
CREATE TABLE native_instances (id TEXT PRIMARY KEY, server TEXT, retired_at TEXT);
INSERT INTO native_instances VALUES ('instance', 'server', NULL);
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
INSERT INTO servers VALUES ('server', NULL);
CREATE TABLE leaderboard_matches (id INTEGER PRIMARY KEY, server TEXT, reporter_match_key TEXT, status TEXT);
INSERT INTO leaderboard_matches VALUES (1, 'server', 'match', 'active');
CREATE TABLE leaderboard_match_players (match_id INTEGER, guid TEXT, present INTEGER);
`);
database.prepare('INSERT INTO leaderboard_match_players VALUES (1, ?, 1)').run(guid);
execute(bankMigration).migrateBank(database);
execute(credentialMigration).migrateBankCredentials(database);
const {bankRequest} = execute(service);
return {
request: (action, body) => bankRequest(action, body, 'server', 'instance'),
ledger: () => database.prepare('SELECT guid, amount FROM bank_ledger ORDER BY id').all(),
requests: () => database.prepare('SELECT request_id, guid, state FROM bank_requests ORDER BY request_id').all(),
close: () => database.close()
};
};
}
+2 -1
View File
@@ -6,12 +6,13 @@
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
if (argc != 4 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) if ((argc != 4 && argc != 5) || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
return 2; return 2;
utils::http::timeouts limits; utils::http::timeouts limits;
// A dash exercises the defaults compiled into the actual plugin source. // A dash exercises the defaults compiled into the actual plugin source.
if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]); if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]);
if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]); if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]);
if (argc == 5) limits.max_response_bytes = std::stoul(argv[4]);
const auto started = std::chrono::steady_clock::now(); const auto started = std::chrono::steady_clock::now();
const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}", const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}",
{{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits); {{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits);
+160
View File
@@ -0,0 +1,160 @@
#include "moderation.hpp"
#include <cassert>
#include <fstream>
#include <iostream>
using namespace utils::moderation;
static const long long NOW = timestamp("2026-10-15T12:00:00.000Z");
static const std::string END = "2026-10-15T12:00:30.000Z";
static json reply(int revision, json sanctions = json::array(), json actions = json::array())
{
if (sanctions.is_null()) sanctions = json::array();
if (sanctions.is_object()) sanctions = json::array({sanctions});
if (actions.is_object()) actions = json::array({actions});
return {{"ok", true}, {"revision", revision}, {"serverTime", "2026-10-15T12:00:00.000Z"}, {"sanctions", sanctions}, {"actions", actions}, {"pollAfterMs", 3000}};
}
static json policy(std::string kind, std::string expiry = END)
{
return {{"id", "policy"}, {"revision", 1}, {"guid", "18446744073709551615"}, {"kind", kind}, {"reason", "rule"}, {"expiresAt", expiry.empty() ? json(nullptr) : json(expiry)}};
}
static json act(std::string id, std::string connection, std::string kind = "kick")
{
return {{"id", id}, {"guid", "18446744073709551615"}, {"connection", connection}, {"kind", kind}, {"reason", "rule; say bad\n\"bad\\^1"}, {"expiresAt", END}};
}
template<class F> void rejects(F f) { bool threw = false; try { f(); } catch (...) { threw = true; } assert(threw); }
int main(int argc, char** argv)
{
assert(argc >= 2); auto root = std::filesystem::path(argv[1]);
runtime game(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a");
if (argc == 3)
{
if (std::string(argv[2]) == "write")
{
assert(game.request()["sessionSequence"] == 1);
game.observe(0, "18446744073709551615", "one"); auto id = game.players.at(0).connection;
game.accept(reply(1, {policy("ban", "")}, {act("crash", id)}).dump(), NOW);
game.enforce(NOW, [](const auto&, int, const auto&) -> std::string { return "unknown"; });
}
else
{
assert(game.request()["sessionSequence"] == 2);
assert(game.revision() == 1); assert(game.request()["receipts"][0]["id"] == "crash");
assert(game.request()["receipts"][0]["state"] == "unknown");
int kicks = 0; game.observe(0, "18446744073709551615", "one");
game.enforce(NOW + 10, [&](const auto& kind, int, const auto&) { if (kind == "kick") ++kicks; return "confirmed"; });
assert(kicks == 1); // cached permanent policy enforces without a network response
}
return 0;
}
assert(canonical_guid("000123") == "123" && canonical_guid("000") == "0");
assert(canonical_guid("18446744073709551615") == "18446744073709551615" && canonical_guid("abc001") == "abc001");
assert(timestamp("2026-10-15T12:00:00Z") == NOW);
for (const auto* invalid : {"garbage", "2026-02-31T12:00:00Z", "2026-10-15T12:00:00+01:00", "2026-10-15T12:00:00.abcZ"}) rejects([&] { timestamp(invalid); });
assert(command_text("a\";\\\n\r^1b") == "a 1b");
assert(command_text(std::string(239, 'a') + "é") == std::string(239, 'a'));
assert(command_text(std::string(238, 'a') + "é") == std::string(238, 'a') + "é");
game.observe(1, "000123", std::string(254, 'a') + "é");
assert(game.players.at(1).guid == "123" && game.players.at(1).name == std::string(254, 'a'));
game.observe(1, "123", std::string("bad") + char(0xff)); assert(game.players.at(1).name == "bad?");
game.observe(1, "123", std::string("bad") + char(1) + "name"); assert(game.players.at(1).name == "bad name");
for (const std::string blank : {"", " ", "\t\r\n\x01\x7f", "\xc2\xa0\xe2\x80\x83\xef\xbb\xbf"})
{
game.observe(1, "123", blank); assert(game.players.at(1).name == "Unknown player");
assert(game.request()["players"][0]["name"] == "Unknown player");
}
rejects([&] { game.observe(1, "abc.001", "name"); });
game.request().dump(); game.disconnect(1);
game.observe(0, "18446744073709551615", "same name"); auto first = game.players.at(0).connection;
game.observe(0, "18446744073709551615", "new name"); assert(game.players.at(0).connection == first);
game.disconnect(0); game.observe(0, "18446744073709551615", "same name"); assert(game.players.at(0).connection != first);
int executed = 0;
auto execute = [&](const std::string&, int, const std::string& text) { ++executed; assert(text.find_first_of("\r\n;\"\\^") == std::string::npos); return "confirmed"; };
game.accept(reply(1, {}, {act("stale", first)}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 0);
assert(game.request()["receipts"][0]["state"] == "failed");
const auto valid = act("once", game.players.at(0).connection);
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
auto changed = valid; changed["reason"] = "different action";
rejects([&] { game.accept(reply(1, {}, {changed}).dump(), NOW); });
auto expired = act("expired", game.players.at(0).connection); game.accept(reply(1, {}, {expired}).dump(), NOW + 31); game.enforce(NOW + 31, execute); assert(executed == 1);
auto future = reply(2); future["serverTime"] = "2026-10-16T12:00:00Z"; rejects([&] { game.accept(future.dump(), NOW); });
rejects([&] { game.accept(reply(0).dump(), NOW); });
auto invalid = reply(2, {policy("mute")}); invalid["sanctions"][0]["guid"] = "bad guid"; rejects([&] { game.accept(invalid.dump(), NOW); }); assert(game.revision() == 1);
for (const auto* malformed : {"{}", "[]", "not json", "{\"ok\":false}"}) rejects([&] { game.accept(malformed, NOW); });
auto too_many = reply(2); for (int i = 0; i < 1001; ++i) too_many["sanctions"].push_back(policy("mute")); rejects([&] { game.accept(too_many.dump(), NOW); });
rejects([&] { game.accept(std::string(1024 * 1024 + 1, ' '), NOW); });
rejects([&] { game.accept(reply(1, {policy("mute")}).dump(), NOW); });
std::vector<std::string> effects;
auto record = [&](const auto& kind, int, const auto&) { effects.push_back(kind); return "unknown"; };
game.accept(reply(2, {policy("mute")}).dump(), NOW); game.enforce(NOW, record); game.enforce(NOW + 1, record); assert(effects == std::vector<std::string>{"mute"});
game.enforce(NOW + 31, record); assert(effects == (std::vector<std::string>{"mute", "unmute"}));
auto expired_snapshot = reply(2); expired_snapshot["serverTime"] = "2026-10-15T12:00:31.000Z";
game.accept(expired_snapshot.dump(), NOW); // server expiry wins even when local clock lags within allowed skew
game.accept(reply(3, {policy("mute", "")}).dump(), NOW); game.enforce(NOW, record);
game.accept(reply(4).dump(), NOW); game.enforce(NOW, record); assert(effects.back() == "unmute");
game.accept(reply(5, {policy("ban", "")}).dump(), NOW);
auto disconnecting = [&](const auto& kind, int slot, const auto&) { assert(kind == "kick"); game.disconnect(slot); return "confirmed"; };
game.enforce(NOW, disconnecting); assert(game.players.empty());
// Slow host time and buffered responses cannot extend a one-time deadline.
{
runtime slow(root / "slow-clock", "server", "endpoint", 1, "credential-a");
slow.observe(0, "18446744073709551615", "one");
slow.accept(reply(1, {}, {act("slow", slow.players.at(0).connection)}).dump(), NOW - 60, 31000);
const auto count = executed; slow.enforce(NOW - 60, execute); assert(executed == count);
assert(slow.request()["receipts"][0]["state"] == "expired");
}
// A faster next response cannot roll the conservative server-time anchor back.
{
runtime monotonic(root / "monotonic-clock", "server", "endpoint", 1, "credential-a");
monotonic.observe(0, "18446744073709551615", "one");
auto timed = reply(1, {policy("mute", "2026-10-15T12:00:07.000Z")});
const auto count = effects.size();
monotonic.accept(timed.dump(), NOW, 8000); monotonic.enforce(NOW, record);
monotonic.accept(timed.dump(), NOW, 0); monotonic.enforce(NOW, record);
assert(effects.size() == count);
}
// A superseded runtime cannot overwrite a newer session counter or execute.
{
const auto replacement_root = root / "replacement";
runtime older(replacement_root, "server", "endpoint", 1, "credential-a");
older.observe(0, "18446744073709551615", "one");
older.accept(reply(1, {}, {act("superseded", older.players.at(0).connection)}).dump(), NOW);
runtime newer(replacement_root, "server", "endpoint", 1, "credential-a");
const auto count = executed; rejects([&] { older.enforce(NOW, execute); }); assert(executed == count);
newer.accept(reply(2).dump(), NOW); assert(newer.request()["sessionSequence"] == 2);
runtime recovered(replacement_root, "server", "endpoint", 1, "credential-a");
assert(recovered.request()["sessionSequence"] == 3 && recovered.revision() == 2);
for (const auto& file : std::filesystem::directory_iterator(replacement_root)) std::filesystem::remove(file.path());
rejects([&] { recovered.accept(reply(3).dump(), NOW); }); // cache deletion cannot silently reset its sequence
}
// Managed mute ownership survives restart and allows revocation without clearing unrelated mutes.
const auto mute_root = root / "mute-restart";
{
runtime original(mute_root, "server", "endpoint", 1, "credential-a");
original.observe(0, "18446744073709551615", "one");
original.accept(reply(1, {policy("mute", "")}).dump(), NOW);
original.enforce(NOW, record);
}
{
runtime recovered(mute_root, "server", "endpoint", 1, "credential-a");
recovered.accept(reply(2).dump(), NOW);
recovered.observe(1, "18446744073709551615", "one");
const auto before = effects.size(); recovered.enforce(NOW, record);
assert(effects.size() == before + 1 && effects.back() == "unmute");
recovered.observe(2, "someone-else", "two"); recovered.enforce(NOW, record);
assert(effects.size() == before + 1);
}
// Pre-dispatch disk failure must prevent the irreversible effect.
game.observe(0, "18446744073709551615", "one");
game.accept(reply(6, {}, {act("disk", game.players.at(0).connection)}).dump(), NOW);
std::filesystem::path journal;
for (const auto& file : std::filesystem::directory_iterator(root)) if (file.path().extension() == ".json") journal = file.path();
auto temporary = journal; temporary += ".tmp"; std::filesystem::create_directory(temporary);
const auto before_disk = executed; rejects([&] { game.enforce(NOW, execute); }); assert(executed == before_disk);
std::filesystem::remove(temporary);
runtime different_credential(root, "server", "https://controller/api/moderation/runtime", 1, "credential-b");
assert(different_credential.revision() == 0 && different_credential.request()["receipts"].empty());
// Corruption is visible: runtime never replaces a bad persistent cache with empty policy.
{ std::ofstream file(journal); file << "corrupt"; }
rejects([&] { runtime broken(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); });
std::cout << "moderation policy, identity, expiry, revocation, journal and schema checks passed\n";
}
@@ -0,0 +1,49 @@
int main()
{
plugin::plugin plugin; moderation::component component; component.on_startup(&plugin);
const std::string url="https://controller/api/moderation/runtime", token="ec_moderation_"+std::string(43,'a');
assert(!gsc::function::configure("server","http://controller/api/moderation/runtime",token,1));
assert(!gsc::function::configure("server","https://controller@evil/api/moderation/runtime",token,1));
assert(!gsc::function::configure("server",url,token+"\r\nX: leak",1));
assert(gsc::function::configure("server",url,token,1));
// A failed replacement config must not permanently poison the valid old config.
assert(!gsc::function::configure("server",url,token,0));
assert(gsc::function::configure("server",url,token,1));
assert(!moderation::fault);
const auto initial_session=moderation::current->session;
const auto initial_sequence=moderation::current->request()["sessionSequence"];
assert(gsc::function::configure("server",url,token,1));
assert(moderation::current->session==initial_session && moderation::current->request()["sessionSequence"] == initial_sequence); // periodic configuration cannot invalidate connections
scripting::entity player{{0,0}}, wrong{{0,1}};
assert(!gsc::method::observe(wrong,"123","one"));
assert(!gsc::method::observe(player,"123; kick all","one"));
assert(gsc::method::observe(player,"123","one"));
const auto connection=moderation::current->players.at(0).connection;
plugin.i.c.disconnect(0); assert(gsc::method::observe(player,"123","two"));
assert(moderation::current->players.at(0).connection!=connection);
auto now=moderation::now();
auto iso=[](long long seconds) {time_t time=seconds; auto tm=*gmtime(&time); std::ostringstream out; out<<std::put_time(&tm,"%Y-%m-%dT%H:%M:%SZ"); return out.str();};
utils::moderation::json reply={{"ok",true},{"revision",1},{"serverTime",iso(now)},{"sanctions",utils::moderation::json::array()},
{"actions",{{{"id","stale"},{"guid","123"},{"connection",connection},{"kind","kick"},{"reason","reason"},{"expiresAt",iso(now+30)}}}},{"pollAfterMs",3000}};
if (reply["actions"].is_object()) reply["actions"] = utils::moderation::json::array({reply["actions"]});
utils::http::response=reply.dump(); gsc::function::pump(); scheduler::thread_pool.flush(); gsc::function::pump();
assert(command::commands.empty()); assert(moderation::current->request()["receipts"][0]["state"]=="failed");
reply["actions"][0]["id"]="valid";reply["actions"][0]["connection"]=moderation::current->players.at(0).connection;
reply["actions"][0]["kind"]="message";reply["actions"][0]["reason"]="a\";\\\r\nsay";
moderation::current->accept(reply.dump(),now); gsc::function::pump();
assert(game::messages.size()==1); assert(game::messages[0].first==0); assert(game::messages[0].second.find(';')==std::string::npos);
assert(game::messages[0].second.find('\n')==std::string::npos);
moderation::current->accept(reply.dump(),now); gsc::function::pump(); assert(game::messages.size()==1);
reply["actions"][0]["id"]="shutdown"; reply["actions"][0]["kind"]="kick";
moderation::current->accept(reply.dump(),now);
command::on_execute=[] { scripting::shutdown(); };
gsc::function::pump(); assert(!moderation::current); // callback cannot free the active frame
command::on_execute={};
assert(gsc::function::configure("server",url,token,1));
assert(moderation::current->request()["receipts"].size()>=1);
const auto old_session=moderation::current->session;
assert(gsc::function::configure("server",url,"ec_moderation_"+std::string(43,'b'),1));
assert(moderation::current->session!=old_session && moderation::current->revision()==0);
assert(moderation::current->request()["receipts"].empty());
scripting::shutdown();
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <json.hpp>
#include "http.hpp"
namespace game {
struct scr_entref_t { unsigned short entnum = 0, classnum = 0; };
inline std::vector<std::pair<int,std::string>> messages;
inline void SV_GameSendServerCommand(int slot, int, const char* message) { messages.emplace_back(slot,message); }
}
namespace scripting {
struct entity { game::scr_entref_t ref; game::scr_entref_t get_entity_reference() const { return ref; } };
struct array {};
inline std::function<void()> shutdown;
inline void on_shutdown(std::function<void()> callback) { shutdown=callback; }
}
namespace plugin {
struct callbacks { std::function<void(unsigned)> connect, disconnect; void on_player_connect(std::function<void(unsigned)> fn) {connect=fn;} void on_player_disconnect(std::function<void(unsigned)> fn) {disconnect=fn;} };
struct iface { plugin::callbacks c; plugin::callbacks* callbacks() {return &c;} };
struct plugin { iface i; iface* get_interface() {return &i;} };
}
struct component_interface { virtual ~component_interface()=default; virtual void on_startup(plugin::plugin*) {} };
#define REGISTER_COMPONENT(x)
namespace command { inline std::function<void()> on_execute; inline std::vector<std::string> commands; inline void execute(std::string command,bool sync) {assert(sync); commands.push_back(command); if(on_execute)on_execute();} }
namespace scheduler { struct pool { std::vector<std::function<void()>> tasks; void push(std::function<void()> fn) {tasks.push_back(fn);} void flush() {auto copy=tasks; tasks.clear(); for(auto& fn:copy)fn();} }; inline pool thread_pool; }
namespace gsc::function {
inline std::function<bool(std::string,std::string,std::string,int)> configure;
inline std::function<void()> pump;
template<typename F> void add(const char*,F fn) {
if constexpr(std::is_invocable_r_v<bool,F,std::string,std::string,std::string,int>)configure=fn;
else if constexpr(std::is_invocable_r_v<void,F>) { if constexpr(std::is_same_v<std::invoke_result_t<F>,void>)pump=fn; }
}
}
namespace gsc::method { inline std::function<bool(scripting::entity,std::string,std::string)> observe; template<typename F> void add(const char*,F fn) {observe=fn;} }
namespace utils::cryptography::sha256 { inline std::string compute(const std::string& input, bool hex) { assert(hex); return std::to_string(std::hash<std::string>{}(input)); } }
namespace utils::http {
inline std::string response, request;
inline std::optional<result> get_data(const std::string& url,const std::string& fields,const headers& hdrs,const std::string& method,const timeouts& limits) {
assert(url=="https://controller/api/moderation/runtime"); assert(method=="POST"); assert(hdrs.at("Authorization").starts_with("Bearer ec_moderation_")); assert(limits.max_response_bytes==1024*1024);
request=fields; result out; out.status=200; out.code=CURLE_OK; out.buffer=response; return out;
}
}
+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"tws_t5_afk.gsc": "1.0.0", "tws_t5_afk.gsc": "2.1.0",
"tws_t5_common.gsc": "1.0.0", "tws_t5_bank.gsc": "2.0.3",
"tws_t5_hud.gsc": "1.0.0", "tws_t5_common.gsc": "1.5.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.0.0", "tws_t5_leaderboard.gsc": "1.2.0",
"tws_t5_lock.gsc": "1.0.0", "tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.0.0", "tws_t6_afk.gsc": "2.1.0",
"tws_t6_common.gsc": "1.0.0", "tws_t6_bank.gsc": "2.0.3",
"tws_t6_hud.gsc": "1.0.0", "tws_t6_common.gsc": "1.5.0",
"tws_t6_join.gsc": "1.0.0", "tws_t6_hud.gsc": "1.4.2",
"tws_t6_leaderboard.gsc": "1.0.0", "tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.2.0",
"tws_t6_lock.gsc": "1.0.0", "tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.0.0", "t5-plugin": "1.4.0",
"t6-plugin": "1.0.0" "t6-plugin": "1.4.0",
"tws_t5_moderation.gsc": "1.0.0",
"tws_t6_moderation.gsc": "1.0.0"
} }