Compare commits

..
3 Commits
39 changed files with 5065 additions and 846 deletions
+341
View File
@@ -4,6 +4,347 @@ Changes are listed newest first, with an independent version for every individua
T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning).
## 2026-10-15 — Player moderation runtime
- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional
paired scripts register current GUIDs and use the existing shared connection
dispatcher. Require common 1.2.0, the matching native plugin 1.3.0, and Electric
Cherry's instance-authenticated moderation endpoint/private configuration.
- **t5-plugin 1.3.0 / t6-plugin 1.3.0**: add the native moderation component and
policy/journal utilities, connection-bound synchronous actions, durable cached
bans/mutes and expiry/revocation, and an opt-in bounded HTTP response limit.
Existing HTTP callers keep their previous limit behavior. Sources are each
plugin's component/moderation.cpp, utils/moderation.*, and utils/http.*.
Both games change for matching functionality; their chat opcodes remain distinct.
- Deploy the receiver first; build and install both matching DLL/script pairs,
supply separate instance credentials, and fully restart game-server processes.
Persistent tws-moderation directories must survive restart. No deployment or
live Windows/Plutonium verification is claimed. See [runtime contract and
validation](docs/moderation.md).
- Validation: paired native moderation policy/adapter checks passed with ASan and
UBSan, including separate-process journal and session-sequence recovery. The
GSC identity and retry/disable/resume checks, native HTTP cap/error checks,
existing HTTP component, bank and telemetry checks, metadata/link validation,
and `git diff --check` passed. Native adapter tests use engine shims; no DLL
build, GSC compilation or live game validation was performed.
## 2026-10-15
### Replace the dashboard panels with unboxed HUD overlays
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.2.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.2.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both games use the supplied minimal layout: unlabeled match/round timers at
upper left, combined branding at top center, zombies above a slim health bar
at bottom center, and numeric current/maximum HP to its right only while
injured. Center the AFK indicator above the zombie count. Remove dashboard
backgrounds while retaining outlined text and health colors.
- Keep `.hud` as a private visibility toggle for the entire core and AFK
indicator. Hidden data updates, frozen game-over values, connection/spawn
lifecycle, AFK protection and stock HUD behavior are unchanged.
- Move services into an unboxed upper-right area below the Plutonium tag.
Records use a compact right-aligned `Map record · Round N` header and up to
three existing bounded holder lines, with no footer or single-page counter.
Record data, cache behavior, all tied holders and 12-second paging remain
unchanged; leaderboard source and versions are unchanged.
- Show all nine installed commands, including `.hud`, in one help list with
full `.d <amount|all>` / `.w <amount|all>` syntax. Both engines use explicit
command/description rows; extra registrations use pages of nine, with a
counter only for multi-page lists. Preserve private requests, help priority,
record pause/resume, replay replacement and 12-second page timing.
- Allocate only the active service view in both engines and destroy it when
idle or before switching kinds. Core + AFK use 11 + 1 archived elements;
nine-command help uses 19 nonarchived elements (20 with a multi-page counter),
and records use four (five with a multi-page counter). Idle holds none.
These counts do not establish live Plutonium rendering capacity.
- Common adds fullscreen overlay helpers using direct normalized `640 × 480`
positions and integer shader dimensions. Retain legacy panel helpers for
compatibility. Both HUD and AFK implementations use the new helpers.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 56 service/
static checks (71 total). Leaderboard passed 143 checks, bank 42, join 17 and
telemetry 23 through their portable check scripts. JSON uniqueness, the
16-script manifest, all six version/changelog matches, 66 local documentation
links/anchors and `git diff --check` passed. Checks use engine shims; actual
EC bank integration was not run. GSC compilation was not run because
gsc-tool/PowerShell are unavailable; live Windows/Plutonium validation remains
outstanding.
- Compatibility: HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`.
Both engines receive companion changes; leaderboard `1.1.0` remains
compatible. No new Plutonium, native-plugin or backend requirement.
- Deployment: copy each game's common, HUD and AFK scripts together where
installed, then restart the map. No full server-process restart or plugin
update is required. No deployment performed. Windows/Plutonium layout,
element capacity, stock HUD overlap and lifecycle validation remain required.
## 2026-10-14
### Release T5 service elements when records or help close
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.1` | `t5/scripts/tws_t5_common.gsc` |
- Address core HUD elements disappearing when the first record announcement
opens and remaining absent after it closes. The previous T5 service allocated
all 24 help/record elements at once and retained them while idle. That pattern
is consistent with HUD allocation pressure; the live T5 SP capacity has not
been established.
- Allocate only the active service view, with at most 12 elements for help or
11 for records. Release the elements when the service becomes idle and before
switching between help and records. Reuse them for pages within the same
view, and render the page indicator as one cached text element. The single
controller, help priority, paused record timing and request replacement remain
intact. Core elements and their visibility setting are unchanged.
- T6 needs no source change: the reported failure is on T5, whose explicit help
rows expanded its former allocation to 24 elements. T6 retains its smaller
18-element multiline renderer; validate its existing behavior separately.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 53 service/
static checks (68 total), including active-view allocation, idle release,
repeated service cycles and core preservation. Leaderboard passed 143 checks,
bank 42, join 17 and telemetry 23 through their portable check scripts. These
use engine shims; actual EC bank integration was not run. Live Windows/
Plutonium rendering validation was not performed.
- Compatibility: HUD `1.3.0`, AFK `1.1.0` and leaderboard `1.1.0` remain
compatible. No new dependency requirement. Deployment: copy only
`tws_t5_common.gsc` and restart the map. No plugin update or full server-process
restart is required. No deployment performed; live T5 validation must confirm
core integrity during automatic records, help, view switches and dismissal.
### Add a per-player HUD visibility command
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.3.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.3.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.3.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both HUD modules handle the exact, case-insensitive `.hud` chat command to
toggle that player's core branding, timers, zombie count, HP/bar and AFK
indicator. Visibility defaults on, persists across death/respawn on the
current connection/map, and resets on reconnect or map restart.
- Common tracks core elements and their intended alpha values so changing
visibility preserves each element's state. Data and timers continue updating
while hidden, and game-over values remain frozen. AFK routes only its
indicator visibility through these helpers; protection behavior is unchanged.
Help, records and stock HUD elements are unaffected.
- Register `.hud` in help only when the HUD module is installed. The full
command order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`,
`.d`, `.w`: nine commands across pages of four, four and one. T5 retains
explicit help rows; T6 retains multiline columns.
- Compatibility: HUD `1.3.0` and AFK `1.1.0` require their matching common
`1.3.0+`. Both engines receive companion implementations. No new Plutonium,
native-plugin or backend dependency requirement.
- Deployment: update each game's common, HUD and AFK scripts together where
installed, then restart the map. No plugin update or full server-process
restart is required. No deployment performed; GSC compilation and live
T5/T6 validation remain required.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 48 service/
static checks (63 total), covering private visibility, intended opacity,
late-created elements, hidden updates/frozen clocks, AFK transitions, service
independence, conditional help and three-page ordering. Leaderboard passed
143 checks, bank 42, join 17 and telemetry 23 through their existing portable
check scripts. These use engine shims; actual EC bank integration was not run.
GSC compiler and PowerShell were unavailable, and live Windows/Plutonium
validation was not performed.
### Render T5 help as separate rows
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.6` | `t5/scripts/tws_t5_common.gsc` |
- Replace T5's newline-separated help columns with four preallocated
command/description pairs, positioned on separate rows. This addresses BO1
rendering the newline text as one concatenated line. Reuse the row elements
across pages and hide unused rows on short pages.
- Preserve panel dimensions, four commands per page, 12-second timing and the
removed help footer. T5's service now uses 24 nonarchived HUD elements.
T6 needs no source change: its existing multiline renderer remains in place
with 18 service elements; the reported rendering issue was observed in T5.
- Validation: `node tools/check_hud.mjs` passed 8 core and 42 service checks
(50 total), covering both engines' pagination and T5's distinct row elements,
partial-page hiding, reuse, record transitions and disconnect cleanup.
GSC compilation and live T5 display/notification validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only the updated T5 common script and restart
the map. No plugin update or full server-process restart is required.
No deployment performed; live T5 display validation remains required.
### Remove the help footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.5` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.5` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove the `Four commands per page` footer and its HUD element.
Help still displays four commands per page with its page indicator and
existing timing. The shared service now uses 18 nonarchived elements.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including the 18-element allocation and removed help footer.
GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Hide the page indicator for single-page records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.4` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.4` | `t6/scripts/tws_t6_common.gsc` |
- Both games hide `1 / 1` when the record service has only one page. Records
spanning multiple pages and all help pages retain their page indicators.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including empty/unavailable records, multi-page indicators,
replacement by a single-page record, resuming after help, and single-page
help indicators. GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Restore panel backgrounds and health bars
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.3` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.3` | `t6/scripts/tws_t6_common.gsc` |
- Both engines apply the final integer conversion after `max()` in
`make_panel_width()`. Native `max()` returns a float, which `SetShader`
rejects for its integer width argument; converting before `max()` left
backgrounds and health-bar shaders missing. The corrected helper supplies
integer widths to both panel creation and health-bar updates.
- Retain panel color `(0.07, 0.10, 0.08)`, opacity `0.72`, and the corrected
text positions/font sizes. HUD source is unchanged and remains `1.2.1`.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total). The new regression models native float typing and strict integer
shader widths, and checks the existing panel color/opacity. GSC compiler and
PowerShell were unavailable; GSC compilation and live Windows/Plutonium display
validation were not performed.
- Compatibility: use matching common `1.2.3+` with HUD `1.2.1`; no new
Plutonium, native-plugin or backend dependency requirement. Deployment: if
HUD `1.2.1` is already installed, copy only each game's updated common script
and restart the map. No plugin update or full server-process restart is
required. No deployment performed; verify restored shaders in T5 and T6.
### Correct footer font size and screen-edge panel placement
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.2` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.2` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.2.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.2.1` | `t6/scripts/tws_t6_hud.gsc` |
- Both engines enforce a minimum text font scale of `1.0`, with both service
footers explicitly at `1.0`, addressing the oversized `.leaders` footer.
- Use fullscreen horizontal and vertical anchors so panels follow the actual
upper-right screen edge instead of safe-area insets. Convert horizontal
design offsets and shader widths by `0.75` into the normalized 640-wide
viewport; use a top offset of 20. The 1920×1080 reference yields a 495-pixel
panel, 22.5-pixel right margin and 45-pixel top margin, leaving space for the
Plutonium tag. Other aspect ratios retain normalized horizontal geometry,
so physical proportions vary; live font fit and tag clearance need validation.
- Both HUD scripts use common's new `make_panel_width(width)` for health-bar
updates so the fill uses the same width conversion as its track. T5 and T6
receive the same fix with their matching namespaces.
- Compatibility: HUD `1.2.1` requires matching common `1.2.2+`; upgrade common
and HUD together. No new Plutonium, native-plugin or backend dependency is
required.
- Deployment: copy each game's updated common and HUD scripts and restart the
map. No full server-process restart is required. No deployment performed;
GSC compilation and live Windows/Plutonium display validation remain required.
- Validation: `node tools/check_hud.mjs` passed 8 core and 39 service/static
geometry checks (47 total); `node tools/check_leaderboard.mjs` passed 143
checks. The geometry shims cover 4:3, 16:9 and ultrawide bounds in a normalized
640×480 frame, not live glyph fit or tag clearance. GSC compiler and PowerShell
were unavailable; no GSC compilation or live display validation was performed.
### Shorter map-record footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.1` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove `Briefly shown` and its separator from the map-record
footer, retaining `.leaders to view again`. Record timing and replay behavior
are unchanged.
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks
using engine shims. GSC compilation and live Plutonium display validation
were not run.
- Compatibility: no new dependency requirement. Deployment: copy each game's
updated common script and restart the map. No plugin update or full
server-process restart is required. No deployment performed; GSC compilation
and live T5/T6 display validation remain required.
### Fixed upper-right core HUD and shared service window
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.2.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.2.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_leaderboard.gsc` | `1.1.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.1.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_afk.gsc` | `1.0.1` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.0.1` | `t6/scripts/tws_t6_afk.gsc` |
- Both engines use a fixed upper-right core panel below the top edge, with
`TWS ZOMBIES`, `discord.tws.lol`, side-by-side match/round timers, zombie count,
current/maximum HP even at full health, and a full-width health bar. Replace
the co-op name row with reserved footer space for the AFK indicator. Core
creation on connection supports spectator placeholders without seeding the
match clock early. Preserve each engine's timer behavior and frozen clocks at
game end; health and zombie-count workers now freeze on both engines as well.
Missing max-health values use a display fallback without modifying gameplay health.
- Common owns one fixed-height service window below the core. Help shows four
commands per page for 12 seconds each, in stable order and filtered by loaded
modules. `.help` / `.commands` restart at page one. The final page dismisses
automatically; opening a service never shifts or resizes the core.
- Render help with two fixed multiline text columns and a muted command-column
backdrop. The service uses 19 nonarchived HUD elements regardless of command
or page count; the core and AFK use 15 + 1 archived elements. Reuse text and
numeric displays instead of allocating elements for every command/page.
- Records use the same window, with a prominent round, page count, replay hint,
and up to three holder lines per page. Abbreviate overlong individual names
without splitting UTF-8 characters; retain every tied holder across bounded
lines and pages. Help pauses records and resumes their remaining page time;
`.leaders` during help replaces the pending record from page one. Unavailable
records are distinct from an empty recorded history. Help and records remain
private and work without the HUD module. Reused elements and request state
protect newer displays from stale timers and clean up on disconnect/game end.
- Compatibility: HUD `1.2.0`, leaderboard `1.1.0`, and AFK `1.0.1` require the
matching game's common `1.2.0+`. Existing common helper signatures remain
available for unchanged consumers. No new native-plugin, backend or external
dependency requirement; existing leaderboard HTTP/JSON requirements still apply.
Both T5 and T6 receive companion implementations adapted to their engines.
- Deployment: copy each game's matching common, HUD, leaderboard and AFK scripts
that are installed, then restart the map. No plugin update or full server-process
restart is required for this change. No deployment performed.
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks;
`node tools/check_leaderboard.mjs` passed 143 checks, `node tools/check_bank.mjs`
passed 42 fixture groups, `node tools/check_join.mjs` passed 17 checks, and
`node tools/check_telemetry.mjs` passed 23 checks. These use engine shims.
No local GSC compiler or PowerShell was available; GSC compilation and live
Windows/Plutonium validation remain required. Follow the
[HUD validation guide](README.md#hud-validation) before rollout.
## 2026-10-13
### Shorter Discord URL
+143 -11
View File
@@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal
changes, the minor for compatible features, and the major for breaking changes
to commands, configuration, native APIs, or data contracts. Reset lower fields
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
versions are separate from upstream revisions in each plugin's `upstream.json`
@@ -359,16 +359,134 @@ For this upgrade also verify stale URL settings are ignored, an empty `.b`
shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal
settles legacy and public journals without duplicate ledger entries or lost points.
## Core HUD and service window (T5 and T6)
The core uses a light, unboxed layout: match time above round time at the upper
left, `TWS ZOMBIES · discord.tws.lol` centered along the top, and the zombie
count centered near the bottom above a slim health bar. Current/maximum HP
appears to the right of the bar only while injured, then disappears at full
health. The AFK indicator sits above the zombie count. Text uses an outline for
contrast; there are no panel backgrounds or timer labels.
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, damage-only HP and AFK indicator. 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 all nine installed commands in one list with command
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
restriction. Additional registered commands use pages of nine rows; a page
count appears only when more than one page is needed. Each page lasts
12 seconds and the list closes after the final page. Repeating either help
command restarts page one. Both engines position rows explicitly, avoiding
T5's newline-rendering problem.
Records use the same service area, right aligned: `Map record · Round N`
followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. Help pauses a visible record and resumes its remaining
page time when help ends. `.leaders` during help replaces the pending record
with a fresh replay from page one. One service is visible at a time. Services
remain private and work when the HUD module is omitted.
Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's
elements. The core uses 11 archived elements, plus one for AFK. The standard
nine-command help view uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements
still occupy slots. The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+` on both games.
Update common, HUD and AFK together where installed, then restart the map.
Leaderboard `1.1.0` remains compatible. There is no new Plutonium, native-plugin
or backend requirement, and this change does not require a full server-process
restart. Existing leaderboard plugin requirements still apply. Common retains
its older panel helpers for unchanged consumers; the new layout uses fullscreen
overlay helpers with the same integer shader-width handling.
### HUD validation
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
request replacement and cleanup checks, and `node tools/check_leaderboard.mjs`
for record data handling. Run `node tools/check_bank.mjs`,
`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.
2. Join before spawning, join mid-match, die/respawn and reconnect. Verify one
core per player, spectator placeholders, no early match-clock start, correct
health/Juggernog changes, and frozen timers at game over. HP numbers must
appear only while injured and disappear on full healing. Toggle AFK and back
without overlapping zombies/health or changing existing protection behavior.
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
one list of nine commands, full bank syntax, no single-page counter and
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 AFK indicator,
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
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
**along with the updated common module** for that game.
The new module privately announces the current map's fleet-wide highest recorded
**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`.
The module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers.
Type `.leaders` to replay the record privately from the latest usable cache.
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
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;
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.
@@ -390,13 +508,18 @@ 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.
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`,
below the health bar, with a heading such as `Record: Round 23` and holder names
underneath. Each page shows up to three holder lines for 12 seconds. Opening
`.help` hides the record and pauses its display time until help closes.
All holders are included; colour/control codes are removed, and unusually long
names are shortened to keep messages readable. HUD elements are removed when
the announcement finishes, the player disconnects, or the game ends.
initial record fetch if necessary. It appears as right-aligned text in the
common-owned upper-right service area: `Map record · Round N`, followed by up
to three holder lines. There is no background or replay footer, and a page
count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining
page time until help closes. A `.leaders` request during help replaces any
pending replay and starts from page one after help closes.
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.
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.
@@ -468,3 +591,12 @@ powershell -File t6/scripts/tools/check.ps1 -CompilerPath C:\path\to\gsc-tool.ex
```
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.
+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.
@@ -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)
@@ -14,13 +14,17 @@ namespace utils::http
{
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
{
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;
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
try
{
buffer->append(static_cast<char*>(contents), total_size);
buffer->bytes.append(static_cast<char*>(contents), total_size);
}
catch (...)
{
@@ -68,7 +72,7 @@ namespace utils::http
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_URL, url.data());
@@ -106,7 +110,7 @@ namespace utils::http
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
if (response.code == CURLE_OK)
response.buffer = std::move(buffer);
response.buffer = std::move(buffer.bytes);
return response;
}
}
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <cstddef>
#include <optional>
#include <unordered_map>
@@ -25,6 +26,8 @@ namespace utils::http
{
long connect_timeout_ms = 5000;
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>;
@@ -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;
};
}
+60 -5
View File
@@ -32,9 +32,12 @@ before copying the renamed scripts.
- **`SetTimerUp(N)` only ticks when `N == 0`** on T5 SP. Non-zero seeds
render "0:00" but don't advance. Late joiners see 0, not real elapsed;
authoritative elapsed lives in `level.match_start_time`.
- **Dynamic text: use `SetValue`, not `SetText`.** `SetText` allocates a
config string per call; per-tick rebuilds overflow and take the server
down. Composite strings like `"87/160"` are three elements.
- **Dynamic numbers use `SetValue`.** Repeatedly generating unique strings
risks exhausting the text config-string pool; identical strings can be reused
by the engine's [string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46).
Composite HP uses numeric elements around a static slash and `HP` suffix;
the group appears only while injured. Help reuses up to nine explicitly
positioned command/description pairs, and record rows skip unchanged text.
- **`NewClientHudElem(player)`, not `NewHudElem()`.** `NewHudElem` is
globally owned; in co-op every player's HUD draws on top of every other
player's. Fall back to `NewHudElem` only if elements don't render at all
@@ -56,18 +59,70 @@ before copying the renamed scripts.
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
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
11 + 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
notifies** (`end_game` / `game_ended` / `intermission`) since BO1 maps
are inconsistent. Per-player triggers do not work — `self waittill("death")`
is intercepted by the zombiemode scripts before it reaches our scope, and
polling `self.health` misses the zero-crossing (health is rewritten during
the game-over sequence).
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`. Update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
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. There is no new Plutonium/native-plugin/backend
dependency or full server-process restart requirement.
Run the [shared HUD checks](../../README.md#hud-validation); portable fixtures
do not replace T5 live loading, layout and lifecycle validation.
## Module map
| 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_hud` | Timer row, zombie count, health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) |
| `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` | 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_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` |
+130 -44
View File
@@ -1,7 +1,7 @@
# TWS T5 Zombies scripts
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.
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
@@ -10,18 +10,21 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| File | Role |
|---|---|
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, `.help`, 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_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, paginated `.help`, service window, connect/spawn registries, single `connected` listener + dispatcher |
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator |
| [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
| [tws_t5_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
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
that directory:
@@ -54,14 +57,45 @@ requests or journal writes, preserving existing strings exactly. Missing or
invalid identities fail privately without changing points. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text
## Core HUD and service window
The HUD shows `https://discord.tws.lol` in small muted-gray text
at the top-left, including for newcomers waiting in spectator mode. The label
is created once per connection and persists across death and respawn. The
gameplay HUD starts one line lower, keeping timers, help and records clear.
Install HUD `1.1.2` with common `1.1.0` or later, then restart the map; no plugin
update or full server-process restart is required.
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. Current/maximum HP appears to the right
of the bar only while injured; it disappears at full health. The AFK indicator
sits above the zombie count. There are no panel backgrounds or timer labels.
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 and AFK indicator.
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 full nine-command list in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter), or four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 11 + 1 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
## Map record announcements
@@ -83,22 +117,35 @@ apply to both games.
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,
before the first spawn. Commands are case-insensitive.
Help appears below the health bar for 12 seconds, clear of the timers, zombie
counter and health display. Typing either command again restarts that display time.
Help uses the unboxed upper-right service area. All nine installed commands
fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either
command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text.
| Command | Effect |
|---|---|
| `.help`, `.commands` | List available commands |
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
| `.afk` | Pause with protection |
| `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) |
| `.leaders` | Show the map record again (leaderboard module required) |
| `.b` | Check bank balance (bank module required) |
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
Each module registers its own help entries; removing AFK or join also removes
those entries from `.help` after a map restart.
Each module registers its own help entries. Commands follow the order above,
independent of initialization order; removing a module also removes its entries
from `.help` after a map restart.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
removes `.hud` from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest
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
@@ -148,16 +195,20 @@ graph BT
## 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
connect listener anywhere else crashes the T5 SP handshake.
**Features**
- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`,
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold`
- Shared HUD constants for overlay positions, rows/columns, fonts, health bar,
colors and update rate
- Spawn-handler registry
- 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
`level.tws_spawn_handlers` after `spawned_player` and threads each on
the player
@@ -172,6 +223,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()` |
| `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_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 |
| `int_dvar_or(name, fallback)` | Parse an integer dvar with default |
| `format_mmss(elapsed_ms)` | Format milliseconds as `M:SS` |
@@ -180,27 +244,41 @@ connect listener anywhere else crashes the T5 SP handshake.
**Live validation (requires a T5 server)**
1. Type `.help` and `.HELP` while playing and while spectating before the first
spawn. Verify all four descriptions appear below the health bar, only for the
requesting player. Take damage so health numbers are visible and check that
they do not cover the help text.
1. Type `.help`, `.HELP` and `.commands` while playing and spectating before the
first spawn. Verify all nine commands appear in one unboxed list, private to
the requesting player. Check explicit rows, full bank syntax and the absence
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
`.help` and the remaining commands still work. Restore the scripts afterward.
3. Repeat `.help` before 12 seconds elapse; verify one list stays visible for
12 seconds after the latest request, then hides. Reopen it after expiry,
die/respawn and reconnect; verify no duplicate text or leftover help elements.
3. Verify help closes after 12 seconds. Add custom registered commands to test
the nine-row page limit, counter and repeat-to-restart behavior. Interrupt a record
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.
---
## 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**
- Match timer + round timer (round resets each round, both freeze on game over)
- Player-name row (only visible in co-op)
- Zombies-remaining counter
- Health bar with green / amber / red ramp; numbers fade in below full HP
- Upper-left match timer above round timer (round resets each round, both freeze on game over)
- `.hud` toggles this player's core and AFK indicator; data updates continue while hidden
- Top-center `TWS ZOMBIES · discord.tws.lol` branding
- Bottom-center zombies-remaining counter, with the AFK indicator above it
- 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
**Provides**
@@ -214,22 +292,25 @@ Bottom-left cluster. Per-player elements spawn via `::hud_start`.
Spawn handler registered: `::hud_start` — threads `hud_timer_row`,
`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**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`,
`make_shader_elem`, `destroy_on_disconnect`, `format_mmss`,
`register_spawn_handler`
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`make_core_overlay_shader`, `make_overlay_width`, `set_core_alpha`, `toggle_core_hud`,
`destroy_on_disconnect`, `format_mmss`, `register_help_command`,
`register_spawn_handler`, `register_connect_handler`
- Every `level.hud_*` config value from `tws_t5_common`
- Stock BO1 state: `level.round_number`, `level.zombie_total`,
`level.players`, `self.health`, `self.maxhealth`, `self.name` /
`self.playername`, AI entities' `.animname` and `.ignore_enemy_count`
`level.players`, `self.health`, `self.maxhealth`, AI entities' `.animname`
and `.ignore_enemy_count`
---
## tws_t5_afk.gsc
Self-invoked pause mode. Owns the `[AFK]` indicator element entirely — HUD
module has zero knowledge of AFK.
Self-invoked pause mode. Owns the `[AFK]` indicator element, centered above the
zombie count through common layout values. It does not require HUD.
**Features**
- Chat commands `.afk` and `.back` (via `level waittill("say")` — not
@@ -239,7 +320,7 @@ module has zero knowledge of AFK.
- On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through
the target-reacquisition window
- `[AFK]` HUD element created per-player via spawn handler; alpha-toggled
by enter / exit
by enter / exit through common's core visibility helpers, respecting `.hud`
**Provides**
- Per-player: `self.hud_afk_on` (bool), `self.hud_afk_elem` (HUD element),
@@ -247,10 +328,9 @@ module has zero knowledge of AFK.
- 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`
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`set_core_alpha`, `destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
- Shared config: overlay AFK position, `level.hud_font_afk`, `level.hud_c_afk`
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
@@ -518,3 +598,9 @@ 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
the natives listed in each module's documentation, then restart the server
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.
+6 -6
View File
@@ -50,11 +50,11 @@ afk_spawn()
{
self endon("disconnect");
afk = scripts\sp\tws_t5_common::make_text_elem(
self, level.hud_x + 42, level.hud_row_match,
"left", level.hud_font_small, level.hud_c_afk);
afk = scripts\sp\tws_t5_common::make_core_overlay_text(
self, level.hud_overlay_afk_x, level.hud_overlay_afk_y,
"center", 1.0, level.hud_c_afk);
afk SetText("[AFK]");
afk.alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, afk, 0);
self.hud_afk_elem = afk;
elems = [];
@@ -75,7 +75,7 @@ afk_enter()
self freezeControls(true);
if (isDefined(self.hud_afk_elem))
self.hud_afk_elem.alpha = 1;
scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 1);
self iPrintLnBold("AFK — type .back to return");
}
@@ -91,7 +91,7 @@ afk_exit()
self freezeControls(false);
if (isDefined(self.hud_afk_elem))
self.hud_afk_elem.alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 0);
self iPrintLnBold("Back in the fight");
+575 -69
View File
@@ -3,6 +3,11 @@
#include maps\_utility;
#include common_scripts\utility;
main()
{
precacheShader("white");
}
init()
{
if (GetDvar(#"zombiemode") != "1")
@@ -11,6 +16,9 @@ init()
common_config();
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 on_player_connect_dispatch();
}
@@ -20,41 +28,84 @@ common_config()
if (isDefined(level.hud_x))
return;
// Anchor the gameplay stack below the persistent Discord line.
// Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10;
level.hud_y = 30;
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54;
level.hud_row_bar = level.hud_y + 84;
level.hud_font_small = 1.4;
level.hud_font_large = 2.0;
level.hud_font_mid = 1.6;
level.hud_bar_width = 120;
level.hud_bar_height = 8;
level.hud_col_count = level.hud_x + 90;
level.hud_col_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
// Retain the legacy fields for consumers of the original common config.
// The panel renderers below use dedicated row/column offsets instead.
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54;
level.hud_col_count = level.hud_x + 90;
level.hud_col_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + 120 + 12;
level.hud_health_threshold = 1.0;
level.hud_panel_right = 10;
level.hud_panel_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
level.hud_service_height = 124;
level.hud_pad = 10;
level.hud_row_header = 10;
level.hud_row_timers = 34;
level.hud_row_stats = 59;
level.hud_row_bar = 80;
level.hud_row_afk = 86;
level.hud_font_small = 1.1;
level.hud_font_large = 1.6;
level.hud_font_mid = 1.25;
level.hud_font_afk = 1.0;
level.hud_bar_width = 200;
level.hud_bar_height = 4;
level.hud_rate = 0.1;
level.hud_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))
level.tws_spawn_handlers = [];
@@ -111,58 +162,371 @@ help_chat_listener()
}
}
// A single per-player controller owns service priority, pages and expiry.
show_help()
{
// Cache the text per connection; repeated SetText calls consume config strings.
if (!isDefined(self.tws_help_elems))
{
elems = [];
// Use the same anchors as the HUD, with room below the health numbers.
y = level.hud_row_bar + level.hud_bar_height + 20;
title = make_text_elem(self, level.hud_x, y,
"left", level.hud_font_mid, level.hud_c_afk);
title SetText("Commands");
elems[elems.size] = title;
if (isDefined(level.tws_service_ended))
return;
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
state.generation++;
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);
}
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;
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++)
{
entry = level.tws_help_commands[i];
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
elem SetText("^3" + entry.command + "^7 - " + entry.description);
elems[elems.size] = elem;
if (help_rank(entry.command) == rank)
entries[entries.size] = entry;
}
self.tws_help_elems = elems;
self thread destroy_on_disconnect(elems);
}
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();
return entries;
}
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);
for (i = 0; i < 3; 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;
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("tws_help_refresh");
wait 12;
for (i = 0; i < self.tws_help_elems.size; i++)
level endon("tws_service_game_over");
for (;;)
{
elem = self.tws_help_elems[i];
elem.alpha = 0;
service_advance(state, getTime());
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()
{
for (;;)
@@ -211,7 +575,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x;
elem.y = y;
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.color = colour;
@@ -238,6 +603,147 @@ make_shader_elem(player, x, y)
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)
{
self waittill("disconnect");
+139 -173
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 common_scripts\utility;
@@ -9,38 +9,108 @@ init()
return;
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 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_connect_handler(::hud_discord);
}
// One static label per connection, including newcomers waiting to spawn.
hud_discord()
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\sp\tws_t5_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
{
self endon("disconnect");
label = scripts\sp\tws_t5_common::make_text_elem(
self, 10, 10, "left", 1.2, (0.65, 0.65, 0.65));
label SetText("https://discord.tws.lol");
if (isDefined(self.tws_hud_elems))
return;
elems = [];
elems[elems.size] = label;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
self.tws_hud_elems = [];
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// 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()
{
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_zombie_counter();
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()
{
level thread game_over_via("end_game");
@@ -52,17 +122,14 @@ game_over_via(notify_name)
{
level endon("hud_game_over");
level waittill(notify_name);
level.tws_ended = true;
level notify("hud_game_over");
}
/*
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).
*/
// Pre-set round 1 is seeded on first spawn; only observed transitions seed here.
round_watcher()
{
level endon("hud_game_over");
last = 0;
if (isDefined(level.round_number))
@@ -92,7 +159,7 @@ hud_timer_row()
{
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) &&
isDefined(level.round_number) &&
level.round_number >= 1)
@@ -102,59 +169,17 @@ hud_timer_row()
level notify("hud_match_started");
}
match = scripts\sp\tws_t5_common::make_text_elem(
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 = self.tws_hud_round;
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 match_timer_bind(match);
self thread freeze_timers_on_game_over(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);
}
self thread match_timer_bind(self.tws_hud_match);
self thread freeze_timers_on_game_over(self.tws_hud_match, round);
}
reset_timer_each_round(timer)
{
level endon("hud_game_over");
self endon("disconnect");
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)
{
self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(level.match_start_time))
level waittill("hud_match_started");
if (isDefined(elem))
{
elem SetTimerUp(0);
elem.alpha = 1;
}
}
/*
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.
*/
// Preserve authoritative frozen clocks when the core is shown after game over.
freeze_timers_on_game_over(match_elem, round_elem)
{
self endon("disconnect");
@@ -201,27 +220,16 @@ freeze_timers_on_game_over(match_elem, round_elem)
hud_zombie_counter()
{
self endon("disconnect");
level endon("hud_game_over");
label = scripts\sp\tws_t5_common::make_text_elem(
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);
count = self.tws_hud_count;
last = -1;
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)
{
@@ -253,117 +261,75 @@ get_enemy_count()
hud_health_display()
{
self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(self.maxhealth) || self.maxhealth <= 0)
self.maxhealth = 100;
bar_width = level.hud_bar_width;
// -2 on both axes so the back frames the bar with 2px padding
back = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2);
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;
bar_width = level.hud_overlay_bar_width;
bar = self.tws_hud_bar;
cur = self.tws_hud_health;
slash = self.tws_hud_slash;
max_hp = self.tws_hud_maxhealth;
hp = self.tws_hud_hp;
last_width = -1;
last_health = -1;
last_max = -1;
last_shown = -1;
last_max = -1;
for (;;)
{
if (!isDefined(self.maxhealth) || self.maxhealth <= 0)
self.maxhealth = 100;
frac = self.health / self.maxhealth;
// Display fallbacks must not alter gameplay health values.
maximum = 100;
if (isDefined(self.maxhealth) && self.maxhealth > 0)
maximum = self.maxhealth;
current = 0;
if (isDefined(self.health))
current = max(0, self.health);
frac = current / (maximum * 1.0);
if (frac > 1)
frac = 1;
if (frac < 0)
frac = 0;
width = int(max(frac * bar_width, 1));
width = scripts\sp\tws_t5_common::make_overlay_width(frac * bar_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;
}
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)
colour = level.hud_c_low;
else if (frac <= 0.5)
colour = level.hud_c_mid;
bar.color = colour;
cur.color = colour;
slash.color = colour;
bar.color = colour;
cur.color = colour;
slash.color = colour;
max_hp.color = colour;
hp.color = colour;
// numbers only show once you're hurt
show = 0;
readout_alpha = 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)
show = 1;
if (show != last_shown)
// Keep health numbers current even at full health or while hidden.
if (current != last_health)
{
cur.alpha = show;
slash.alpha = show;
max_hp.alpha = show;
last_shown = show;
cur SetValue(current);
last_health = current;
}
if (self.health != last_health)
if (maximum != last_max)
{
cur SetValue(self.health);
last_health = self.health;
}
if (self.maxhealth != last_max)
{
max_hp SetValue(self.maxhealth);
last_max = self.maxhealth;
max_hp SetValue(maximum);
last_max = maximum;
}
wait (level.hud_rate);
+72 -112
View File
@@ -124,120 +124,17 @@ lb_request(manual)
level endon("game_ended");
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.
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self lb_show_hud(snapshot);
}
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
lb_show_hud(snapshot)
{
// Stop the previous display and its cleanup threads before replacing it.
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();
}
// Common owns pagination, help priority and the single service window.
// Undefined snapshots produce a private unavailable page for manual replay.
self scripts\sp\tws_t5_common::show_record(snapshot);
}
lb_refresh()
@@ -480,22 +377,85 @@ lb_holder_lines(names)
{
lines = [];
line = "";
columns = 0;
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;
line = "";
columns = 0;
}
if (line != "")
{
line += ", ";
line += names[i];
columns += 2;
}
line += name;
columns += width;
}
if (line != "")
lines[lines.size] = line;
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)
{
output = "";
+63
View File
@@ -0,0 +1,63 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1")
return;
scripts\sp\tws_t5_common::ensure_config();
level.moderation_ready = false;
scripts\sp\tws_t5_common::register_connect_handler(::moderation_connected);
level thread moderation_loop();
}
moderation_connected()
{
self endon("disconnect");
if (!level.moderation_ready)
return;
moderation_observe(self);
moderationPump();
}
moderation_observe(player)
{
if (!isDefined(player))
return;
guid = player getGuid();
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return;
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
guid = "" + guid;
name = player.name;
if (!isDefined(name))
name = "Unknown player";
player moderationObserve(guid, name);
}
moderation_loop()
{
for (;;)
{
if (GetDvar("tws_moderation_enabled") == "0")
{
moderationStop();
level.moderation_ready = false;
wait 3;
continue;
}
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
if (!level.moderation_ready)
{
wait 3;
continue;
}
players = getPlayers();
for (i = 0; i < players.size; i++)
moderation_observe(players[i]);
moderationPump();
wait 0.5;
}
}
@@ -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)
@@ -14,13 +14,17 @@ namespace utils::http
{
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
{
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;
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
try
{
buffer->append(static_cast<char*>(contents), total_size);
buffer->bytes.append(static_cast<char*>(contents), total_size);
}
catch (...)
{
@@ -68,7 +72,7 @@ namespace utils::http
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_URL, url.data());
@@ -106,7 +110,7 @@ namespace utils::http
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
if (response.code == CURLE_OK)
response.buffer = std::move(buffer);
response.buffer = std::move(buffer.bytes);
return response;
}
}
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <cstddef>
#include <optional>
#include <unordered_map>
@@ -25,6 +26,8 @@ namespace utils::http
{
long connect_timeout_ms = 5000;
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>;
@@ -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;
};
}
+91 -27
View File
@@ -1,7 +1,7 @@
# T6 port notes
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
@@ -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 |
| 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 |
| 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 |
@@ -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 |
| 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
notifications, applies T5's random password, and clears it on startup/game over.
The added password-preservation dvars and independent lock watcher are gone.
@@ -26,11 +26,56 @@ Common also listens for `.help` through the native `say` notification, independe
of first-spawn initialization so newcomers can view commands while spectating.
Each loaded command module registers its command and description with
`register_help_command()` during `init()`. Lazy registry initialization preserves
registrations regardless of module initialization order. Help uses client HUD
elements below the health bar, sharing the HUD's anchors to avoid overlap with
timers, zombie count and health numbers. Text is created once per connection and
reused; each request restarts a 12-second visibility timer. Disconnect cleanup
is independent of that timer. The command handlers retain their existing listeners.
registrations regardless of module initialization order. Common owns one
unboxed upper-right service area. Help shows all nine installed commands in
explicit command/description rows; extra registered commands use pages of nine
rows. Pages last 12 seconds, with automatic dismissal after the final page and
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 11 + 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.0` and AFK `1.2.0` require matching common `1.4.0+`; update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
Overlay helpers use direct normalized `640 × 480` coordinates. Dynamic health
fills use `make_overlay_width()` like their tracks. Apply `int()` after `max()`
in that helper: native `max()` returns a float, but `SetShader` requires an
integer width. Legacy panel helpers retain their `0.75` horizontal conversion
for existing consumers. Font scale is at least `1.0`. This introduces no
Plutonium/native-plugin/backend dependency or full server-process restart
requirement.
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
chat registration. The additional T6 controls are only held while AFK; `.back`
@@ -49,7 +94,7 @@ The module is `tws_t6_leaderboard.gsc` in T6 and `tws_t5_leaderboard.gsc` in T5.
Common's existing
connection listener now also dispatches optional connection handlers after a
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;
T5 sets `hasSpawned` for both playable and spectator spawns.
@@ -60,9 +105,10 @@ Mode/location combinations follow the
and Electric Cherry's map catalogue. Unknown selections require an explicit API
key instead of falling back to another variant sharing the engine map.
Validation for this change: 135 fixture/static checks pass using the actual
data-handling functions from both scripts with JavaScript engine-value shims.
All seven T6 scripts compile and assemble using a local Intel macOS driver built
Historical validation for the initial leaderboard change: 135 fixture/static
checks passed using the actual data-handling functions from both scripts with
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
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
@@ -137,15 +183,18 @@ wait for a normal round-start spawn, die, and verify `.join` is rejected.
Also check a round-1 participant, reconnecting, disconnect during setup, and
game over. Repeat on another T6 map; compile with the checker below first.
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared
references and entry points. It requires PowerShell and gsc-tool. The `.help`
change passed the static entry-point/reference checks, but compilation and live
gameplay were not run. Compiler success does not certify runtime linking.
Local: `tools/check.ps1` compiles all eight files for T6 PC and checks shared
references and entry points. It requires PowerShell and gsc-tool. Run the
[shared HUD checks](../../README.md#hud-validation) for core/service behavior and
command regressions. Compiler success does not certify runtime linking.
Live checks after deploying:
1. Join, late-join, die/respawn, and reconnect: one HUD per player, correct corner
position, health/Juggernog changes, zombies remaining, timers frozen at game over.
1. Join, late-join, die/respawn, and reconnect: one unboxed core per player,
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.
2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid
toggles, a round transition, death/respawn and game over.
3. Lock fires at the threshold and clears on game over/restart. HUD must be installed.
@@ -153,12 +202,27 @@ Live checks after deploying:
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.
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
`.help`, `.afk`, `.back` and `.join` with descriptions, only on the requesting
player's screen below the health bar. Take damage so health numbers appear
and verify they do not overlap help. Repeat the command before 12 seconds
elapse: one list should remain visible until 12 seconds after the latest
request. Reopen after expiry and reconnect; verify no duplicates or leftovers.
Remove the AFK or Join module, restart the map and confirm its commands are
absent. Restore the modules and confirm `.afk`, `.back` and `.join` still
behave as before.
6. Type `.help`, `.HELP` and `.commands` before first spawn, during play and
while AFK: one page of nine commands with all modules installed, full bank
syntax and no page counter, only on the requesting player's screen. Verify
dismissal after 12 seconds and repeat-to-restart behavior. Add custom
registered commands to exercise the nine-row page limit and page counter.
Remove optional modules and restart the map to confirm their commands are
absent. Restore them and confirm command behavior is unchanged.
7. Interrupt a record with help and check that its remaining page time resumes.
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.
+81 -24
View File
@@ -2,26 +2,29 @@
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
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.
| File | Role | Dependencies |
| --- | --- | --- |
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, 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_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common |
| `tws_t6_common.gsc` | Shared HUD values, helpers, paginated `.help`, service window, connection and first-spawn dispatchers | Stock T6 |
| `tws_t6_hud.gsc` | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle | Common 1.4.0+ |
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.4.0+ |
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils |
| `tws_t6_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
1. Copy all eight `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. The other five scripts
1. Copy the desired modules from the nine `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard
and AFK when those modules are installed.
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.
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).
@@ -67,14 +70,45 @@ changing points. Stock T6
populates that field before the `connected` notification. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text
## Core HUD and service window
The HUD shows `https://discord.tws.lol` in small muted-gray text
at the top-left, including for newcomers waiting in spectator mode. The label
is created once per connection and persists across death and respawn. The
gameplay HUD starts one line lower, keeping timers, help and records clear.
Install HUD `1.1.2` with common `1.1.0` or later, then restart the map; no plugin
update or full server-process restart is required.
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. Current/maximum HP appears to the right
of the bar only while injured; it disappears at full health. The AFK indicator
sits above the zombie count. There are no panel backgrounds or timer labels.
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 and AFK indicator.
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 full nine-command list in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter), or four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 11 + 1 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
## Map record announcements
@@ -99,30 +133,44 @@ apply to both versions. All T6 script filenames use the `tws_t6_` prefix.
Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and
a short description of each. The list is shown only to the requesting player,
including spectators who have not spawned. Commands are case-insensitive.
Help appears below the health bar for 12 seconds, clear of the timers, zombie
counter and health display. Typing either command again restarts that display time.
Help uses the unboxed upper-right service area. All nine installed commands
fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either
command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text.
| Command | Description |
| --- | --- |
| `.help`, `.commands` | List available commands |
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
| `.afk` | Pause with protection |
| `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) |
| `.leaders` | Show the map record again (leaderboard module required) |
| `.b` | Check bank balance (bank module required) |
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
AFK and Join register their commands during `init()` through
Command modules register their commands during `init()` through
`tws_t6_common::register_help_command(command, description)`. If a module is
omitted, its commands are omitted from `.help`. Register new commands alongside
their module's initialization so the list stays current.
omitted, its commands are omitted from `.help`. Commands follow the order above,
independent of initialization order. Register new commands alongside their
module's initialization so the list stays current.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
removes `.hud` from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest
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
- 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
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.
`.back` restores movement and weapons immediately, then ends protection after
two seconds. The T6 control/weapon fix remains in place.
@@ -211,7 +259,10 @@ another mod installed one first.
## 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
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
@@ -219,3 +270,9 @@ To compile all eight scripts for T6 PC with gsc-tool:
Compilation does not verify engine/plugin linking or gameplay. T6 differences,
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.
+3 -3
View File
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot
$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')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' }
$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 nine TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank, moderation.' }
$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 })
foreach ($file in $files) {
@@ -32,4 +32,4 @@ foreach ($file in $files) {
}
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.'
+7 -7
View File
@@ -48,11 +48,11 @@ afk_spawn()
self.hud_afk_on = false;
self.tws_afk_protected = false;
self.tws_afk_controls_locked = false;
elem = scripts\zm\tws_t6_common::make_text_elem(self,
level.hud_x + 42, level.hud_row_match, "left",
level.hud_font_small, level.hud_c_afk);
elem = scripts\zm\tws_t6_common::make_core_overlay_text(self,
level.hud_overlay_afk_x, level.hud_overlay_afk_y, "center",
1.0, level.hud_c_afk);
elem SetText("[AFK]");
elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, elem, 0);
self.hud_afk_elem = elem;
elems = [];
elems[0] = elem;
@@ -84,7 +84,7 @@ afk_enter()
self.ignoreme = true;
self enableInvulnerability();
self afk_lock_controls();
self.hud_afk_elem.alpha = 1;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 1);
self iPrintLnBold("AFK - type .back to return");
}
@@ -95,7 +95,7 @@ afk_exit()
return;
self.hud_afk_on = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0);
self.tws_afk_generation++;
generation = self.tws_afk_generation;
self iPrintLnBold("Back in the fight");
@@ -110,7 +110,7 @@ afk_release()
self.hud_afk_on = false;
self.tws_afk_protected = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0);
// Stock scripts own protection during laststand/intermission/death.
if (scripts\zm\tws_t6_common::player_active(self))
{
+570 -69
View File
@@ -13,6 +13,9 @@ init()
common_config();
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 on_player_connect_dispatch();
}
@@ -22,41 +25,84 @@ common_config()
if (isDefined(level.hud_x))
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_y = 30;
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54;
level.hud_row_bar = level.hud_y + 84;
level.hud_font_small = 1.4;
level.hud_font_large = 2.0;
level.hud_font_mid = 1.6;
level.hud_bar_width = 120;
level.hud_bar_height = 8;
level.hud_col_count = level.hud_x + 90;
level.hud_col_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
// Retain the legacy fields for consumers of the original common config.
// The panel renderers below use dedicated row/column offsets instead.
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
level.hud_row_name = level.hud_y + 32;
level.hud_row_count = level.hud_y + 54;
level.hud_col_count = level.hud_x + 90;
level.hud_col_name = level.hud_x + 60;
level.hud_col_health = level.hud_x + 120 + 12;
level.hud_health_threshold = 1.0;
level.hud_panel_right = 10;
level.hud_panel_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
level.hud_service_height = 124;
level.hud_pad = 10;
level.hud_row_header = 10;
level.hud_row_timers = 34;
level.hud_row_stats = 59;
level.hud_row_bar = 80;
level.hud_row_afk = 86;
level.hud_font_small = 1.1;
level.hud_font_large = 1.6;
level.hud_font_mid = 1.25;
level.hud_font_afk = 1.0;
level.hud_bar_width = 200;
level.hud_bar_height = 4;
level.hud_rate = 0.1;
level.hud_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))
level.tws_spawn_handlers = [];
@@ -112,58 +158,371 @@ help_chat_listener()
}
}
// A single per-player controller owns service priority, pages and expiry.
show_help()
{
// Cache the text per connection; repeated SetText calls consume config strings.
if (!isDefined(self.tws_help_elems))
{
elems = [];
// Use the same anchors as the HUD, with room below the health numbers.
y = level.hud_row_bar + level.hud_bar_height + 20;
title = make_text_elem(self, level.hud_x, y,
"left", level.hud_font_mid, level.hud_c_afk);
title SetText("Commands");
elems[elems.size] = title;
if (isDefined(level.tws_service_ended))
return;
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
state.generation++;
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);
}
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;
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++)
{
entry = level.tws_help_commands[i];
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18,
"left", level.hud_font_small, level.hud_c_count);
elem SetText("^3" + entry.command + "^7 - " + entry.description);
elems[elems.size] = elem;
if (help_rank(entry.command) == rank)
entries[entries.size] = entry;
}
self.tws_help_elems = elems;
self thread destroy_on_disconnect(elems);
}
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();
return entries;
}
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);
for (i = 0; i < 3; 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;
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("tws_help_refresh");
wait 12;
for (i = 0; i < self.tws_help_elems.size; i++)
level endon("tws_service_game_over");
for (;;)
{
elem = self.tws_help_elems[i];
elem.alpha = 0;
service_advance(state, getTime());
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()
{
for (;;)
@@ -213,7 +572,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x;
elem.y = y;
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.color = colour;
@@ -240,6 +600,147 @@ make_shader_elem(player, x, y)
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)
{
self waittill("disconnect");
+126 -160
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 common_scripts\utility;
@@ -11,40 +11,108 @@ main()
init()
{
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 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_connect_handler(::hud_discord);
}
// One static label per connection, including newcomers waiting to spawn.
hud_discord()
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\zm\tws_t6_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
{
self endon("disconnect");
label = scripts\zm\tws_t6_common::make_text_elem(
self, 10, 10, "left", 1.2, (0.65, 0.65, 0.65));
label SetText("https://discord.tws.lol");
if (isDefined(self.tws_hud_elems))
return;
elems = [];
elems[elems.size] = label;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
self.tws_hud_elems = [];
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// 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()
{
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;
self.tws_hud_bound = true;
self thread hud_timer_row();
self thread hud_zombie_counter();
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()
{
level thread game_over_via("end_game");
@@ -60,7 +128,7 @@ game_over_via(notify_name)
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()
{
level endon("hud_game_over");
@@ -93,7 +161,7 @@ hud_timer_row()
{
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) &&
isDefined(level.round_number) &&
level.round_number >= 1)
@@ -103,58 +171,14 @@ hud_timer_row()
level notify("hud_match_started");
}
match = scripts\zm\tws_t6_common::make_text_elem(
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 = self.tws_hud_round;
round SetTimerUp(0);
if (isDefined(level.round_start_time))
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 match_timer_bind(match);
self thread freeze_timers_on_game_over(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);
}
self thread match_timer_bind(self.tws_hud_match);
self thread freeze_timers_on_game_over(self.tws_hud_match, round);
}
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)
{
self endon("disconnect");
level endon("hud_game_over");
if (!isDefined(level.match_start_time))
level waittill("hud_match_started");
if (isDefined(elem))
{
elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0));
elem.alpha = 1;
}
}
/*
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.
*/
// Preserve authoritative frozen clocks when the core is shown after game over.
freeze_timers_on_game_over(match_elem, round_elem)
{
self endon("disconnect");
@@ -206,22 +224,9 @@ freeze_timers_on_game_over(match_elem, round_elem)
hud_zombie_counter()
{
self endon("disconnect");
level endon("hud_game_over");
label = scripts\zm\tws_t6_common::make_text_elem(
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);
count = self.tws_hud_count;
last = -1;
for (;;)
@@ -245,108 +250,69 @@ hud_zombie_counter()
hud_health_display()
{
self endon("disconnect");
level endon("hud_game_over");
bar_width = level.hud_bar_width;
// -2 on both axes so the back frames the bar with 2px padding
back = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2);
back.alpha = 0.5;
back.color = (0, 0, 0);
back SetShader("white", bar_width + 4, level.hud_bar_height + 4);
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;
bar_width = level.hud_overlay_bar_width;
bar = self.tws_hud_bar;
cur = self.tws_hud_health;
slash = self.tws_hud_slash;
max_hp = self.tws_hud_maxhealth;
hp = self.tws_hud_hp;
last_width = -1;
last_health = -1;
last_max = -1;
last_shown = -1;
last_max = -1;
for (;;)
{
// Display fallbacks must not alter gameplay health values.
maximum = 100;
if (isDefined(self.maxhealth) && self.maxhealth > 0)
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)
frac = 1;
if (frac < 0)
frac = 0;
width = int(max(frac * bar_width, 1));
width = scripts\zm\tws_t6_common::make_overlay_width(frac * bar_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;
}
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)
colour = level.hud_c_low;
else if (frac <= 0.5)
colour = level.hud_c_mid;
bar.color = colour;
cur.color = colour;
slash.color = colour;
bar.color = colour;
cur.color = colour;
slash.color = colour;
max_hp.color = colour;
hp.color = colour;
// numbers only show once you're hurt
show = 0;
readout_alpha = 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)
show = 1;
if (show != last_shown)
// Keep health numbers current even at full health or while hidden.
if (current != last_health)
{
cur.alpha = show;
slash.alpha = show;
max_hp.alpha = show;
last_shown = show;
}
if (self.health != last_health)
{
cur SetValue(self.health);
last_health = self.health;
cur SetValue(current);
last_health = current;
}
if (maximum != last_max)
+72 -112
View File
@@ -127,120 +127,17 @@ lb_request(manual)
level endon("game_ended");
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.
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self lb_show_hud(snapshot);
}
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
lb_show_hud(snapshot)
{
// Stop the previous display and its cleanup threads before replacing it.
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();
}
// Common owns pagination, help priority and the single service window.
// Undefined snapshots produce a private unavailable page for manual replay.
self scripts\zm\tws_t6_common::show_record(snapshot);
}
lb_refresh()
@@ -529,22 +426,85 @@ lb_holder_lines(names)
{
lines = [];
line = "";
columns = 0;
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;
line = "";
columns = 0;
}
if (line != "")
{
line += ", ";
line += names[i];
columns += 2;
}
line += name;
columns += width;
}
if (line != "")
lines[lines.size] = line;
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)
{
output = "";
+66
View File
@@ -0,0 +1,66 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\mp\_utility;
#include common_scripts\utility;
// T6 loader probes main and init; initialize only once.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
level.moderation_ready = false;
scripts\zm\tws_t6_common::register_connect_handler(::moderation_connected);
level thread moderation_loop();
}
moderation_connected()
{
self endon("disconnect");
if (!level.moderation_ready)
return;
moderation_observe(self);
moderationPump();
}
moderation_observe(player)
{
if (!isDefined(player))
return;
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return;
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
guid = "" + guid;
name = player.name;
if (!isDefined(name))
name = "Unknown player";
player moderationObserve(guid, name);
}
moderation_loop()
{
for (;;)
{
if (GetDvar("tws_moderation_enabled") == "0")
{
moderationStop();
level.moderation_ready = false;
wait 3;
continue;
}
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
if (!level.moderation_ready)
{
wait 3;
continue;
}
players = getPlayers();
for (i = 0; i < players.size; i++)
moderation_observe(players[i]);
moderationPump();
wait 0.5;
}
}
+274
View File
@@ -0,0 +1,274 @@
// Execute selected core HUD/AFK functions with engine-value shims.
// 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(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
.replace(/self (\w+)\(/g, 'self.$1(')
.replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/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]); },
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,
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,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
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'])
assert(body(source, name)[1].includes('level endon("hud_game_over")'));
const bind = body(source, 'match_timer_bind')[1];
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
assert(afk.includes('make_core_overlay_text'));
assert(afk.includes('level.hud_overlay_afk_y'));
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
});
check(`${game}: connection places 11 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
assert.equal(e.elems.length, 11);
assert.equal(e.self.tws_core_hud_elems.length, 11);
e.context.hud_connect(); assert.equal(e.elems.length, 11);
assert.equal(e.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');
assert.deepEqual(e.self.tws_hud_count.args.slice(1), [300, 440, 'right', 1.5, 'white']);
textAt('ZOMBIES', 306, 446, 'left', 1.05, 'dim');
textAt('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, 11); assert.equal(e.self.tws_hud_hidden, true);
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
e.context.hud_timer_row();
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
const threads = e.queued.length;
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 11);
});
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, 11); 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);
});
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
const e = make(); e.context.hud_connect(); e.spawnAfk();
const indicator = e.self.hud_afk_elem;
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
e.context.hud_toggle(); e.context.afk_enter();
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
const actions = e.actions.length;
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
e.context.hud_toggle(); const exit = e.context.afk_exit();
assert.equal(exit.next().value, 2);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
exit.next(); assert.equal(e.self.ignoreme, false);
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
assert.equal(e.elems.length, 12);
});
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
watcher.next();
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true);
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
e.self.active = true; watcher.next();
assert.equal(e.self.tws_afk_controls_locked, false);
assert(e.actions.some(([name]) => name === 'enableWeapons'));
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
});
}
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
+584
View File
@@ -0,0 +1,584 @@
// 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', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', '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|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++; },
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_spawn();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
get live() { return elements.filter(e => !e.destroyCalls); },
get peakLive() { return peakLive; },
get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); },
get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); },
get helpCommands() {
return Array.from(self.tws_service.command_rows).filter(row => row.alpha > 0).map(row => row.text);
},
get helpDescriptions() {
return Array.from(self.tws_service.description_rows).filter(row => row.alpha > 0).map(row => row.text);
},
};
}
const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines });
function assertCounter(e, page, pages) {
const counter = e.state.page_counter;
if (pages === 1) assert.equal(counter, undefined, 'single-page services do not allocate a counter');
else {
assert.equal(counter.alpha, 1);
assert.equal(counter.text, `${page} / ${pages}`);
}
}
function assertTextOnly(e) {
assert(e.state.elems.every(elem => elem.shader === undefined && elem.archived === false),
'service elements are private, non-archived text with no background shaders');
}
for (const game of ['t5', 't6']) {
check(`${game} installed HUD joins all nine commands in one view`, () => {
const e = engine(game, 8, true);
e.help();
assert.deepEqual(e.helpCommands,
['.help', '.hud', '.afk', '.back', '.join', '.leaders', '.b', '.d <amount|all>', '.w <amount|all>']);
assert.deepEqual(e.helpDescriptions,
['Show commands', 'Toggle core HUD', 'Protect & freeze', 'Return to play', 'Join as newcomer',
'View records', 'Bank balance', 'Deposit', 'Withdraw']);
assert.equal(e.state.help.pages, 1);
assertCounter(e, 1, 1);
assert.equal(e.live.length, 19);
assertTextOnly(e);
e.advance(12000);
assert.equal(e.live.length, 0);
const withoutHud = engine(game);
withoutHud.help();
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
});
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
const e = engine(game);
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
const bar = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0, 11);
const label = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
label.SetText('48:16');
const other = e.addPlayer();
const otherLabel = e.c.make_core_overlay_text(other, 6, 8, 'left', 1.4, [1, 1, 1]);
e.record(snapshot()); e.help();
const serviceAlpha = Array.from(e.state.elems, elem => elem.alpha);
const help = e.state.help, record = e.state.record;
e.c.toggle_core_hud();
assert.equal(e.self.tws_hud_hidden, true);
assert([track, bar, label].every(elem => elem.alpha === 0));
assert.equal(otherLabel.alpha, 1);
assert.equal(other.tws_hud_hidden, undefined);
e.c.set_core_alpha(e.self, bar, 1);
const afk = e.c.make_core_overlay_text(e.self, 320, 421, 'center', 1.0, [1, 1, 1]);
assert.equal(afk.alpha, 0, 'late core elements inherit the hidden state');
e.c.set_core_alpha(e.self, afk, 0);
const count = e.elements.length;
e.c.toggle_core_hud();
assert.deepEqual([track.alpha, bar.alpha, label.alpha, afk.alpha], [0.8, 1, 1, 0]);
assert.equal(label.text, '48:16', 'toggle does not rewrite displayed data');
assert.equal(e.elements.length, count);
assert.deepEqual(Array.from(e.state.elems, elem => elem.alpha), serviceAlpha);
assert.equal(e.state.help, help); assert.equal(e.state.record, record);
e.c.toggle_core_hud();
e.c.set_core_alpha(e.self, bar, 0);
e.c.set_core_alpha(e.self, afk, 1);
assert.equal(afk.alpha, 0);
e.c.toggle_core_hud();
assert.equal(bar.alpha, 0);
assert.equal(afk.alpha, 1);
label.Destroy();
e.c.toggle_core_hud();
assert(!e.self.tws_core_hud_elems.includes(label), 'toggle prunes destroyed elements');
assert.equal(label.destroyCalls, 1, 'registry never owns element destruction');
});
check(`${game} hiding before construction survives late core creation and prunes dead registrations`, () => {
const e = engine(game);
e.c.toggle_core_hud();
const old = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(old.alpha, 0);
old.Destroy();
const fresh = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(fresh.alpha, 0);
assert.equal(e.self.tws_core_hud_elems.length, 1);
e.c.toggle_core_hud();
assert.equal(fresh.alpha, 1);
assert.equal(e.level.match_start_time, undefined);
assert.equal(e.level.round_start_time, undefined);
const reconnected = e.addPlayer();
const newLabel = e.c.make_core_overlay_text(reconnected, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(newLabel.alpha, 1, 'a new player defaults to visible');
});
for (const count of [0, 1, 4, 8, 9]) check(`${game} ${count} registry entries order and dismiss`, () => {
const e = engine(game, count);
assert.equal(e.elements.length, 0, 'service is lazy and independent of core');
e.help();
const s = e.state;
assert.equal(s.help.pages, 1);
assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0]));
assertCounter(e, 1, 1);
assert.deepEqual(e.helpCommands, Array.from(s.commands, entry => e.c.help_command_label(entry.command)));
assert.deepEqual(e.helpDescriptions, Array.from(s.commands, entry => e.c.help_description(entry)));
assert.equal(e.live.length, count * 2 + 1);
assertTextOnly(e);
e.advance(12000);
assert.equal(s.help, undefined);
assert.equal(e.live.length, 0, 'idle must release even hidden elements');
});
check(`${game} overflow help pages reuse rows and repeated help resets expiry`, () => {
const e = engine(game, 9, true);
e.help();
assert.equal(e.state.help.pages, 2);
assert.equal(e.live.length, 20);
assertCounter(e, 1, 2);
const rows = [...e.state.command_rows, ...e.state.description_rows];
const count = e.elements.length, texts = e.textCalls;
e.help();
assert.equal(e.textCalls, texts, 'repeating current page does not reassign text');
e.advance(12000);
assert.equal(e.state.help.page, 1);
assert.deepEqual(e.helpCommands, ['.x']);
assertCounter(e, 2, 2);
assert.deepEqual(Array.from(e.state.command_rows, row => row.alpha), [1, 0, 0, 0, 0, 0, 0, 0, 0]);
e.help();
assert.equal(e.state.help.page, 0);
e.advance(11900);
e.help();
e.advance(100);
assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement');
assert.deepEqual([...e.state.command_rows, ...e.state.description_rows], rows);
e.advance(11900);
assertCounter(e, 2, 2);
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.elements.length, count);
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(15000);
assert.equal(e.state.record.page, 1);
const remaining = e.state.record.remaining;
e.help();
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.record.remaining, remaining);
assert.equal(e.state.rows[0].text, 'Four');
assert.equal(e.live.length, 5);
const resumedTexts = e.textCalls;
e.c.service_render(e.state);
assert.equal(e.textCalls, resumedTexts, 'subsequent ticks must reuse the rendered view');
e.advance(remaining);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} latest record replaces pending one without disturbing help`, () => {
const e = engine(game);
e.help();
e.advance(3000);
const help = e.state.help;
e.record(snapshot(['Old']));
e.record(snapshot(['Newest']));
assert.equal(e.state.help, help);
assert.equal(e.state.record.page, 0);
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.rows.length, 0, 'pending records allocate no holder elements');
e.advance(9000);
assert.equal(e.state.rows[0].text, 'Newest');
assert.equal(e.state.record.remaining, 12000);
assertCounter(e, 1, 1);
e.advance(11900);
e.record(snapshot(['Newest']));
const texts = e.textCalls;
e.advance(100);
assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts);
});
check(`${game} 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.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, 12, 'eleven core elements plus one optional AFK indicator');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetValue(6);
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, '12 core/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.`);
+61 -3
View File
@@ -44,7 +44,7 @@ function functions(source) {
function engine(game) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_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']);
const table = [];
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
@@ -111,6 +111,42 @@ for (const game of ['t5', 't6']) {
c.level.tws_lb_cache = undefined;
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;
});
check(`${game} unicode and URL encoding`, () => {
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
@@ -193,11 +229,33 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_refresh(), undefined);
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 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(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') {
const variants = [
+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`);
}
+7 -2
View File
@@ -66,9 +66,9 @@ def main():
base = f"http://127.0.0.1:{http.server_address[1]}"
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
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)
normal = probe("/200", "-", "-")
@@ -84,6 +84,11 @@ def main():
assert timed_out["body_size"] == 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.
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")
assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow
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)
+2 -1
View File
@@ -6,12 +6,13 @@
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;
utils::http::timeouts limits;
// 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[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 result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}",
{{"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;
}
}
+12 -10
View File
@@ -1,20 +1,22 @@
{
"tws_t5_afk.gsc": "1.0.0",
"tws_t5_afk.gsc": "1.2.0",
"tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.1.0",
"tws_t5_hud.gsc": "1.1.2",
"tws_t5_common.gsc": "1.4.0",
"tws_t5_hud.gsc": "1.4.0",
"tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.1.0",
"tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.2.0",
"tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.1.0",
"tws_t6_hud.gsc": "1.1.2",
"tws_t6_common.gsc": "1.4.0",
"tws_t6_hud.gsc": "1.4.0",
"tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.0.0",
"tws_t6_leaderboard.gsc": "1.1.0",
"tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.2.0",
"t6-plugin": "1.2.0"
"t5-plugin": "1.3.0",
"t6-plugin": "1.3.0",
"tws_t5_moderation.gsc": "1.0.0",
"tws_t6_moderation.gsc": "1.0.0"
}