Compare commits

..
10 Commits
58 changed files with 10476 additions and 1062 deletions
+594
View File
@@ -4,8 +4,602 @@ Changes are listed newest first, with an independent version for every individua
T5/T6 script and each native plugin. Current versions are recorded in T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning). [versions.json](versions.json); see the [version policy](README.md#versioning).
## 2026-10-18
### Add majority votes to lock and unlock the lobby
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_lock.gsc` | `1.1.0` | `t5/scripts/tws_t5_lock.gsc` |
| `tws_t6_lock.gsc` | `1.1.0` | `t6/scripts/tws_t6_lock.gsc` |
- Add exact, case-insensitive `.lock` (round 6 onward) and `.unlock` commands
with help registration. Both need a strict majority of all connected players:
1/1, 2/2, 2/3, 3/4, or `floor(players / 2) + 1` in larger lobbies. Solo
requests apply immediately; dead, spectating and AFK connections count.
- Use a 60-second ballot with one vote per connection, no extension on repeats,
and fresh votes after expiry or state changes. Recount joins/disconnects;
disconnected votes do not count, and reconnects do not inherit votes.
- Retain the configurable automatic lock at round 20, consumed once per match.
A successful unlock overrides it for the rest of the match, including after
an early manual lock. Players can still vote to relock. Automatic locking
clears any incomplete manual lock ballot. Generate a fresh password for each
lock and clear it and pending votes at startup and HUD/stock game over.
- Keep early locking optional: no round-6 prompt. Use ordinary message text
for vote progress, expiry and state changes, with private duplicate/unavailable
replies. Update help pagination coverage and both games' documentation.
- Compatibility: no new dependency requirement. Keep the matching common
`1.0.0+` and HUD `1.0.0+` modules installed; HUD still supplies round events.
T5 and T6 receive matching behavior using their existing namespaces and
initialization conventions. No common, HUD, native-plugin or upstream-pin
source changes are needed.
- Validation: 76 lock vote/lifecycle checks, 74 service HUD checks and 14 core
HUD checks passed with engine shims. The T6 lock script compiled and assembled
with upstream gsc-tool `8f1d6bd` libraries. T5 has no gsc-tool backend; live
Windows/Plutonium loading, message rendering and connection blocking still
need validation on both engines. No native-plugin build or deployment was
performed. See [lock voting and live checks](README.md#lobby-lock-voting-t5-and-t6).
- Deployment: copy the updated lock script for each installed game to its
existing script directory and restart the map. No full server-process restart
or additional deployment action is required for this script-only update.
## 2026-10-17
### Allow solo and last-active-player AFK
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Remove the teammate requirement before reservation and after start
authorization on both engines. Solo players, the last active player and
players whose teammates are already AFK or awaiting authorization can enter.
- Remove the AFK team watcher that ended matches when no non-AFK survivor
remained. All players may be AFK together, and teammate death/disconnect
does not cancel protection. Keep reserve accounting, the local deadline,
cooldown, player-state checks and normal stock match-end settlement.
- Compatibility: no new dependency requirement. Existing common `1.4.0+`,
telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and
host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same
policy change using their existing engine-specific state checks.
- Validation: 47 AFK script behavior checks passed, including solo entry,
unavailable/pending teammates, teammate loss during authorization, all-AFK
play, solo return/expiration and stock match-end cleanup. The changed T6
script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries.
Portable checks use engine shims; live T5/T6 validation remains required.
No Windows plugin build, live server test or deployment was performed.
- Deployment: copy the updated AFK script for each installed game and restart
the map. No full server-process restart or additional deployment action is
required for this script-only update. See the [AFK guide](docs/afk.md) for
the updated live validation cases.
### Increase map-record reminders as players approach the target
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` |
- Add matching T5/T6 round-start reminders using the latest usable cached map
record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the
final five. Show matching/overtake milestones, then stop reminders after the
first overtake for the rest of the match. Milestones distinguish reaching a round
from saving the result after match completion; `.leaders` remains available.
Listen to the stock `start_of_round` event so reminders follow the actual
start, including on T6 where the counter advances during the round break.
Ignore duplicate starts and counter rewinds within the match.
- Common renders a compact record header and one proximity/milestone line for
six seconds, using two nonarchived elements in the existing service area.
Keep full join/`.leaders` announcements, all tied holders and 12-second pages.
Skip pending or active help/records, unready players and recent welcome/manual
record requests; opening help discards a reminder. Release elements on idle,
interruption, disconnect and game end. Both games work without optional HUD.
- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or
expired records suppress reminders; skipped reminders are not queued.
- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`.
There is no new native-plugin, backend or upstream dependency requirement.
Unchanged HUD, AFK and other scripts retain their individual versions.
- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join,
23 telemetry and 39 AFK portable checks passed, as did both moderation
script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool
`8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked
after the event-timing fix. JSON uniqueness, all 18 script manifest entries,
version/changelog consistency, local documentation links and `git diff --check`
passed. Portable checks use engine shims; no live Plutonium validation or
backend integration was performed. T5 has no gsc-tool compiler backend.
- Deployment: copy the updated leaderboard and common scripts together for
each installed game, then restart the map. No full server-process restart is
required for this script-only update. No deployment performed. Live T5/T6
rendering, readiness and round-transition validation remain required; see
[record validation](README.md#map-record-announcements).
### Persistent AFK reserve and immediate expiration (Refs #12)
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.0.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.0.0` | `t6/scripts/tws_t6_afk.gsc` |
| `t5-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
| `t6-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
- Replace unlimited AFK with shared GUID reserves, authenticated authorization,
explicit bank purchase confirmation, real contribution rewards and durable
request/session journals. Both engines use matching contracts and behavior.
- Show an independent central countdown, final-minute urgency and audible warnings.
Zero immediately releases AFK protection and owned controls/weapons. Manual
`.back` retains its separate two-second grace; it cannot renew an AFK session.
Reject downed activation (including T5), stock restrictions and last-active-player
entry. End the match when only AFK players remain, respecting stock recovery.
- Award each successful revive, including repeated revives of one teammate. Count
kill/headshot events, round participation and active engagement without using
score changes. EC owns configurable rates, caps, persistent progress and atomic
bank conversion; no second points bank is introduced.
- Compatibility: AFK `2.0.0` requires matching TWS plugin `1.4.0+` (native AFK API
`1.0.0`), common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+ and the EC #151
endpoints. EC host agent `1.12.0+` must be deployed before activation. Existing
private `tws_bank_token` and `tws_server_id` authenticate requests. There is no
new upstream dependency pin or native loader `plugin_version()` requirement.
- Deployment: deploy EC and upgraded host agents first; build/install both plugin
DLLs and matching scripts, retain persistent `tws-afk` storage, then **fully
restart each game-server process**. No deployment, Windows DLL build or live
Plutonium validation is claimed. See [AFK operation and validation](docs/afk.md).
- Validation: both engines pass native AFK journal/adapter tests with ASan/UBSan,
39 AFK behavior checks, 14 core HUD and 56 service HUD checks. All nine T6 scripts
compiled/assembled with upstream gsc-tool `8f1d6bd` T6 PC compiler/assembler
libraries. Script and integration checks are documented in the AFK guide; final
in-game T5/T6 validation remains required. Unchanged common/HUD/bank/telemetry
scripts retain their individual versions.
### Center the complete zombie count above the health bar
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.4.2` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.2` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the separately positioned number and label with one center-aligned
`N ZOMBIES` readout at the health track's midpoint in both engines. Use a
uniform font scale of `1.25` and the existing count color; digit changes and
the initial `-- ZOMBIES` placeholder share the same center.
- Update text only when the count changes using `SetTextUnlimited`, avoiding
a new config string for each distinct count. Keep counting, `.hud` visibility,
game-over termination and disconnect cleanup behavior. Core HUD allocation
decreases from 11 to 10 archived elements; AFK still adds one.
- Compatibility: HUD `1.4.2` requires matching common `1.4.0+` and
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334), which provides
`SetTextUnlimited` for T5 and T6. No new plugin or backend requirement.
Common and AFK need no source change for this fix; both HUD scripts change.
- Validation: 73 HUD checks passed, including digit transitions, a non-default
track midpoint, unchanged-count polling, hidden updates and service cleanup.
Leaderboard (143), bank (42), join (17) and telemetry (23) portable checks
passed. These use engine shims; actual EC integration and live Plutonium
validation were not run. GSC compilation was unavailable because gsc-tool
and PowerShell are not installed. Verify rendering on Windows/Plutonium.
- Deployment: copy each updated HUD script and restart the map on r5334+.
Older installations must first update Plutonium on clients and servers and
fully restart those processes. No full server-process restart is required
for the script update alone. No deployment performed.
### Use ASCII separators in HUD branding and map records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.1` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.1` | `t6/scripts/tws_t6_hud.gsc` |
- Replace the middle dot with an ASCII vertical bar in the top-center branding
and map-record heading after the separator displayed as two dots on T5.
Apply the same text change to T6 for a consistent appearance. Keep existing
spacing, positioning and HUD/service behavior.
- Compatibility: no new dependency requirement. No native-plugin, backend or
other script changes are required.
- Validation: 71 HUD, 143 leaderboard, 42 bank, 17 join and 23 telemetry portable
checks passed. These use engine shims; actual EC bank integration, GSC
compilation and live Windows/Plutonium validation were not performed.
- Deployment: copy the updated common and HUD scripts for each installed game,
then restart the map. No full server-process restart is required. No deployment
performed; live Windows/Plutonium rendering validation remains outstanding.
## 2026-10-15 — Player moderation runtime
- **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 ## 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 ### Bank failure diagnostics — TWS #14
| Component | Version | Affected source | | Component | Version | Affected source |
+266 -12
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 16 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`
@@ -173,6 +173,59 @@ Modules currently in each tree:
| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank | | bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank |
| leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` | | leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` |
## Lobby lock voting (T5 and T6)
Lock scripts `1.1.0` add exact, case-insensitive `.lock` and `.unlock` chat
commands, also listed in `.help` when the lock module is installed. `.lock`
is available **after round 5 (round 6 onward)**; `.unlock` is available whenever
the server is locked. Both require a strict majority of the current connected
players: 1 of 1, 2 of 2, 2 of 3, or 3 of 4. Dead, spectating and AFK players
count equally. Larger lobbies use `floor(players / 2) + 1`.
Typing the command starts a 60-second vote and casts your vote; teammates type
the same command to support it. Solo commands apply immediately. Each connection
gets one vote per ballot. Repeats neither add votes nor extend the deadline.
Joins and disconnects update the majority; disconnected votes are discarded,
and reconnecting does not restore a vote. A smaller roster can pass an ongoing
vote with the remaining votes. An expired vote needs fresh votes from everyone.
Typing the command for the current state only replies privately; it does not
withdraw a vote or cancel an ongoing ballot.
Automatic locking remains at `tws_lock_round` (default `20`) and happens at
most once per match. A successful `.unlock` vote keeps the lobby open for the
rest of that match, including when players lock and unlock before round 20.
Players can still vote `.lock` again. An `.unlock` request while already open
does not disable the automatic lock. Automatic locking clears any unfinished
manual lock ballot. Each activation generates a fresh `g_password`; existing
players remain connected. Startup and game over clear the password and votes.
There is no automatic prompt at round 6. Vote progress and state changes use
ordinary message text, with private replies for unavailable/repeated commands;
no center-screen popup or recurring reminder is added.
Compatibility: no new dependency requirement; keep the matching common and HUD
modules installed (common `1.0.0+`, HUD `1.0.0+`). HUD supplies round notifications.
Copy each updated lock script to its existing game script directory and restart
the map. No native plugin update, full server-process restart or other deployment
action is required.
Run `node tools/check_lock.mjs` and `node tools/check_hud.mjs` for portable vote,
lifecycle and help pagination checks. Compile the T6 script using the
[T6 compiler check](t6/scripts/README.md#validation). Before rollout, verify on
live T5 and T6 servers:
1. Try `.lock` in rounds 5 and 6, then both commands with 1–4 players. Confirm
the exact majority, instant solo behavior, private duplicate replies and
ordinary vote text. Check both commands in `.help` and `.commands`.
2. Let a vote expire, then retry; disconnect a voter, disconnect a non-voter,
reconnect and join mid-vote. Verify the current roster and fresh votes count.
3. Allow round-20 auto-lock, unlock, advance another round and relock. Also
lock/unlock early and cross round 20. Confirm a new client is blocked when
locked and can join when unlocked; connected players stay in the game.
4. End the match with a lock or pending vote, then restart the map. Confirm
the next lobby is open and old votes cannot change it. Check a custom
`tws_lock_round` and confirm it does not change the round-6 manual gate.
## Player bank (T5 and T6) ## Player bank (T5 and T6)
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and
@@ -269,7 +322,7 @@ 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; 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. existing bank API, common/telemetry and endpoint-migration requirements still apply.
Verify deployed DLL and GSC SHA-256 hashes against the built release artifacts, Verify deployed DLL and GSC SHA-256 hashes against the built release artifacts,
and confirm startup reports `script=2.0.2 plugin=1.2.0` before interpreting results. 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 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. only the DLL does not update GSC. No deployment was performed here.
@@ -359,16 +412,153 @@ 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 shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal
settles legacy and public journals without duplicate ledger entries or lost points. 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`, plus registered commands such as `.lock` and `.unlock`.
Missing modules' commands are omitted. Bank syntax remains
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
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.
@@ -390,13 +580,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.
@@ -430,6 +651,19 @@ page: each should start one private announcement from the first page. Repeat
while help is open, then disconnect or end the game during a replay to check while help is open, then disconnect or end the game during a replay to check
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help. cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
For reminders, use a known record on each engine and advance through all cadence
boundaries, the matching round and the first higher round. Confirm later rounds
remain quiet while `.leaders` still works, including after a cache refresh.
Check a record not divisible by five to verify the cadence follows the record.
Keep help or a full record open at an eligible round, join immediately before
that round, and request `.leaders` within 30 seconds of it; no reminder should
replace those views or appear later as a queued message. Open help during a
reminder and confirm it does not resume. Verify six-second dismissal, two-element
cleanup on disconnect/game end, and the same behavior without HUD installed.
Repeat with an empty or expired cache and compare request logs: eligible rounds
must add no HTTP traffic. Live T5/T6 rendering and round-transition validation
remain required even when portable checks and T6 compilation pass.
## Telemetry authentication (T5 and T6) ## Telemetry authentication (T5 and T6)
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
@@ -468,3 +702,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.
+8
View File
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14). restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser, Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims. storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t5",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,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;
};
}
@@ -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;
};
}
+72 -6
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,20 +62,80 @@ 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` | Automatic `g_password` lock (`tws_lock_round`, default 20) plus majority `.lock` / `.unlock` votes | `tws_lock_game_over` (internal) | `say`, `hud_round_changed`, `hud_game_over`, `end_game`, `game_ended`, `intermission` |
| `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` | | `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` |
| `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` | | `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
+188 -71
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 eight `.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,18 +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) | Top-left Discord URL, 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) | Automatic lobby lock plus majority `.lock` / `.unlock` votes |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | | [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection | | [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands | | [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
## 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:
@@ -54,19 +57,58 @@ requests or journal writes, preserving existing strings exactly. Missing or
invalid identities fail privately without changing points. Follow the shared invalid identities fail privately without changing points. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation). [identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text ## Core HUD and service window
The HUD shows `https://discord.thewrightserver.net` in small light-gray text The unboxed core puts match time above round time at the upper left,
at the top-left, including for newcomers waiting in spectator mode. The label `TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
is created once per connection and persists across death and respawn. The slim health bar at the bottom center. `N ZOMBIES` is one centered element at
gameplay HUD starts one line lower, keeping timers, help and records clear. the health track's midpoint, using uniform font scale `1.25` so the whole group
Install HUD and common `1.1.0` together, then restart the map; no plugin update stays centered as the digit count changes. Its initial placeholder is
or full server-process restart is required. `-- 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;
@@ -78,27 +120,51 @@ 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 with command and description
counter and health display. Typing either command again restarts that display time. columns. 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) |
| `.lock` | Vote to lock the lobby from round 6 (lock module required) |
| `.unlock` | Vote to unlock the lobby (lock module required) |
Each module registers its own help entries; removing AFK or join also removes Each module registers its own help entries. The first nine commands follow
those entries from `.help` after a map restart. the order above; additional entries follow registration order. Removing a module
also removes its entries from `.help` after a map restart. Lock and extended AFK
commands appear on additional pages. Bank entries retain 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
@@ -148,16 +214,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
@@ -172,6 +242,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` |
@@ -180,27 +263,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**
@@ -214,47 +314,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`.
--- ---
@@ -303,9 +394,16 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/
**Features** **Features**
- Sets `g_password` to a random per-lobby value when the round crosses a - Sets `g_password` to a random per-lobby value when the round crosses a
milestone (default: round 20) milestone (default: round 20), once per match
- Adds `.lock` from round 6 and `.unlock` whenever locked, using strict-majority
votes (1/1, 2/2, 2/3, 3/4) of all connected players
- Votes expire after 60 seconds; duplicates do not count, and roster changes
update the tally without retaining disconnected votes
- A successful unlock disables automatic locking for the rest of that match;
players can vote to relock at any time from round 6
- No automatic round-6 prompt; progress/results use ordinary message text
- Existing players are unaffected; `g_password` is only checked at connect - Existing players are unaffected; `g_password` is only checked at connect
- Clears `g_password` on `hud_game_over` so the next lobby opens unlocked - Clears `g_password` and pending votes at startup and on HUD/stock game-over events
- Trigger round is dvar-driven — retune without redeploying - Trigger round is dvar-driven — retune without redeploying
**Provides** **Provides**
@@ -313,13 +411,19 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/
|---|---| |---|---|
| `level.lock_round` | Round at which the lobby locks (read at init) | | `level.lock_round` | Round at which the lobby locks (read at init) |
| `level.lock_active` | `true` after the password has been set | | `level.lock_active` | `true` after the password has been set |
| `level.lock_auto_done` | Automatic locking consumed or overridden by a successful unlock |
| `level.lock_vote` | Current ballot, or undefined when idle |
**Depends on** **Depends on**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or` - `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or`, `register_help_command`
- `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over` - `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over`
- Stock BO1: `level.round_number` - Stock BO1: `level.round_number`
- Dvar: `tws_lock_round` (int, default `20`) - Dvar: `tws_lock_round` (int, default `20`)
Lock `1.1.0` has no new dependency requirement. Copy the updated script and
restart the map; no full server-process restart is required. See the shared
[voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6).
--- ---
## Dvars ## Dvars
@@ -518,3 +622,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.
+945 -58
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -15,7 +15,7 @@ init()
credentials = "valid"; credentials = "valid";
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_") if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
credentials = "invalid"; credentials = "invalid";
print("[tws_bank] startup script=2.0.2 plugin=" + bankVersion() + print("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) + " endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
" credentials=" + credentials); " credentials=" + credentials);
// A private per-server cfg supplies this; never embed bank credentials. // A private per-server cfg supplies this; never embed bank credentials.
@@ -179,7 +179,7 @@ bank_transfer(entry, args)
if (!isDefined(reply) || reply["state"] != "balance") if (!isDefined(reply) || reply["state"] != "balance")
bank_say(entry, "^3Bank unavailable. Your points are unchanged."); bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
else else
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points; available: ^7" + reply["amount"]); bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
return; return;
} }
if (args.size != 2) if (args.size != 2)
+612 -69
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 the gameplay stack below the persistent Discord line. // Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10; level.hud_x = 10;
level.hud_y = 30; level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
level.hud_row_match = level.hud_y; // The panel renderers below use dedicated row/column offsets instead.
level.hud_row_round = level.hud_y + 15; level.hud_row_match = level.hud_y;
level.hud_row_name = level.hud_y + 32; level.hud_row_round = level.hud_y + 15;
level.hud_row_count = level.hud_y + 54; level.hud_row_name = level.hud_y + 32;
level.hud_row_bar = level.hud_y + 84; level.hud_row_count = level.hud_y + 54;
level.hud_col_count = level.hud_x + 90;
level.hud_font_small = 1.4; level.hud_col_name = level.hud_x + 60;
level.hud_font_large = 2.0; level.hud_col_health = level.hud_x + 120 + 12;
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_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + level.hud_bar_width + 12;
level.hud_rate = 0.1;
// green/amber/red ramp is health-only
level.hud_c_label = (0.85, 0.85, 0.85);
level.hud_c_dim = (0.55, 0.55, 0.55);
level.hud_c_count = (1, 1, 1);
level.hud_c_name = (0.4, 0.9, 0.5);
level.hud_c_high = (0.2, 1, 0.4);
level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// numbers fade in below this fraction of max hp
level.hud_health_threshold = 1.0; 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_c_panel = (0.07, 0.10, 0.08);
level.hud_panel_alpha = 0.72;
level.hud_c_label = (0.72, 0.77, 0.74);
level.hud_c_dim = (0.65, 0.70, 0.67);
level.hud_c_count = (0.93, 0.96, 0.94);
level.hud_c_name = (0.65, 0.85, 0.70);
level.hud_c_high = (0.65, 0.85, 0.70);
level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
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");
+141 -174
View File
@@ -1,4 +1,4 @@
/* tws_t5_hud.gsc — top-left Discord URL, timers, zombie count and 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,38 +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);
scripts\sp\tws_t5_common::register_connect_handler(::hud_discord);
} }
// One static label per connection, including newcomers waiting to spawn. hud_chat_listener()
hud_discord() {
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"); self endon("disconnect");
label = scripts\sp\tws_t5_common::make_text_elem( if (isDefined(self.tws_hud_elems))
self, 10, 10, "left", level.hud_font_small, level.hud_c_label); return;
label SetText("https://discord.thewrightserver.net");
elems = []; self.tws_hud_elems = [];
elems[elems.size] = label; self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); 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");
@@ -52,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))
@@ -92,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)
@@ -102,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 (;;)
@@ -166,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");
@@ -201,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;
} }
@@ -253,117 +262,75 @@ 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)
colour = level.hud_c_mid; colour = level.hud_c_mid;
bar.color = colour; bar.color = colour;
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 = "";
+194 -24
View File
@@ -1,7 +1,7 @@
/* /*
tws_t5_lock.gsc — lobby g_password lock at milestone round. Existing players tws_t5_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
are unaffected; g_password is only checked at connect. Cleared on Existing players are unaffected; g_password is only checked at connect.
hud_game_over so the next lobby opens unlocked. A successful unlock overrides automatic locking for the rest of the match.
*/ */
#include maps\_utility; #include maps\_utility;
@@ -13,6 +13,8 @@ init()
return; return;
scripts\sp\tws_t5_common::ensure_config(); scripts\sp\tws_t5_common::ensure_config();
scripts\sp\tws_t5_common::register_help_command(".lock", "Vote to lock (6+)");
scripts\sp\tws_t5_common::register_help_command(".unlock", "Vote to unlock");
lock_init(); lock_init();
} }
@@ -21,47 +23,215 @@ lock_init()
{ {
level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20); level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20);
level.lock_active = false; level.lock_active = false;
level.lock_auto_done = false;
level.lock_ended = false;
level.lock_vote = undefined;
level.lock_vote_serial = 0;
// recover from a prior lobby that crashed before clearing // recover from a prior lobby that crashed before clearing
setDvar("g_password", ""); setDvar("g_password", "");
level thread lock_round_watcher(); level thread lock_round_watcher();
level thread lock_cleanup_watcher(); level thread lock_chat_listener();
level thread lock_vote_watcher();
level thread lock_cleanup_watcher("hud_game_over");
level thread lock_cleanup_watcher("end_game");
level thread lock_cleanup_watcher("game_ended");
level thread lock_cleanup_watcher("intermission");
} }
lock_round_watcher() lock_round_watcher()
{ {
level endon("hud_game_over"); level endon("tws_lock_game_over");
for (;;) for (;;)
{ {
level waittill("hud_round_changed"); level waittill("hud_round_changed");
lock_check_round();
if (level.lock_active)
continue;
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
continue;
// random per activation so the password can't be inferred from source
password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password);
level.lock_active = true;
lock_announce();
} }
} }
lock_cleanup_watcher() lock_check_round()
{ {
level waittill("hud_game_over"); if (lock_game_ended() || level.lock_auto_done)
setDvar("g_password", ""); return;
level.lock_active = false;
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
return;
// Consume the milestone once, even if players already locked early.
level.lock_auto_done = true;
if (!level.lock_active)
lock_set_state(true);
} }
lock_announce() lock_chat_listener()
{
level endon("tws_lock_game_over");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
command = toLower(message);
if (command == ".lock" || command == ".unlock")
lock_request(player, command);
}
}
lock_game_ended()
{
return level.lock_ended || is_true(level.intermission) ||
is_true(level.gameended) || is_true(level.tws_ended);
}
// This path never yields: each chat message casts at most one vote, and a
// successful vote changes the password before another request can run.
lock_request(player, command)
{
if (lock_game_ended())
return;
players = getPlayers();
present = false;
for (i = 0; i < players.size; i++)
{
if (players[i] == player)
present = true;
}
if (!present)
return;
lock_update_vote();
target = command == ".lock";
if (target == level.lock_active)
{
if (target)
player iPrintLn("^3Server is already locked");
else
player iPrintLn("^3Server is already unlocked");
return;
}
if (target && (!isDefined(level.round_number) || level.round_number <= 5))
{
player iPrintLn("^3.lock is available from round 6");
return;
}
if (!isDefined(level.lock_vote))
{
level.lock_vote_serial++;
vote = spawnStruct();
vote.id = level.lock_vote_serial;
vote.command = command;
vote.target = target;
vote.deadline = getTime() + 60000;
level.lock_vote = vote;
}
if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id)
{
player iPrintLn("^3You already voted " + command);
return;
}
// Connection-local fields cannot carry a vote across a reconnect.
player.tws_lock_vote_id = level.lock_vote.id;
if (lock_update_vote())
{
tally = lock_vote_tally();
lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required +
" votes. Type " + command + " to vote (60s window)");
}
}
lock_vote_tally()
{
tally = spawnStruct();
tally.yes = 0;
players = getPlayers();
tally.required = int(players.size / 2) + 1;
for (i = 0; i < players.size; i++)
{
if (isDefined(players[i].tws_lock_vote_id) &&
players[i].tws_lock_vote_id == level.lock_vote.id)
tally.yes++;
}
return tally;
}
// Recount the live roster so disconnected votes are dropped and newcomers
// count toward the majority. Expiry is checked before accepting any result.
lock_update_vote()
{
if (lock_game_ended() || !isDefined(level.lock_vote))
return false;
if (getTime() >= level.lock_vote.deadline)
{
command = level.lock_vote.command;
level.lock_vote = undefined;
lock_announce("^3" + command + " vote expired");
return false;
}
tally = lock_vote_tally();
if (tally.yes >= tally.required)
{
lock_set_state(level.lock_vote.target);
return false;
}
return true;
}
lock_vote_watcher()
{
level endon("tws_lock_game_over");
for (;;)
{
wait (0.5);
lock_update_vote();
}
}
lock_set_state(locked)
{
level.lock_vote = undefined;
if (locked)
{
// Random per activation so the password cannot be inferred from source.
password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password);
}
else
{
setDvar("g_password", "");
level.lock_auto_done = true;
}
level.lock_active = locked;
if (locked)
lock_announce("^3Server locked");
else
lock_announce("^3Server unlocked");
}
lock_cleanup_watcher(event)
{
level endon("tws_lock_game_over");
level waittill(event);
level.lock_ended = true;
level.lock_vote = undefined;
setDvar("g_password", "");
level.lock_active = false;
level notify("tws_lock_game_over");
}
lock_announce(message)
{ {
players = getPlayers(); players = getPlayers();
for (i = 0; i < players.size; i++) for (i = 0; i < players.size; i++)
players[i] iPrintLnBold("^3Server locked"); players[i] iPrintLn(message);
} }
+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;
}
}
+8
View File
@@ -129,3 +129,11 @@ dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14). restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser, Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims. storage and native registration adapter with sanitizers and engine shims.
## AFK journal API (`1.4.0+`)
The AFK component exports API `1.0.0` for immutable requests, reservations,
monotonic countdowns, durable stopped-session decisions and crash recovery.
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
credential, uses a separate `tws-afk` journal, and never stores credentials in
that journal. Installing this native change requires a full process restart.
@@ -0,0 +1,93 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/afk.hpp>
#include <array>
#include <chrono>
namespace afk
{
namespace
{
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
{ return {"tws-afk/t6",server,endpoint}; }
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
void storage_result(size_t operation, bool failed)
{
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
static std::array<status,9> states;
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
state.failed = failed; state.logged = now; state.suppressed = 0;
}
template <typename F> bool mutate(size_t operation, F action)
{
try { action(); storage_result(operation,false); return true; }
catch (...) { storage_result(operation,true); return false; }
}
}
class component final : public component_interface
{
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
{
scripting::array entries;
for (const auto& value : spool(server,endpoint).load())
{
scripting::array entry;
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0,false); return entries;
}
catch (...) { storage_result(0,true); return {}; }
});
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
{ return spool(server,endpoint).remaining(id); });
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
catch (...) { storage_result(6,true); return -1; }
});
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
gsc::function::add("afkresponse", [](const std::string& response)
{
const auto ack = utils::afk::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid; result["reason"] = ack.reason;
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
result["session_id"] = ack.session_id;
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
return result;
});
}
};
}
REGISTER_COMPONENT(afk::component)
@@ -0,0 +1,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;
};
}
@@ -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;
};
}
+99 -27
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,7 +9,7 @@ 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 |
@@ -17,7 +17,7 @@ telemetry and leaderboard modules; neither includes bank or database modules.
| 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 | | 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
@@ -137,15 +189,20 @@ wait for a normal round-start spawn, die, and verify `.join` is rejected.
Also check a round-1 participant, reconnecting, disconnect during setup, and Also check a round-1 participant, reconnecting, disconnect during setup, and
game over. Repeat on another T6 map; compile with the checker below first. game over. Repeat on another T6 map; compile with the checker below first.
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared Local: `tools/check.ps1` compiles all eight files for T6 PC and checks shared
references and entry points. It requires PowerShell and gsc-tool. The `.help` references and entry points. It requires PowerShell and gsc-tool. Run the
change passed the static entry-point/reference checks, but compilation and live [shared HUD checks](../../README.md#hud-validation) for core/service behavior and
gameplay were not run. Compiler success does not certify runtime linking. 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.
@@ -153,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.
+123 -28
View File
@@ -2,26 +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 telemetry token config is required. The optional bank needs its own separate telemetry token config is required. The optional bank and moderation modules each need their own
private server token. 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` | Top-left Discord URL, 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` | Auto-lock at round 20 plus majority `.lock` / `.unlock` votes; clear on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils | | `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils | | `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 | | `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
## Install ## Install
1. Copy all eight `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, leaderboard or bank, 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).
@@ -67,14 +70,51 @@ changing points. Stock T6
populates that field before the `connected` notification. Follow the shared populates that field before the `connected` notification. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation). [identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text ## Core HUD and service window
The HUD shows `https://discord.thewrightserver.net` in small light-gray text The unboxed core puts match time above round time at the upper left,
at the top-left, including for newcomers waiting in spectator mode. The label `TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
is created once per connection and persists across death and respawn. The slim health bar at the bottom center. `N ZOMBIES` is one centered element at
gameplay HUD starts one line lower, keeping timers, help and records clear. the health track's midpoint, using uniform font scale `1.25` so the whole group
Install HUD and common `1.1.0` together, then restart the map; no plugin update stays centered as the digit count changes. Its initial placeholder is
or full server-process restart is required. `-- 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
@@ -82,6 +122,21 @@ or full server-process restart is required.
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,
@@ -99,35 +154,56 @@ 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 with command and description
counter and health display. Typing either command again restarts that display time. columns. 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) |
| `.lock` | Vote to lock the lobby from round 6 (lock module required) |
| `.unlock` | Vote to unlock the lobby (lock 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`. The first nine commands follow
their module's initialization so the list stays current. the order above; additional entries follow registration order. Register new
commands alongside their module's initialization so the list stays current.
Lock and extended AFK commands appear on additional pages. Bank entries retain
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.
- Lock uses T5's HUD round/game-over notifications. Default threshold is 20. - Lock uses T5's HUD round notifications with a default automatic threshold of
As in T5, it clears `g_password` at startup and game over. 20. `.lock` is available from round 6; `.unlock` whenever locked. Both use a
60-second strict-majority vote (1/1, 2/2, 2/3, 3/4). A successful unlock
overrides auto-lock for the rest of the match. There is no round-6 prompt.
As in T5, it clears `g_password` and votes at startup and HUD/stock game over.
Lock `1.1.0` adds no dependency; copy the script and restart the map, without
a full server-process restart. See [voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6).
- Telemetry is enabled by default and posts to - Telemetry is enabled by default and posts to
`https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact, `https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact,
case-sensitive Electric Cherry registry name; missing or overlong names case-sensitive Electric Cherry registry name; missing or overlong names
@@ -211,7 +287,10 @@ another mod installed one first.
## Validation ## Validation
To compile all eight 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
@@ -219,3 +298,19 @@ To compile all eight 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', 'tws_t6_bank.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 eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' } 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 eight 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.'
+883 -68
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -19,7 +19,7 @@ init()
credentials = "valid"; credentials = "valid";
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_") if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
credentials = "invalid"; credentials = "invalid";
println("[tws_bank] startup script=2.0.2 plugin=" + bankVersion() + println("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) + " endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
" credentials=" + credentials); " credentials=" + credentials);
// A private per-server cfg supplies this; never embed bank credentials. // A private per-server cfg supplies this; never embed bank credentials.
@@ -184,7 +184,7 @@ bank_transfer(entry, args)
if (!isDefined(reply) || reply["state"] != "balance") if (!isDefined(reply) || reply["state"] != "balance")
bank_say(entry, "^3Bank unavailable. Your points are unchanged."); bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
else else
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points; available: ^7" + reply["amount"]); bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
return; return;
} }
if (args.size != 2) if (args.size != 2)
+607 -69
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;
// Fullscreen coordinates; reserve the top line for the Discord URL. // Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10; level.hud_x = 10;
level.hud_y = 30; level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
level.hud_row_match = level.hud_y; // The panel renderers below use dedicated row/column offsets instead.
level.hud_row_round = level.hud_y + 15; level.hud_row_match = level.hud_y;
level.hud_row_name = level.hud_y + 32; level.hud_row_round = level.hud_y + 15;
level.hud_row_count = level.hud_y + 54; level.hud_row_name = level.hud_y + 32;
level.hud_row_bar = level.hud_y + 84; level.hud_row_count = level.hud_y + 54;
level.hud_col_count = level.hud_x + 90;
level.hud_font_small = 1.4; level.hud_col_name = level.hud_x + 60;
level.hud_font_large = 2.0; level.hud_col_health = level.hud_x + 120 + 12;
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_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + level.hud_bar_width + 12;
level.hud_rate = 0.1;
// green/amber/red ramp is health-only
level.hud_c_label = (0.85, 0.85, 0.85);
level.hud_c_dim = (0.55, 0.55, 0.55);
level.hud_c_count = (1, 1, 1);
level.hud_c_name = (0.4, 0.9, 0.5);
level.hud_c_high = (0.2, 1, 0.4);
level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// numbers fade in below this fraction of max hp
level.hud_health_threshold = 1.0; 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_c_panel = (0.07, 0.10, 0.08);
level.hud_panel_alpha = 0.72;
level.hud_c_label = (0.72, 0.77, 0.74);
level.hud_c_dim = (0.65, 0.70, 0.67);
level.hud_c_count = (0.93, 0.96, 0.94);
level.hud_c_name = (0.65, 0.85, 0.70);
level.hud_c_high = (0.65, 0.85, 0.70);
level.hud_c_mid = (1, 0.8, 0);
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
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");
+128 -161
View File
@@ -1,4 +1,4 @@
/* tws_t6_hud.gsc - top-left Discord URL, timers, zombie count and 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,40 +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);
scripts\zm\tws_t6_common::register_connect_handler(::hud_discord);
} }
// One static label per connection, including newcomers waiting to spawn. hud_chat_listener()
hud_discord() {
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"); self endon("disconnect");
label = scripts\zm\tws_t6_common::make_text_elem( if (isDefined(self.tws_hud_elems))
self, 10, 10, "left", level.hud_font_small, level.hud_c_label); return;
label SetText("https://discord.thewrightserver.net");
elems = []; self.tws_hud_elems = [];
elems[elems.size] = label; self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); 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");
@@ -60,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");
@@ -93,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)
@@ -103,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)
@@ -171,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");
@@ -206,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 (;;)
@@ -234,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;
} }
@@ -245,108 +251,69 @@ 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)
colour = level.hud_c_mid; colour = level.hud_c_mid;
bar.color = colour; bar.color = colour;
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)
+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 = "";
+194 -24
View File
@@ -1,7 +1,7 @@
/* /*
tws_t6_lock.gsc — lobby g_password lock at milestone round. Existing players tws_t6_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
are unaffected; g_password is only checked at connect. Cleared on Existing players are unaffected; g_password is only checked at connect.
hud_game_over so the next lobby opens unlocked. A successful unlock overrides automatic locking for the rest of the match.
*/ */
#include maps\mp\_utility; #include maps\mp\_utility;
@@ -15,6 +15,8 @@ main()
init() init()
{ {
scripts\zm\tws_t6_common::ensure_config(); scripts\zm\tws_t6_common::ensure_config();
scripts\zm\tws_t6_common::register_help_command(".lock", "Vote to lock (6+)");
scripts\zm\tws_t6_common::register_help_command(".unlock", "Vote to unlock");
lock_init(); lock_init();
} }
@@ -23,47 +25,215 @@ lock_init()
{ {
level.lock_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20); level.lock_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20);
level.lock_active = false; level.lock_active = false;
level.lock_auto_done = false;
level.lock_ended = false;
level.lock_vote = undefined;
level.lock_vote_serial = 0;
// recover from a prior lobby that crashed before clearing // recover from a prior lobby that crashed before clearing
setDvar("g_password", ""); setDvar("g_password", "");
level thread lock_round_watcher(); level thread lock_round_watcher();
level thread lock_cleanup_watcher(); level thread lock_chat_listener();
level thread lock_vote_watcher();
level thread lock_cleanup_watcher("hud_game_over");
level thread lock_cleanup_watcher("end_game");
level thread lock_cleanup_watcher("game_ended");
level thread lock_cleanup_watcher("intermission");
} }
lock_round_watcher() lock_round_watcher()
{ {
level endon("hud_game_over"); level endon("tws_lock_game_over");
for (;;) for (;;)
{ {
level waittill("hud_round_changed"); level waittill("hud_round_changed");
lock_check_round();
if (level.lock_active)
continue;
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
continue;
// random per activation so the password can't be inferred from source
password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password);
level.lock_active = true;
lock_announce();
} }
} }
lock_cleanup_watcher() lock_check_round()
{ {
level waittill("hud_game_over"); if (lock_game_ended() || level.lock_auto_done)
setDvar("g_password", ""); return;
level.lock_active = false;
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
return;
// Consume the milestone once, even if players already locked early.
level.lock_auto_done = true;
if (!level.lock_active)
lock_set_state(true);
} }
lock_announce() lock_chat_listener()
{
level endon("tws_lock_game_over");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
command = toLower(message);
if (command == ".lock" || command == ".unlock")
lock_request(player, command);
}
}
lock_game_ended()
{
return level.lock_ended || is_true(level.intermission) ||
is_true(level.gameended) || is_true(level.tws_ended);
}
// This path never yields: each chat message casts at most one vote, and a
// successful vote changes the password before another request can run.
lock_request(player, command)
{
if (lock_game_ended())
return;
players = getPlayers();
present = false;
for (i = 0; i < players.size; i++)
{
if (players[i] == player)
present = true;
}
if (!present)
return;
lock_update_vote();
target = command == ".lock";
if (target == level.lock_active)
{
if (target)
player iPrintLn("^3Server is already locked");
else
player iPrintLn("^3Server is already unlocked");
return;
}
if (target && (!isDefined(level.round_number) || level.round_number <= 5))
{
player iPrintLn("^3.lock is available from round 6");
return;
}
if (!isDefined(level.lock_vote))
{
level.lock_vote_serial++;
vote = spawnStruct();
vote.id = level.lock_vote_serial;
vote.command = command;
vote.target = target;
vote.deadline = getTime() + 60000;
level.lock_vote = vote;
}
if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id)
{
player iPrintLn("^3You already voted " + command);
return;
}
// Connection-local fields cannot carry a vote across a reconnect.
player.tws_lock_vote_id = level.lock_vote.id;
if (lock_update_vote())
{
tally = lock_vote_tally();
lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required +
" votes. Type " + command + " to vote (60s window)");
}
}
lock_vote_tally()
{
tally = spawnStruct();
tally.yes = 0;
players = getPlayers();
tally.required = int(players.size / 2) + 1;
for (i = 0; i < players.size; i++)
{
if (isDefined(players[i].tws_lock_vote_id) &&
players[i].tws_lock_vote_id == level.lock_vote.id)
tally.yes++;
}
return tally;
}
// Recount the live roster so disconnected votes are dropped and newcomers
// count toward the majority. Expiry is checked before accepting any result.
lock_update_vote()
{
if (lock_game_ended() || !isDefined(level.lock_vote))
return false;
if (getTime() >= level.lock_vote.deadline)
{
command = level.lock_vote.command;
level.lock_vote = undefined;
lock_announce("^3" + command + " vote expired");
return false;
}
tally = lock_vote_tally();
if (tally.yes >= tally.required)
{
lock_set_state(level.lock_vote.target);
return false;
}
return true;
}
lock_vote_watcher()
{
level endon("tws_lock_game_over");
for (;;)
{
wait (0.5);
lock_update_vote();
}
}
lock_set_state(locked)
{
level.lock_vote = undefined;
if (locked)
{
// Random per activation so the password cannot be inferred from source.
password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password);
}
else
{
setDvar("g_password", "");
level.lock_auto_done = true;
}
level.lock_active = locked;
if (locked)
lock_announce("^3Server locked");
else
lock_announce("^3Server unlocked");
}
lock_cleanup_watcher(event)
{
level endon("tws_lock_game_over");
level waittill(event);
level.lock_ended = true;
level.lock_vote = undefined;
setDvar("g_password", "");
level.lock_active = false;
level notify("tws_lock_game_over");
}
lock_announce(message)
{ {
players = getPlayers(); players = getPlayers();
for (i = 0; i < players.size; i++) for (i = 0; i < players.size; i++)
players[i] iPrintLnBold("^3Server locked"); players[i] iPrintLn(message);
} }
+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).`);
+4 -4
View File
@@ -188,7 +188,7 @@ for (const game of ['t5','t6']) {
}); });
test('startup reports versions, scope, credential validation and journal count safely', () => { test('startup reports versions, scope, credential validation and journal count safely', () => {
const e = engine(game); const e = engine(game);
assert.match(e.logs.join('\n'), /script=2\.0\.2 plugin=1\.2\.0/); 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(`endpoint=${publicEndpoint} server=server credentials=valid`)));
assert(e.logs.some(line => line.includes('journal_load=success pending=0'))); assert(e.logs.some(line => line.includes('journal_load=success pending=0')));
const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}}); const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}});
@@ -304,11 +304,11 @@ for (const game of ['t5','t6']) {
const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0); 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', () => { 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.match(e.messages[0],/5000/); 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', () => { test('empty balance displays zero and withdrawing all reports insufficient balance', () => {
const e = engine(game, {amount:0, balance:0}); e.run(['.b']); const e = engine(game, {amount:0, balance:0}); e.run(['.b']);
assert.equal(e.messages[0], '^3Bank: ^70 ^3points; available: ^70'); assert.equal(e.messages[0], '^3Bank: ^70 ^3points');
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000); 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']); 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.match(w.messages[0], /Not enough bank balance/);
@@ -356,7 +356,7 @@ for (const game of ['t5','t6']) {
} }
assert.equal(ec.requests().length, 0); assert.equal(ec.requests().length, 0);
const e = engine(game, {guid, receiver:ec.request}); const e = engine(game, {guid, receiver:ec.request});
e.run(['.b']); assert.equal(e.messages[0], '^3Bank: ^70 ^3points; available: ^70'); 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/); 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'); 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.c.bank_sender().next(); assert.equal(e.saved.length, 0);
+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).`);
+716
View File
@@ -0,0 +1,716 @@
// 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} core and bank commands fit in one view without extra modules`, () => {
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} lock module registrations appear on the second help page`, () => {
const e = engine(game, 8, true);
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8');
for (const [, command, description] of source.matchAll(/register_help_command\("([^"]+)", "([^"]+)"\)/g))
e.c.register_help_command(command, description);
e.help();
assert.equal(e.state.help.pages, 2);
e.advance(12000);
assert.deepEqual(e.helpCommands, ['.lock', '.unlock']);
assert.deepEqual(e.helpDescriptions, ['Vote to lock (6+)', 'Vote to unlock']);
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
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.`);
+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);
} }
+270
View File
@@ -0,0 +1,270 @@
// Execute both lock scripts with chat, roster, clock and event shims. This does
// not emulate Plutonium's VM or prove that g_password prevents a real join.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
const coroutines = new Set(['lock_chat_listener', 'lock_round_watcher', 'lock_vote_watcher', 'lock_cleanup_watcher']);
function translate(source) {
const literals = [];
source = source.replace(/#include[^\n]*|"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (!token.startsWith('"')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const result = [], headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(source));) {
let end = headers.lastIndex, depth = 1;
while (depth) {
assert(end < source.length, `unclosed ${match[1]}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = source.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+)/g)].map(m => m[1]));
if (match[1] === 'lock_chat_listener') { locals.add('message'); locals.add('player'); }
body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '')
.replace(/#(__literal_\d+__)/g, '$1')
.replace(/\.size\b/g, '.length')
.replace(/level thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);')
.replace(/level endon\(([^;]+)\);/g, 'endon($1);')
.replace(/level waittill\(([^,;]+), message, player\);/g, '[message, player] = yield { event: $1 };')
.replace(/level waittill\(([^;]+)\);/g, 'yield { event: $1 };')
.replace(/level notify\(([^;]+)\);/g, 'emit($1);')
.replace(/wait \(([^;]+)\);/g, 'yield { delay: ($1) * 1000 };')
.replace(/(player|players\[i\]) iPrintLn\(/g, 'print($1, ')
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
result.push(`function${coroutines.has(match[1]) ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return result.join('\n');
}
function engine(game, count = 4, lockRound = 20) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8');
const level = { round_number: 6 }, players = [], tasks = [], messages = [], writes = [], help = [];
let now = 0, running, random = 0, password = 'stale-password';
function resume(task, args) {
const prior = running;
running = task;
const next = task.generator.next(args);
task.done = task.done || next.done;
task.wait = next.value;
task.at = next.value?.delay !== undefined ? now + next.value.delay : Infinity;
running = prior;
}
function startThread(fn, args) {
const task = { generator: fn(...args), ends: [] };
tasks.push(task);
resume(task);
}
function emit(event, ...args) {
const listeners = tasks.filter(t => !t.done);
for (const task of listeners) if (task.ends.includes(event)) task.done = true;
for (const task of listeners) if (!task.done && task.wait?.event === event) resume(task, args);
}
function advance(milliseconds) {
const until = now + milliseconds;
for (;;) {
const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
resume(next);
}
now = until;
}
const c = vm.createContext({
level, ensure_config() {}, register_help_command: (command, description) => help.push([command, description]),
GetDvar: () => '1', int_dvar_or: () => lockRound,
getPlayers: () => players.slice(), getTime: () => now,
isDefined: value => value !== undefined && value !== null,
is_true: value => value === true || value === 1,
spawnStruct: () => ({}), int: Math.trunc, randomInt: () => ++random,
toLower: value => value.toLowerCase(),
setDvar(name, value) { assert.equal(name, 'g_password'); password = value; writes.push(value); },
print: (player, text) => messages.push({ player, text }),
startThread, endon: event => running.ends.push(event), emit,
});
vm.runInContext(translate(source), c);
function connect() { const player = { sessionstate: 'playing' }; players.push(player); return player; }
for (let i = 0; i < count; i++) connect();
c.init();
return {
level, players, tasks, messages, writes, help, c, advance, connect, emit,
chat: (player, message) => emit('say', message, player),
round(number) { level.round_number = number; emit('hud_round_changed'); },
disconnect(player) { const index = players.indexOf(player); assert(index >= 0); players.splice(index, 1); },
get password() { return password; },
// Jump without running the timer to test expiry at the exact chat boundary.
jump(milliseconds) { now += milliseconds; },
};
}
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (cause) { throw new Error(name, { cause }); }
}
for (const game of ['t5', 't6']) {
check(`${game}: startup clears a stale lock and registers both commands quietly`, () => {
const e = engine(game);
assert.equal(e.password, '');
assert.deepEqual(e.help.map(([command]) => command), ['.lock', '.unlock']);
e.round(5); e.round(6); e.round(19);
assert.equal(e.messages.length, 0);
assert.equal(e.writes.length, 1);
});
for (const [count, required] of [[1, 1], [2, 2], [3, 2], [4, 3], [8, 5]]) {
for (const command of ['.lock', '.unlock']) {
check(`${game}: ${command} needs ${required}/${count} distinct voters`, () => {
const e = engine(game, count);
const target = command === '.lock';
if (!target) e.round(20);
for (let i = 0; i < required - 1; i++) {
e.chat(e.players[i], command);
e.chat(e.players[i], command);
assert.equal(e.level.lock_active, !target);
}
e.chat(e.players[required - 1], command.toUpperCase());
assert.equal(e.level.lock_active, target);
assert.equal(Boolean(e.password), target);
assert.equal(e.level.lock_vote, undefined);
const writes = e.writes.length;
e.chat(e.players[0], command);
assert.equal(e.writes.length, writes);
});
}
}
for (const round of [undefined, 0, 1, 4, 5]) {
check(`${game}: manual locking is unavailable at round ${round}`, () => {
const e = engine(game, 1);
e.round(round); e.chat(e.players[0], '.lock');
assert.equal(e.password, '');
assert.equal(e.level.lock_vote, undefined);
assert.match(e.messages.at(-1).text, /round 6/);
});
}
check(`${game}: exact chat commands only; missing and disconnected senders are ignored`, () => {
const e = engine(game, 1);
for (const text of ['hello .lock', '.lock extra', '.unlock extra', '.locked', undefined]) e.chat(e.players[0], text);
e.chat(undefined, '.lock'); e.chat({}, '.lock');
assert.equal(e.messages.length, 0);
assert.equal(e.password, '');
});
check(`${game}: round-20 auto-lock runs once; unlock persists across later rounds`, () => {
const e = engine(game, 1);
e.round(19); assert.equal(e.password, '');
e.round(20); assert(e.password);
const writes = e.writes.length;
e.round(20); e.round(21); assert.equal(e.writes.length, writes);
e.chat(e.players[0], '.unlock');
e.round(22); e.round(40); assert.equal(e.password, '');
e.chat(e.players[0], '.lock'); assert(e.password);
});
check(`${game}: early lock/unlock stays open through round 20; later relocks use a fresh password`, () => {
const e = engine(game, 1);
e.chat(e.players[0], '.lock'); const first = e.password;
e.chat(e.players[0], '.unlock');
e.round(20); e.round(21); assert.equal(e.password, '');
e.chat(e.players[0], '.lock'); assert(e.password); assert.notEqual(e.password, first);
});
check(`${game}: an already-open reply cannot cancel automatic locking`, () => {
const e = engine(game, 1);
e.chat(e.players[0], '.unlock');
e.round(20); assert(e.password);
});
check(`${game}: early lock is retained at the automatic milestone without another announcement`, () => {
const e = engine(game, 1);
e.chat(e.players[0], '.lock'); const writes = e.writes.length, messages = e.messages.length;
e.round(20); assert.equal(e.writes.length, writes); assert.equal(e.messages.length, messages);
e.chat(e.players[0], '.unlock'); e.round(21); assert.equal(e.password, '');
});
check(`${game}: custom automatic threshold below round 6 still permits unlocking`, () => {
const e = engine(game, 1, 3);
e.round(2); assert.equal(e.password, '');
e.round(3); assert(e.password);
e.chat(e.players[0], '.unlock'); assert.equal(e.password, '');
e.round(4); assert.equal(e.password, '');
e.chat(e.players[0], '.lock'); assert.equal(e.password, '');
});
check(`${game}: automatic lock clears an incomplete manual vote`, () => {
const e = engine(game);
e.round(19); e.chat(e.players[0], '.lock');
e.round(20); assert(e.password); assert.equal(e.level.lock_vote, undefined);
e.chat(e.players[1], '.unlock'); e.chat(e.players[2], '.unlock'); assert(e.password);
e.chat(e.players[0], '.unlock'); assert.equal(e.password, '');
});
check(`${game}: repeated and opposite commands do not extend or cancel a pending vote`, () => {
const e = engine(game);
e.chat(e.players[0], '.lock'); const deadline = e.level.lock_vote.deadline;
e.advance(30000); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.unlock');
assert.equal(e.level.lock_vote.deadline, deadline);
assert.equal(e.c.lock_vote_tally().yes, 1);
e.advance(30000); assert.equal(e.level.lock_vote, undefined); assert.equal(e.password, '');
e.chat(e.players[1], '.lock'); e.chat(e.players[2], '.lock'); assert.equal(e.password, '');
e.chat(e.players[0], '.lock'); assert(e.password);
});
check(`${game}: the exact deadline rejects old votes before processing chat`, () => {
const e = engine(game, 3);
e.chat(e.players[0], '.lock'); e.jump(60000); e.chat(e.players[1], '.lock');
assert.equal(e.password, ''); assert.equal(e.c.lock_vote_tally().yes, 1);
e.chat(e.players[0], '.lock'); assert(e.password);
});
check(`${game}: votes pass just before the deadline`, () => {
const e = engine(game, 2);
e.chat(e.players[0], '.lock'); e.advance(59999); e.chat(e.players[1], '.lock'); assert(e.password);
});
check(`${game}: departed voters do not count and reconnects get no inherited ballot`, () => {
const e = engine(game);
const departed = e.players[0];
e.chat(departed, '.lock'); e.disconnect(departed);
const newcomer = e.connect(); e.advance(500);
e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); assert.equal(e.password, '');
e.chat(newcomer, '.lock'); assert(e.password);
});
check(`${game}: roster shrink can complete a current majority`, () => {
const e = engine(game);
e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock');
e.disconnect(e.players[3]); e.advance(500); assert(e.password);
});
check(`${game}: roster growth raises the majority before accepting more votes`, () => {
const e = engine(game, 3);
e.chat(e.players[0], '.lock'); const newcomer = e.connect();
e.chat(e.players[1], '.lock'); assert.equal(e.password, '');
e.chat(newcomer, '.lock'); assert(e.password);
});
check(`${game}: an empty roster cannot pass a vote`, () => {
const e = engine(game, 2);
e.chat(e.players[0], '.lock'); e.players.splice(0); e.advance(500);
assert.equal(e.password, '');
});
check(`${game}: dead, spectator and AFK connections have equal votes`, () => {
const e = engine(game, 3);
e.players[0].sessionstate = 'spectator'; e.players[1].afk = true;
e.chat(e.players[0], '.lock'); assert.equal(e.password, '');
e.chat(e.players[1], '.lock'); assert(e.password);
});
for (const event of ['hud_game_over', 'end_game', 'game_ended', 'intermission']) {
check(`${game}: ${event} clears the password/vote and stops all workers`, () => {
const e = engine(game);
e.round(20); e.chat(e.players[0], '.unlock');
e.emit(event);
assert.equal(e.password, ''); assert.equal(e.level.lock_vote, undefined);
assert(e.tasks.every(t => t.done));
const writes = e.writes.length;
e.chat(e.players[1], '.lock'); e.c.lock_request(e.players[1], '.lock');
e.round(21); e.advance(61000); e.emit('hud_game_over');
assert.equal(e.writes.length, writes);
});
}
for (const flag of ['intermission', 'gameended', 'tws_ended']) {
check(`${game}: ${flag} blocks state changes before cleanup notifications arrive`, () => {
const e = engine(game, 2);
e.chat(e.players[0], '.lock'); e.level[flag] = true;
e.chat(e.players[1], '.lock'); e.disconnect(e.players[1]); e.advance(500); e.round(20);
assert.equal(e.password, '');
});
}
}
console.log(`PASS ${checks} T5/T6 lock vote/lifecycle checks (engine shims; live validation required)`);
+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)
+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>());
}
+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;
}
}
+16 -14
View File
@@ -1,20 +1,22 @@
{ {
"tws_t5_afk.gsc": "1.0.0", "tws_t5_afk.gsc": "2.1.0",
"tws_t5_bank.gsc": "2.0.2", "tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.1.0", "tws_t5_common.gsc": "1.5.0",
"tws_t5_hud.gsc": "1.1.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.1.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_bank.gsc": "2.0.2", "tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.1.0", "tws_t6_common.gsc": "1.5.0",
"tws_t6_hud.gsc": "1.1.0", "tws_t6_hud.gsc": "1.4.2",
"tws_t6_join.gsc": "1.0.1", "tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.0.0", "tws_t6_leaderboard.gsc": "1.2.0",
"tws_t6_lock.gsc": "1.0.0", "tws_t6_lock.gsc": "1.1.0",
"tws_t6_telemetry.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.2.0", "t5-plugin": "1.4.0",
"t6-plugin": "1.2.0" "t6-plugin": "1.4.0",
"tws_t5_moderation.gsc": "1.0.0",
"tws_t6_moderation.gsc": "1.0.0"
} }