Organize scripts and add TWS plugins with HTTP timeouts
Vendor pinned T5 and T6 plugin sources under the new plugins layout. Add native HTTP deadlines, status and timing diagnostics, telemetry integration, and build/test tooling.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# TWS T5 script notes
|
||||
|
||||
Reference for the `tws_t5_*.gsc` files in this repo. Platform quirks and
|
||||
load-bearing decisions that show up as invariants in the code.
|
||||
|
||||
## Install
|
||||
|
||||
Copy all `tws_t5_*.gsc` to `storage/t5/scripts/sp/` on the Plutonium T5 server
|
||||
(v2 Plutainer: `app/runtime/plutonium/storage/t5/scripts/sp/`).
|
||||
|
||||
- `sudo chown -R 1000:1000 <app>` — Plutainer runs as UID 1000
|
||||
- `docker exec <container> rcon-cli map_restart` — reloads scripts in place
|
||||
|
||||
For existing installations, follow the [upgrade cleanup](README.md#install--upgrade)
|
||||
before copying the renamed scripts.
|
||||
|
||||
## Platform quirks (T5 SP engine, Plutonium)
|
||||
|
||||
- **Initialize locals before loops when reading them afterwards.** T5 rejected
|
||||
leaderboard's `chosen` variable at load time when its only assignment was
|
||||
inside the map-search loop, even though its later use was guarded by the
|
||||
match count. It now starts as `undefined` before that loop in both versions.
|
||||
The JavaScript fixture harness and T6 compiler did not catch this T5 error.
|
||||
- **One `level waittill("connected")` only.** `tws_t5_common` owns it. A
|
||||
second listener breaks the T5 SP connect handshake. Per-player work goes
|
||||
through `tws_t5_common::register_spawn_handler` after `spawned_player`, or
|
||||
`register_connect_handler` for spectator-safe work after the dispatcher yields.
|
||||
Leaderboard uses the latter; its level worker owns all HTTP work.
|
||||
- **Chat uses `level waittill("say", ...)`**, not `callbackPlayerSay`. T5 SP
|
||||
dedicated disables the sound subsystem, cascading through the say-callback
|
||||
dispatch — `callbackPlayerSay` never fires.
|
||||
- **`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.
|
||||
- **`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
|
||||
(diagnostic).
|
||||
- **Zombies namespace is WaW-derived** (`maps\_zombiemode_*`), not BO2's
|
||||
`maps\mp\zombies\_zm_*`.
|
||||
- **Check `t5-gsc-utils` source as well as its README.** Current source
|
||||
includes `http::request` and `jsonSerialize` / `jsonParse`. Telemetry and
|
||||
leaderboard require a DLL built with these HTTP/JSON components; older
|
||||
builds may lack them.
|
||||
See [HTTP source](https://github.com/alicealys/t5-gsc-utils/blob/main/src/component/http.cpp)
|
||||
and [JSON source](https://github.com/alicealys/t5-gsc-utils/blob/main/src/component/json.cpp).
|
||||
|
||||
## Load-bearing decisions
|
||||
|
||||
- **Match / round timers anchor to first spawn / first observed round
|
||||
transition — never level init.** Anchoring at init included the map-load
|
||||
+ connect + intro window, producing "displayed 4:00 → frozen 7:00" drift.
|
||||
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.
|
||||
- **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).
|
||||
## 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_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` |
|
||||
| `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` |
|
||||
| `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
|
||||
@@ -0,0 +1,480 @@
|
||||
# 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 seven `.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).
|
||||
|
||||
## Files
|
||||
|
||||
| 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) | Bottom-left HUD stack: timers, zombie count, health bar |
|
||||
| [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 |
|
||||
|
||||
## Install / upgrade
|
||||
|
||||
Copy `tws_t5_*.gsc` to `storage/t5/scripts/sp/`, then restart the map.
|
||||
When upgrading an existing installation, first remove these old scripts from
|
||||
that directory:
|
||||
|
||||
- `tws_bank.gsc` and `tws_db.gsc` (removed modules)
|
||||
- `tws_common.gsc`, `tws_hud.gsc`, `tws_afk.gsc`, `tws_join.gsc`,
|
||||
`tws_lock.gsc`, `tws_telemetry.gsc` and `tws_leaderboard.gsc` (renamed modules)
|
||||
|
||||
Do not leave both common scripts installed: duplicate connection listeners
|
||||
break T5's connect handshake. Existing dvar names are unchanged.
|
||||
|
||||
## Map record announcements
|
||||
|
||||
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
|
||||
`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a
|
||||
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
|
||||
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
|
||||
requires a full server-process restart. To omit leaderboard, remove its script;
|
||||
the enable dvar does not bypass native linking dependencies.
|
||||
|
||||
Records are read from the public Electric Cherry API with no credentials and
|
||||
work independently of telemetry and HUD. Spectator newcomers receive
|
||||
the same private announcement as playable joins. T5 matches its engine map to
|
||||
the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
|
||||
apply to both games.
|
||||
|
||||
## Player commands
|
||||
|
||||
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.
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
|
||||
Each module registers its own help entries; removing AFK or join also removes
|
||||
those entries from `.help` after a map restart.
|
||||
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.
|
||||
|
||||
## Dependency graph
|
||||
|
||||
Arrows read "depends on" — solid means the source names the target with
|
||||
`scripts\sp\tws_t5_common::...`, dashed means the source waits on a level
|
||||
notify fired by the target.
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
common[tws_t5_common<br/><i>config + helpers + dispatcher</i>]
|
||||
|
||||
hud[tws_t5_hud]
|
||||
afk[tws_t5_afk]
|
||||
join[tws_t5_join]
|
||||
lock[tws_t5_lock]
|
||||
telemetry[tws_t5_telemetry]
|
||||
leaderboard[tws_t5_leaderboard]
|
||||
|
||||
hud --> common
|
||||
afk --> common
|
||||
join --> common
|
||||
lock --> common
|
||||
telemetry --> common
|
||||
leaderboard --> common
|
||||
|
||||
lock -. hud_round_changed<br/>hud_game_over .-> hud
|
||||
```
|
||||
|
||||
- `tws_t5_common` is the only module named by other files at compile time.
|
||||
- All other cross-module coupling flows through level notifies
|
||||
(`hud_round_changed`, `hud_match_started`,
|
||||
`hud_game_over`) or through spawn handlers registered with
|
||||
`tws_t5_common::register_spawn_handler` / `register_connect_handler`.
|
||||
- Any file except `tws_t5_common` can be removed; the rest still load.
|
||||
`tws_t5_afk` has no downstream consumers — pull it independently.
|
||||
`tws_t5_lock` needs `tws_t5_hud` to receive round and game-over notifications.
|
||||
|
||||
## Runtime notifies
|
||||
|
||||
| Notify | Fired by | Read by |
|
||||
|---|---|---|
|
||||
| `hud_round_changed` | `tws_t5_hud` | `tws_t5_hud` (round-timer reset), `tws_t5_lock` |
|
||||
| `hud_match_started` | `tws_t5_hud` | `tws_t5_hud` (match-timer reveal) |
|
||||
| `hud_game_over` | `tws_t5_hud` | `tws_t5_hud` (freeze timers), `tws_t5_lock` (clear password) |
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_common.gsc
|
||||
|
||||
Hub file. Shared config, HUD helpers, 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`
|
||||
- Spawn-handler registry
|
||||
- Connection-handler registry for optional modules that serve spectator newcomers
|
||||
- `.help` listener and help registry populated by installed command modules
|
||||
- Connect listener + per-player dispatch: iterates
|
||||
`level.tws_spawn_handlers` after `spawned_player` and threads each on
|
||||
the player
|
||||
|
||||
**Provides**
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `common_config()` | Populate every `level.hud_*` (idempotent) |
|
||||
| `ensure_config()` | No-op if config already set; called by every module's `init` |
|
||||
| `register_spawn_handler(fn)` | Appends `::fn` to `level.tws_spawn_handlers` |
|
||||
| `register_connect_handler(fn)` | Threads `::fn` after connection dispatch yields, without waiting for playable spawn |
|
||||
| `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 |
|
||||
| `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` |
|
||||
|
||||
**Depends on**: nothing (leaf module).
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_hud.gsc
|
||||
|
||||
Bottom-left cluster. Per-player elements spawn via `::hud_start`.
|
||||
|
||||
**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
|
||||
- Round-transition and game-over notify machinery
|
||||
|
||||
**Provides**
|
||||
| Notify / level var | Meaning |
|
||||
|---|---|
|
||||
| notify `hud_round_changed` | Fires each time `level.round_number` transitions |
|
||||
| notify `hud_match_started` | Fires once when the match clock seeds |
|
||||
| notify `hud_game_over` | Fires on the first `end_game` / `game_ended` / `intermission` notify |
|
||||
| `level.round_start_time` | Anchored at first observed round transition |
|
||||
| `level.match_start_time` | Anchored at first observed round-1 (or on first player spawn) |
|
||||
|
||||
Spawn handler registered: `::hud_start` — threads `hud_timer_row`,
|
||||
`hud_zombie_counter`, `hud_health_display`.
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`,
|
||||
`make_shader_elem`, `destroy_on_disconnect`, `format_mmss`,
|
||||
`register_spawn_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`
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_afk.gsc
|
||||
|
||||
Self-invoked pause mode. Owns the `[AFK]` indicator element entirely — HUD
|
||||
module has zero knowledge of AFK.
|
||||
|
||||
**Features**
|
||||
- Chat commands `.afk` and `.back` (via `level waittill("say")` — not
|
||||
`callbackPlayerSay`, which never fires on T5 SP dedicated)
|
||||
- On enter: `enableInvulnerability`, `freezeControls(true)`,
|
||||
`self.ignoreme = true` (drops out of zombie targeting)
|
||||
- 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
|
||||
|
||||
**Provides**
|
||||
- Per-player: `self.hud_afk_on` (bool), `self.hud_afk_elem` (HUD element),
|
||||
writes `self.ignoreme` (stock BO1 targeting flag)
|
||||
- Level var: `level.afk_grace_seconds` (default 2)
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`,
|
||||
`destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
|
||||
- Shared config: `level.hud_x`, `level.hud_row_match`,
|
||||
`level.hud_font_small`, `level.hud_c_afk`
|
||||
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
|
||||
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
|
||||
|
||||
Spawn handler registered: `::afk_spawn`.
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_join.gsc
|
||||
|
||||
Type `.join` in chat while spectating after joining a match in progress to
|
||||
spawn immediately. Install with `tws_t5_common.gsc`; no HUD dependency.
|
||||
|
||||
**Eligibility**
|
||||
|
||||
- Only before the player's first playable spawn on the current connection.
|
||||
- A normal spawn or successful `.join` permanently closes eligibility for
|
||||
that connection. Getting downed, bleeding out, or changing rounds never
|
||||
restores it. Dead players must wait for the normal round-end respawn.
|
||||
- Requests during connection setup, without a living teammate, or after
|
||||
game over cannot spawn a player. Repeated `.join` requests cannot create
|
||||
overlapping spawns.
|
||||
- This is connection-local tracking; reconnecting creates a new player and
|
||||
resets eligibility. It is not an account-level reconnect restriction.
|
||||
|
||||
Uses the stock `level.spawnPlayer` entry point and preserves map-specific
|
||||
`level.custom_spawnPlayer` callbacks. If absent, installs the stock zombies
|
||||
spectator callback, as the round-end routine does. Missing spectator spawn
|
||||
locations are seeded from the map's `initial_spawn_points`.
|
||||
|
||||
Spawn handler registered: `::join_spawn`. Stock `self.initialized` provides
|
||||
an additional first-spawn guard; `hasSpawned` cannot distinguish spectators.
|
||||
See the [stock zombies lifecycle](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Common/maps/_zombiemode.gsc)
|
||||
and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Common/maps/_callbackglobal.gsc).
|
||||
|
||||
**Live validation (requires a T5 server)**
|
||||
|
||||
1. Join during an active round, type `.join`, and verify one playable spawn
|
||||
with normal weapons, controls, zombie targeting, and HUD initialization.
|
||||
Check both round 1 and a later round, including each deployed map.
|
||||
2. Repeat `.join` rapidly; verify no duplicate spawns. Try it alive, while
|
||||
downed, and after bleeding out; verify no revive or respawn.
|
||||
3. Join and wait for the normal round-end spawn instead; after dying, verify
|
||||
`.join` remains blocked, including on subsequent rounds.
|
||||
4. Try during round transition and game over; verify no duplicate spawn or
|
||||
restart of an ended match. Check that `.afk` / `.back` still work.
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_lock.gsc
|
||||
|
||||
**Features**
|
||||
- Sets `g_password` to a random per-lobby value when the round crosses a
|
||||
milestone (default: round 20)
|
||||
- Existing players are unaffected; `g_password` is only checked at connect
|
||||
- Clears `g_password` on `hud_game_over` so the next lobby opens unlocked
|
||||
- Trigger round is dvar-driven — retune without redeploying
|
||||
|
||||
**Provides**
|
||||
| Level var | Meaning |
|
||||
|---|---|
|
||||
| `level.lock_round` | Round at which the lobby locks (read at init) |
|
||||
| `level.lock_active` | `true` after the password has been set |
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or`
|
||||
- `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over`
|
||||
- Stock BO1: `level.round_number`
|
||||
- Dvar: `tws_lock_round` (int, default `20`)
|
||||
|
||||
---
|
||||
|
||||
## Dvars
|
||||
|
||||
| Dvar | Consumer | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `zombiemode` | every module | — | Gate: every `init()` returns early unless `"1"` |
|
||||
| `tws_lock_round` | `tws_t5_lock` | `20` | Round at which the lobby locks |
|
||||
| `tws_server_id` | `tws_t5_telemetry` | Required for telemetry | Exact registered Electric Cherry server name |
|
||||
| `tws_telemetry_enabled` | `tws_t5_telemetry` | `1` | Set to `0` to disable telemetry at init |
|
||||
| `tws_telemetry_url` | `tws_t5_telemetry` | `https://ec.thewrightserver.net/api/telemetry` | Electric Cherry telemetry endpoint for JSON POSTs |
|
||||
| `tws_telemetry_log_level` | `tws_t5_telemetry` | `1` | Read live: `0` warnings/errors/recovery only, `1` lifecycle/request/health logs, `2` also capture/queue logs and response previews |
|
||||
|
||||
## tws_t5_telemetry.gsc
|
||||
|
||||
Uses `tws-t5-gsc-utils` HTTP/JSON components to POST directly to Electric Cherry's
|
||||
`/api/telemetry`. Install alongside `tws_t5_common.gsc` in `storage/t5/scripts/sp/`.
|
||||
No HUD dependency. Reports run whether or not a beta is active.
|
||||
Follow the shared [authentication setup](../../README.md#telemetry-authentication-t5-and-t6)
|
||||
to configure the service before upgrading. The reporter token is embedded in
|
||||
`level.telemetry_token`; it must match the service's `EC_TELEMETRY_TOKEN`. Short
|
||||
embedded tokens and non-HTTPS URLs disable reporting at init.
|
||||
|
||||
Example server config (except log level, read at script init; restart the map after changes):
|
||||
|
||||
```cfg
|
||||
set tws_server_id "t5_zombie_prototype_1"
|
||||
set tws_telemetry_enabled "1"
|
||||
set tws_telemetry_url "https://ec.thewrightserver.net/api/telemetry"
|
||||
set tws_telemetry_log_level "1"
|
||||
```
|
||||
|
||||
`tws_server_id` must match the registered server name exactly. If it is
|
||||
unset, the script logs a configuration error and does not start reporting.
|
||||
An unknown or destroyed server is rejected by Electric Cherry with HTTP 404.
|
||||
|
||||
The reporter emits exactly four event types:
|
||||
|
||||
| Event | When | Payload |
|
||||
|---|---|---|
|
||||
| `game_start` | First playable spawn, with the stock `begin_spawning` flag as a fallback | `server`, `map`, `players` with `name` and `guid` |
|
||||
| `round_change` | First observed round after start, then each change (checked every 0.5 seconds) | `server`, `map`, `round`, full player stats |
|
||||
| `heartbeat` | Every 30 seconds after `game_start`, until game end | Same fields as `round_change`, with current round and player stats |
|
||||
| `game_end` | First stock `end_game`, `game_ended`, or `intermission` notification | `server`, `map`, final `round`, full player stats |
|
||||
|
||||
Each player in round/heartbeat/end reports has `name`, `guid`, `kills`, `headshots`,
|
||||
`downs`, `revives`, and `score`. Names and GUIDs remain strings; GUIDs never
|
||||
pass through numeric conversion. Round and stats are nonnegative integers,
|
||||
with missing or negative stat values reported as zero. The four counters
|
||||
come from T5's `player.stats`; `score` is the player's current spendable
|
||||
`player.score`, rather than cumulative points earned. `map` is `mapname`.
|
||||
|
||||
Every report includes everyone returned by `getPlayers()`, including
|
||||
spectators, downed players, and AFK players. Empty rosters are sent as `[]`.
|
||||
Undefined players and players with missing/empty names or GUIDs are skipped
|
||||
with a warning containing their roster index; a later snapshot can include
|
||||
them once their identity is available.
|
||||
The script builds the JSON array delimiters explicitly because the plugin
|
||||
serializes an empty GSC array as `null`; player objects and strings still
|
||||
use `jsonSerialize` for escaping.
|
||||
|
||||
```json
|
||||
{"event":"game_start","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","players":[{"name":"Josh","guid":"12345678901234567"}]}
|
||||
{"event":"round_change","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":420,"headshots":160,"downs":1,"revives":3,"score":32000}]}
|
||||
{"event":"heartbeat","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":430,"headshots":165,"downs":1,"revives":3,"score":32500}]}
|
||||
{"event":"game_end","server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":18,"players":[{"name":"Josh","guid":"12345678901234567","kills":445,"headshots":170,"downs":2,"revives":3,"score":33500}]}
|
||||
```
|
||||
|
||||
Events are captured immediately into an in-memory queue. One HTTP worker
|
||||
POSTs each snapshot once, in gameplay order, with `Content-Type:
|
||||
application/json` and `Authorization: Bearer <token>`, waiting for completion
|
||||
before sending the next. Gameplay and round detection continue while requests
|
||||
are pending. Repeated joins or
|
||||
end notifications do not duplicate start/end reports. At game over, the
|
||||
reporter captures any round change since the last poll, stops detection,
|
||||
and lets the sender drain through `game_end`.
|
||||
|
||||
The first heartbeat is captured 30 seconds after match start, then every
|
||||
30 seconds even when the round is unchanged or the roster is empty. Round
|
||||
changes and later spawns do not reset the interval or add heartbeat workers.
|
||||
Heartbeats use the same snapshot builder and ordered sender as round reports;
|
||||
pending HTTP requests can delay transmission. Game end stops the heartbeat
|
||||
worker, and already queued heartbeats drain before `game_end`.
|
||||
|
||||
There is no discovery, beta token, session ID, sequence field, custom event,
|
||||
external helper, or separate beta acknowledgement. Delivery is best effort:
|
||||
no retries or persistence across map reloads, and no cross-game exactly-once
|
||||
guarantee. The [TWS plugin build](../plugins/README.md) enforces a 5-second
|
||||
connection and 15-second total transfer timeout. A timed-out report is logged
|
||||
and skipped so later reports can proceed. A map reload can discard queued reports.
|
||||
|
||||
Transport failures and non-2xx HTTP responses log to the server console. The TWS
|
||||
plugin exposes HTTP status and cumulative DNS/TCP/TLS/first-byte/total timings,
|
||||
plus worker-queue and completion-notification delays. Transport success alone
|
||||
does not establish API acceptance.
|
||||
Verify acceptance at Electric Cherry: success is HTTP 200 with `ok: true`
|
||||
and telemetry containing `receivedAt`; invalid payloads return 400 and
|
||||
non-JSON content types return 415.
|
||||
|
||||
**Diagnosing a lobby that stops reporting**
|
||||
|
||||
Default logging includes initialization, match start/end, each request's
|
||||
event/round and local ID, queue wait and request duration in milliseconds,
|
||||
transport failures/recovery, HTTP rejection/status/timings, and the final queue drain. IDs reset on map
|
||||
load and only appear in logs; payloads are unchanged. Every log includes
|
||||
the server, map, and game time in milliseconds.
|
||||
|
||||
An independent watchdog prints health every 30 seconds, even before match
|
||||
start and while HTTP is pending or the game-end queue is draining. It shows
|
||||
the sender stage, current snapshot capture, pending queue size (excluding
|
||||
the in-flight request), total queued, completed transports, transport failures,
|
||||
HTTP rejections, and
|
||||
time since the last completion (`-1` means none). Warnings remain enabled
|
||||
at log level `0`:
|
||||
|
||||
- `HTTP stalled`: a request has been pending for at least 30 seconds. The
|
||||
warning repeats on watchdog ticks with its ID, event, age, and queue size.
|
||||
- `sender is not consuming`: the oldest queued report has waited at least
|
||||
30 seconds with no in-flight request.
|
||||
- `round watcher has stopped advancing`: no polling progress for over
|
||||
5 seconds, detected on the next watchdog tick.
|
||||
- `no snapshot captured`: an active match has gone at least 65 seconds
|
||||
without a new snapshot, despite its 30-second heartbeat.
|
||||
|
||||
Set `tws_telemetry_log_level "2"` in the server console while investigating;
|
||||
no map restart is needed. This adds capture/queue stages and API response
|
||||
previews, escaped onto one line and limited to the first 256 characters.
|
||||
Look for `ok: false` and the rejection reason in those previews; HTML may
|
||||
indicate a proxy error. Previews can include player identities echoed by
|
||||
the API. Restore `1` afterward, or use `0` to retain only warnings, errors,
|
||||
and transport recovery messages.
|
||||
|
||||
Missing request handles and reported transport failures are counted and
|
||||
skipped so the sender can continue with the next event. Empty responses
|
||||
produce a warning. Arbitrary response bodies are not parsed as JSON,
|
||||
because malformed/proxy responses must not break the sender. The watchdog
|
||||
does not launch overlapping requests or retry failed events. Native timeouts
|
||||
release stalled transfers. A request still pending after 30 seconds warrants
|
||||
checking the installed plugin version, worker-queue delay and completion delivery.
|
||||
If diagnostics are absent, the reporter warns that native deadlines are unverified;
|
||||
install the TWS plugin build and restart the server process.
|
||||
|
||||
Native deadlines and completion diagnostics require the [TWS plugin build](../plugins/README.md).
|
||||
The upstream baseline contains
|
||||
[`http::request`](https://github.com/alicealys/t5-gsc-utils/blob/main/src/component/http.cpp)
|
||||
and [`jsonSerialize`](https://github.com/alicealys/t5-gsc-utils/blob/main/src/component/json.cpp).
|
||||
Disabling the dvar does not remove that compile-time dependency; remove the
|
||||
script when using a plugin build without those functions.
|
||||
|
||||
Stat and lifecycle references: [stock T5 zombies](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Common/maps/_zombiemode.gsc)
|
||||
and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Common/maps/_zombiemode_score.gsc).
|
||||
|
||||
**Live validation (requires a T5 server)**
|
||||
|
||||
1. Start a match and verify one `game_start`, then an initial `round_change`
|
||||
and one per new round. Check the exact server name, map, string GUIDs,
|
||||
all player identities, and all five stats against the game.
|
||||
2. Join again in the same round, go AFK, become downed, and spectate. Verify
|
||||
the next report includes the full roster without another start event.
|
||||
3. End a match and verify one `game_end` with final stats after all round
|
||||
reports, including if more than one stock end notification fires. Check
|
||||
a round-0 intro and an empty roster, including last-player disconnect.
|
||||
4. Delay HTTP completion while rounds advance and the match ends. Verify
|
||||
requests remain ordered and retain the stats captured at each event.
|
||||
With the API unreachable, verify transport-error logging and continued
|
||||
gameplay. Repeat without an active beta and with the HUD module removed.
|
||||
5. Stay in one round for over a minute. Verify heartbeats at 30 and 60 seconds
|
||||
after start, with current stats and the same fields as `round_change`.
|
||||
Check that round changes and joins do not reset or duplicate the interval,
|
||||
and no heartbeat follows `game_end`, including with requests pending.
|
||||
6. Change log level between `0`, `1`, and `2` without a map restart. Check
|
||||
request IDs pair sends with completions and level `2` shows bounded,
|
||||
escaped response previews. Use an unregistered server name on a test
|
||||
server and confirm the API rejection is visible in the response even
|
||||
though transport completes. Also check an empty or HTML response.
|
||||
7. Point a test server at an endpoint that accepts a connection but does
|
||||
not reply. With the TWS plugin, verify a native timeout near 15 seconds and
|
||||
continued queue consumption. With an older plugin, expect recurring
|
||||
`HTTP stalled` warnings and increasing
|
||||
pending counts while gameplay continues, including after game over.
|
||||
Release the delayed reply and verify the queue resumes in order and
|
||||
the watchdog stops after the final drain. Test a refused connection
|
||||
separately to check failure counts and the recovery log once restored.
|
||||
|
||||
## External dependencies
|
||||
|
||||
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) is required for telemetry
|
||||
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.
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
tws_t5_afk.gsc — .afk / .back chat toggle. Owns the [AFK] indicator element.
|
||||
Chat uses level waittill("say") — callbackPlayerSay never fires on T5 SP
|
||||
dedicated (see NOTES.md).
|
||||
*/
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
scripts\sp\tws_t5_common::register_help_command(".afk", "Pause with protection");
|
||||
scripts\sp\tws_t5_common::register_help_command(".back", "Return from AFK");
|
||||
|
||||
afk_init();
|
||||
|
||||
scripts\sp\tws_t5_common::register_spawn_handler(::afk_spawn);
|
||||
}
|
||||
|
||||
afk_init()
|
||||
{
|
||||
level.afk_grace_seconds = 2; // invuln tail on .back
|
||||
|
||||
level thread afk_chat_listener();
|
||||
}
|
||||
|
||||
afk_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
|
||||
cmd = toLower(message);
|
||||
|
||||
if (cmd == ".afk")
|
||||
player thread afk_enter();
|
||||
else if (cmd == ".back")
|
||||
player thread afk_exit();
|
||||
}
|
||||
}
|
||||
|
||||
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 SetText("[AFK]");
|
||||
afk.alpha = 0;
|
||||
self.hud_afk_elem = afk;
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = afk;
|
||||
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
|
||||
}
|
||||
|
||||
afk_enter()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
if (is_true(self.hud_afk_on))
|
||||
return;
|
||||
|
||||
self.hud_afk_on = true;
|
||||
self.ignoreme = true; // filters new targeting decisions (1-3s aggro drop)
|
||||
self enableInvulnerability();
|
||||
self freezeControls(true);
|
||||
|
||||
if (isDefined(self.hud_afk_elem))
|
||||
self.hud_afk_elem.alpha = 1;
|
||||
|
||||
self iPrintLnBold("AFK — type .back to return");
|
||||
}
|
||||
|
||||
afk_exit()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
if (!is_true(self.hud_afk_on))
|
||||
return;
|
||||
|
||||
self.hud_afk_on = false;
|
||||
self freezeControls(false);
|
||||
|
||||
if (isDefined(self.hud_afk_elem))
|
||||
self.hud_afk_elem.alpha = 0;
|
||||
|
||||
self iPrintLnBold("Back in the fight");
|
||||
|
||||
// grace tail: keep invuln through the wait so zombies don't re-acquire
|
||||
// and swing while controls are un-freezing
|
||||
wait level.afk_grace_seconds;
|
||||
|
||||
if (!is_true(self.hud_afk_on))
|
||||
{
|
||||
self.ignoreme = false;
|
||||
self disableInvulnerability();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* tws_t5_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
common_config();
|
||||
|
||||
register_help_command(".help", "List available commands");
|
||||
level thread help_chat_listener();
|
||||
level thread on_player_connect_dispatch();
|
||||
}
|
||||
|
||||
common_config()
|
||||
{
|
||||
if (isDefined(level.hud_x))
|
||||
return;
|
||||
|
||||
// anchor: move the whole cluster with these two
|
||||
level.hud_x = 10;
|
||||
level.hud_y = 10;
|
||||
|
||||
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
|
||||
level.hud_health_threshold = 1.0;
|
||||
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
level.tws_spawn_handlers = [];
|
||||
}
|
||||
|
||||
ensure_config()
|
||||
{
|
||||
if (!isDefined(level.hud_x))
|
||||
common_config();
|
||||
}
|
||||
|
||||
register_spawn_handler(fn)
|
||||
{
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
level.tws_spawn_handlers = [];
|
||||
|
||||
level.tws_spawn_handlers[level.tws_spawn_handlers.size] = fn;
|
||||
}
|
||||
|
||||
// Connection handlers also serve newcomers still waiting in spectator mode.
|
||||
register_connect_handler(fn)
|
||||
{
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
level.tws_connect_handlers = [];
|
||||
|
||||
level.tws_connect_handlers[level.tws_connect_handlers.size] = fn;
|
||||
}
|
||||
|
||||
// Modules register their own entries so help only lists installed commands.
|
||||
register_help_command(command, description)
|
||||
{
|
||||
if (!isDefined(level.tws_help_commands))
|
||||
level.tws_help_commands = [];
|
||||
|
||||
entry = spawnStruct();
|
||||
entry.command = command;
|
||||
entry.description = description;
|
||||
level.tws_help_commands[level.tws_help_commands.size] = entry;
|
||||
}
|
||||
|
||||
// Listen at level scope so newcomers can ask for help before their first spawn.
|
||||
help_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
|
||||
command = toLower(message);
|
||||
if (command == ".help" || command == ".commands")
|
||||
player show_help();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
hide_help_after_delay()
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_help_refresh");
|
||||
|
||||
wait 12;
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// only connect listener in the fleet; a second one crashes the T5 SP handshake
|
||||
on_player_connect_dispatch()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("connected", player);
|
||||
player thread on_player_spawned_dispatch();
|
||||
player thread on_player_connected_dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
on_player_connected_dispatch()
|
||||
{
|
||||
self endon("disconnect");
|
||||
// Leave the connection handshake before invoking optional modules.
|
||||
wait 0.05;
|
||||
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
return;
|
||||
|
||||
for (i = 0; i < level.tws_connect_handlers.size; i++)
|
||||
self thread [[ level.tws_connect_handlers[i] ]]();
|
||||
}
|
||||
|
||||
// handlers own their own self endon("disconnect")
|
||||
on_player_spawned_dispatch()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
self waittill("spawned_player");
|
||||
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
return;
|
||||
|
||||
for (i = 0; i < level.tws_spawn_handlers.size; i++)
|
||||
self thread [[ level.tws_spawn_handlers[i] ]]();
|
||||
}
|
||||
|
||||
make_text_elem(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = NewClientHudElem(player);
|
||||
|
||||
elem.horzAlign = "left";
|
||||
elem.vertAlign = "top";
|
||||
elem.alignX = align_x;
|
||||
elem.alignY = "top";
|
||||
elem.x = x;
|
||||
elem.y = y;
|
||||
elem.foreground = 1;
|
||||
elem.fontScale = scale;
|
||||
elem.alpha = 1;
|
||||
elem.color = colour;
|
||||
|
||||
// black glow reads as an outline against bright geometry
|
||||
elem.glowColor = (0, 0, 0);
|
||||
elem.glowAlpha = 0.6;
|
||||
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_shader_elem(player, x, y)
|
||||
{
|
||||
elem = NewClientHudElem(player);
|
||||
|
||||
elem.horzAlign = "left";
|
||||
elem.vertAlign = "top";
|
||||
elem.alignX = "left";
|
||||
elem.alignY = "top";
|
||||
elem.x = x;
|
||||
elem.y = y;
|
||||
elem.foreground = 1;
|
||||
elem.alpha = 1;
|
||||
|
||||
return elem;
|
||||
}
|
||||
|
||||
destroy_on_disconnect(elems)
|
||||
{
|
||||
self waittill("disconnect");
|
||||
|
||||
for (i = 0; i < elems.size; i++)
|
||||
{
|
||||
if (isDefined(elems[i]))
|
||||
elems[i] Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
int_dvar_or(name, fallback)
|
||||
{
|
||||
raw = GetDvar(name);
|
||||
if (raw == "")
|
||||
return fallback;
|
||||
return int(raw);
|
||||
}
|
||||
|
||||
format_mmss(elapsed_ms)
|
||||
{
|
||||
total_secs = int(elapsed_ms / 1000);
|
||||
|
||||
if (total_secs < 0)
|
||||
total_secs = 0;
|
||||
|
||||
mins = int(total_secs / 60);
|
||||
secs = total_secs - (mins * 60);
|
||||
|
||||
sec_str = "" + secs;
|
||||
if (secs < 10)
|
||||
sec_str = "0" + secs;
|
||||
|
||||
return "" + mins + ":" + sec_str;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/* tws_t5_hud.gsc — bottom-left HUD stack. Timer / zombie count / health bar. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
|
||||
level thread round_watcher();
|
||||
level thread game_over_watcher();
|
||||
|
||||
scripts\sp\tws_t5_common::register_spawn_handler(::hud_start);
|
||||
}
|
||||
|
||||
hud_start()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
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
|
||||
game_over_watcher()
|
||||
{
|
||||
level thread game_over_via("end_game");
|
||||
level thread game_over_via("game_ended");
|
||||
level thread game_over_via("intermission");
|
||||
}
|
||||
|
||||
game_over_via(notify_name)
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
level waittill(notify_name);
|
||||
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).
|
||||
*/
|
||||
round_watcher()
|
||||
{
|
||||
last = 0;
|
||||
|
||||
if (isDefined(level.round_number))
|
||||
last = level.round_number;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (isDefined(level.round_number) && level.round_number != last)
|
||||
{
|
||||
last = level.round_number;
|
||||
level.round_start_time = getTime();
|
||||
|
||||
if (!isDefined(level.match_start_time) && level.round_number >= 1)
|
||||
{
|
||||
level.match_start_time = getTime();
|
||||
level notify("hud_match_started");
|
||||
}
|
||||
|
||||
level notify("hud_round_changed");
|
||||
}
|
||||
|
||||
wait (0.5);
|
||||
}
|
||||
}
|
||||
|
||||
hud_timer_row()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
// fallback seed for the round-1-already-active case (round_watcher misses it)
|
||||
if (!isDefined(level.match_start_time) &&
|
||||
isDefined(level.round_number) &&
|
||||
level.round_number >= 1)
|
||||
{
|
||||
level.match_start_time = getTime();
|
||||
level.round_start_time = getTime();
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
reset_timer_each_round(timer)
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level waittill("hud_round_changed");
|
||||
|
||||
if (isDefined(timer))
|
||||
timer SetTimerUp(0);
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimerUp only ticks with arg 0 on T5 SP; late joiners start at 0
|
||||
match_timer_bind(elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
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.
|
||||
*/
|
||||
freeze_timers_on_game_over(match_elem, round_elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level waittill("hud_game_over");
|
||||
|
||||
if (isDefined(match_elem) && isDefined(level.match_start_time))
|
||||
match_elem SetText(scripts\sp\tws_t5_common::format_mmss(getTime() - level.match_start_time));
|
||||
|
||||
if (isDefined(round_elem) && isDefined(level.round_start_time))
|
||||
round_elem SetText(scripts\sp\tws_t5_common::format_mmss(getTime() - level.round_start_time));
|
||||
}
|
||||
|
||||
hud_zombie_counter()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
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);
|
||||
|
||||
last = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
remaining = get_enemy_count() + level.zombie_total;
|
||||
|
||||
if (remaining != last)
|
||||
{
|
||||
count SetValue(remaining);
|
||||
last = remaining;
|
||||
}
|
||||
|
||||
wait (level.hud_rate);
|
||||
}
|
||||
}
|
||||
|
||||
get_enemy_count()
|
||||
{
|
||||
valid = [];
|
||||
enemies = GetAiSpeciesArray("axis", "all");
|
||||
|
||||
for (i = 0; i < enemies.size; i++)
|
||||
{
|
||||
if (is_true(enemies[i].ignore_enemy_count))
|
||||
continue;
|
||||
|
||||
if (isDefined(enemies[i].animname))
|
||||
valid[valid.size] = enemies[i];
|
||||
}
|
||||
|
||||
return valid.size;
|
||||
}
|
||||
|
||||
hud_health_display()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
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;
|
||||
last_health = -1;
|
||||
last_max = -1;
|
||||
last_shown = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (!isDefined(self.maxhealth) || self.maxhealth <= 0)
|
||||
self.maxhealth = 100;
|
||||
|
||||
frac = self.health / self.maxhealth;
|
||||
|
||||
if (frac > 1)
|
||||
frac = 1;
|
||||
|
||||
if (frac < 0)
|
||||
frac = 0;
|
||||
|
||||
width = int(max(frac * bar_width, 1));
|
||||
|
||||
if (width != last_width)
|
||||
{
|
||||
bar SetShader("white", width, level.hud_bar_height);
|
||||
last_width = width;
|
||||
}
|
||||
|
||||
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;
|
||||
max_hp.color = colour;
|
||||
|
||||
// numbers only show once you're hurt
|
||||
show = 0;
|
||||
|
||||
if (frac < level.hud_health_threshold)
|
||||
show = 1;
|
||||
|
||||
if (show != last_shown)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (self.maxhealth != last_max)
|
||||
{
|
||||
max_hp SetValue(self.maxhealth);
|
||||
last_max = self.maxhealth;
|
||||
}
|
||||
|
||||
wait (level.hud_rate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* tws_t5_join.gsc - .join lets a newcomer leave spectator mode before round end. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
scripts\sp\tws_t5_common::register_help_command(".join", "Join mid-round (newcomers only)");
|
||||
scripts\sp\tws_t5_common::register_spawn_handler(::join_spawn);
|
||||
|
||||
level thread join_chat_listener();
|
||||
}
|
||||
|
||||
// Never reset this on death or round change. Spectator spawns do not count.
|
||||
join_spawn()
|
||||
{
|
||||
self.tws_join_has_played = true;
|
||||
}
|
||||
|
||||
join_chat_listener()
|
||||
{
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
|
||||
if (toLower(message) == ".join")
|
||||
player thread join_request();
|
||||
}
|
||||
}
|
||||
|
||||
join_request()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
// Stock zombiemode sets initialized on the first playable spawn. This
|
||||
// also covers a spawn that happened before our handler was dispatched.
|
||||
// hasSpawned is unsuitable: stock sets it for spectators as well.
|
||||
if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used) ||
|
||||
is_true(self.initialized))
|
||||
{
|
||||
self iPrintLnBold("^3.join is only for newcomers who have not played yet");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail closed while the stock connection setup is still in progress.
|
||||
if (!isDefined(self.initialized) || !isDefined(self.sessionstate) ||
|
||||
self.sessionstate != "spectator")
|
||||
{
|
||||
self iPrintLnBold("^3.join is only available while waiting to enter the game");
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_true(level.intermission) || !isDefined(level.round_number) ||
|
||||
level.round_number < 1 || !isDefined(level.spawnPlayer) ||
|
||||
!join_has_active_player())
|
||||
{
|
||||
self iPrintLnBold("^3The game is not ready for .join");
|
||||
return;
|
||||
}
|
||||
|
||||
// Late connections can miss coop_player_spawn_placement, which seeds
|
||||
// this fallback for the stock spectator respawn callback.
|
||||
if (!isDefined(self.spectator_respawn))
|
||||
{
|
||||
points = getstructarray("initial_spawn_points", "targetname");
|
||||
if (!isDefined(points) || points.size == 0)
|
||||
{
|
||||
self iPrintLnBold("^3No spawn point available; wait for the next round");
|
||||
return;
|
||||
}
|
||||
|
||||
self.spectator_respawn = points[randomInt(points.size)];
|
||||
}
|
||||
|
||||
// Match stock spectators_respawn setup, including round 1 when that
|
||||
// routine may not have run yet. Preserve map-specific spawn callbacks.
|
||||
if (!isDefined(level.custom_spawnPlayer))
|
||||
level.custom_spawnPlayer = maps\_zombiemode::spectator_respawn;
|
||||
|
||||
// Consume BEFORE entering stock code, which may yield. Repeated chat
|
||||
// commands cannot start overlapping spawns or grant another life.
|
||||
self.tws_join_used = true;
|
||||
self [[level.spawnPlayer]]();
|
||||
}
|
||||
|
||||
join_has_active_player()
|
||||
{
|
||||
players = getPlayers();
|
||||
for (i = 0; i < players.size; i++)
|
||||
{
|
||||
player = players[i];
|
||||
if (isDefined(player.sessionstate) && player.sessionstate == "playing" &&
|
||||
isAlive(player) && !(player maps\_laststand::player_is_in_laststand()))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
if (GetDvar("tws_leaderboard_enabled") == "0")
|
||||
return;
|
||||
|
||||
level.tws_lb_game = "t5";
|
||||
level.tws_lb_url = GetDvar("tws_leaderboard_url");
|
||||
if (level.tws_lb_url == "")
|
||||
level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard";
|
||||
if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://")
|
||||
{
|
||||
lb_log("Disabled: tws_leaderboard_url must use HTTPS");
|
||||
return;
|
||||
}
|
||||
|
||||
level.tws_lb_override = GetDvar("tws_leaderboard_map_key");
|
||||
level.tws_lb_pending = undefined;
|
||||
level.tws_lb_cache = undefined;
|
||||
level.tws_lb_attempt = 0;
|
||||
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
|
||||
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
|
||||
level thread lb_chat_listener();
|
||||
level thread lb_worker();
|
||||
}
|
||||
|
||||
lb_worker()
|
||||
{
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
// The same byte table supports UTF-8 validation and URL percent encoding.
|
||||
level.tws_lb_bytes = [];
|
||||
for (i = 1; i < 256; i++)
|
||||
level.tws_lb_bytes[formatString("%c", i)] = i;
|
||||
wait 1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
// A timed-out native request cannot be cancelled. Never overlap it.
|
||||
while (isDefined(level.tws_lb_pending))
|
||||
wait 1;
|
||||
|
||||
snapshot = lb_refresh();
|
||||
level.tws_lb_attempt++;
|
||||
if (isDefined(snapshot))
|
||||
{
|
||||
snapshot.saved_at = getTime();
|
||||
level.tws_lb_cache = snapshot;
|
||||
wait 300;
|
||||
}
|
||||
else
|
||||
{
|
||||
lb_log("Refresh failed; retaining the last complete record, if available");
|
||||
wait 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lb_welcome()
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_requested");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
if (isDefined(self.tws_lb_welcomed))
|
||||
return;
|
||||
self.tws_lb_welcomed = true;
|
||||
|
||||
// Spectator readiness matters too: do not wait for a playable spawn.
|
||||
while (!isDefined(self.sessionstate) ||
|
||||
(self.sessionstate != "playing" && self.sessionstate != "spectator"))
|
||||
wait 0.25;
|
||||
while (isDefined(self.hasSpawned) && !self.hasSpawned)
|
||||
wait 0.25;
|
||||
wait 5;
|
||||
|
||||
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
|
||||
wait 0.25;
|
||||
|
||||
self lb_request(false);
|
||||
}
|
||||
|
||||
lb_chat_listener()
|
||||
{
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
if (toLower(message) == ".leaders")
|
||||
player thread lb_request(true);
|
||||
}
|
||||
}
|
||||
|
||||
lb_cached_record()
|
||||
{
|
||||
if (!isDefined(level.tws_lb_cache))
|
||||
return undefined;
|
||||
snapshot = level.tws_lb_cache;
|
||||
// Do not advertise an indefinitely stale record during an outage.
|
||||
if (getTime() - snapshot.saved_at > 900000)
|
||||
return undefined;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
lb_request(manual)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("end_game");
|
||||
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.
|
||||
if (manual)
|
||||
self notify("tws_lb_requested");
|
||||
self lb_show_hud(snapshot);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
lb_refresh()
|
||||
{
|
||||
selection = lb_selection();
|
||||
if (!isDefined(selection))
|
||||
{
|
||||
lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map keys are discovered and checked against the API's available maps.
|
||||
discovery = lb_get("&limit=1&offset=0");
|
||||
if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1))
|
||||
return undefined;
|
||||
if (!lb_resolve_map(selection, discovery["maps"]))
|
||||
return undefined;
|
||||
|
||||
snapshot = spawnStruct();
|
||||
snapshot.name = lb_display_text(selection.name, 64);
|
||||
snapshot.round = 0;
|
||||
snapshot.names = [];
|
||||
snapshot.guids = [];
|
||||
snapshot.lines = [];
|
||||
snapshot.total = -1;
|
||||
snapshot.last_round = 2147483647;
|
||||
|
||||
// No map option means no completed match has been recorded for this map.
|
||||
if (!selection.found)
|
||||
return snapshot;
|
||||
|
||||
offset = 0;
|
||||
for (;;)
|
||||
{
|
||||
page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset);
|
||||
if (!isDefined(page) || !lb_page_valid(page, offset, 3))
|
||||
return undefined;
|
||||
result = lb_take_page(snapshot, page, selection.key, offset);
|
||||
if (result < 0)
|
||||
{
|
||||
lb_log("Invalid or changing leaderboard; discarding incomplete refresh");
|
||||
return undefined;
|
||||
}
|
||||
if (result == 1)
|
||||
{
|
||||
snapshot.lines = lb_holder_lines(snapshot.names);
|
||||
return snapshot;
|
||||
}
|
||||
offset += page["entries"].size;
|
||||
if (offset > 1000000)
|
||||
{
|
||||
lb_log("Leaderboard exceeds the API pagination limit");
|
||||
return undefined;
|
||||
}
|
||||
wait 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
lb_selection()
|
||||
{
|
||||
selection = spawnStruct();
|
||||
selection.map = toLower(GetDvar("mapname"));
|
||||
selection.name = selection.map;
|
||||
selection.key = level.tws_lb_override;
|
||||
selection.explicit = selection.key != "";
|
||||
return selection;
|
||||
}
|
||||
|
||||
// All HTTP and parsing work belongs to the level worker, never the join thread.
|
||||
lb_get(query)
|
||||
{
|
||||
options = [];
|
||||
options["method"] = "GET";
|
||||
headers = [];
|
||||
headers["Accept"] = "application/json";
|
||||
options["headers"] = headers;
|
||||
url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query;
|
||||
request = http::request(url, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
lb_log("HTTP request could not be created");
|
||||
return undefined;
|
||||
}
|
||||
pending = spawnStruct();
|
||||
pending.done = false;
|
||||
level.tws_lb_pending = pending;
|
||||
level thread lb_collect(request, pending);
|
||||
started = getTime();
|
||||
while (!pending.done && getTime() - started < 15000)
|
||||
wait 0.1;
|
||||
if (!pending.done)
|
||||
{
|
||||
lb_log("HTTP timed out; waiting for native completion before requesting again");
|
||||
return undefined;
|
||||
}
|
||||
if (!isDefined(pending.success) || !pending.success)
|
||||
{
|
||||
lb_log("HTTP transport failed");
|
||||
return undefined;
|
||||
}
|
||||
// Plugins expose neither HTTP status nor response headers. Reject error
|
||||
// bodies by grammar and success-schema checks, not the transport flag.
|
||||
if (!lb_json_valid(pending.body))
|
||||
{
|
||||
lb_log("Rejected malformed, oversized or non-JSON HTTP response");
|
||||
return undefined;
|
||||
}
|
||||
return jsonParse(pending.body);
|
||||
}
|
||||
|
||||
lb_collect(request, pending)
|
||||
{
|
||||
// Keep draining after game over; ending a GSC thread cannot cancel HTTP.
|
||||
request waittill("done", body, success, error);
|
||||
pending.body = body;
|
||||
pending.success = success;
|
||||
pending.done = true;
|
||||
level.tws_lb_pending = undefined;
|
||||
}
|
||||
|
||||
lb_log(message)
|
||||
{
|
||||
print("[tws_t5_leaderboard] " + message);
|
||||
}
|
||||
|
||||
// Pure data helpers below are exercised by tools/check_leaderboard.mjs.
|
||||
lb_resolve_map(selection, maps)
|
||||
{
|
||||
selection.found = false;
|
||||
matches = 0;
|
||||
// T5 requires initialization before the loop, even with matches == 1 below.
|
||||
chosen = undefined;
|
||||
for (i = 0; i < maps.size; i++)
|
||||
{
|
||||
item = maps[i];
|
||||
if (typeof(item) != "array" || typeof(item["game"]) != "string" ||
|
||||
typeof(item["map"]) != "string" || typeof(item["key"]) != "string" ||
|
||||
typeof(item["name"]) != "string")
|
||||
return false;
|
||||
if (item["game"] != level.tws_lb_game || item["map"] != selection.map)
|
||||
continue;
|
||||
if (selection.key != "" && item["key"] != selection.key)
|
||||
continue;
|
||||
matches++;
|
||||
chosen = item;
|
||||
}
|
||||
if (matches > 1 || (matches == 0 && selection.explicit))
|
||||
{
|
||||
lb_log("Map key is absent, ambiguous or belongs to another map/game");
|
||||
return false;
|
||||
}
|
||||
if (matches == 1)
|
||||
{
|
||||
selection.key = chosen["key"];
|
||||
selection.name = chosen["name"];
|
||||
selection.found = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
lb_page_valid(page, offset, limit)
|
||||
{
|
||||
if (typeof(page) != "array" || typeof(page["entries"]) != "array" ||
|
||||
typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array")
|
||||
return false;
|
||||
if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"]))
|
||||
return false;
|
||||
if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit)
|
||||
return false;
|
||||
return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) &&
|
||||
page["entries"].size <= limit;
|
||||
}
|
||||
|
||||
// -1 rejects the whole refresh, 0 requests another page, 1 is complete.
|
||||
lb_take_page(snapshot, page, key, offset)
|
||||
{
|
||||
entries = page["entries"];
|
||||
if (offset == 0)
|
||||
{
|
||||
snapshot.round = page["summary"]["highestRound"];
|
||||
snapshot.total = page["total"];
|
||||
}
|
||||
if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"])
|
||||
return -1;
|
||||
if (entries.size == 0)
|
||||
{
|
||||
if (offset == 0 && snapshot.total == 0 && snapshot.round == 0)
|
||||
return 1;
|
||||
return -1;
|
||||
}
|
||||
if (snapshot.round < 2 || offset + entries.size > snapshot.total)
|
||||
return -1;
|
||||
for (i = 0; i < entries.size; i++)
|
||||
{
|
||||
entry = entries[i];
|
||||
if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"]))
|
||||
return -1;
|
||||
value = entry["highestRound"];
|
||||
if (value > snapshot.last_round || value > snapshot.round)
|
||||
return -1;
|
||||
snapshot.last_round = value;
|
||||
if (value < snapshot.round)
|
||||
{
|
||||
if (snapshot.names.size == 0)
|
||||
return -1;
|
||||
return 1;
|
||||
}
|
||||
if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" ||
|
||||
typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array")
|
||||
return -1;
|
||||
evidence = entry["records"]["highestRound"];
|
||||
if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" ||
|
||||
typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"]))
|
||||
return -1;
|
||||
if (evidence["game"] != level.tws_lb_game ||
|
||||
evidence["mapKey"] != key || evidence["value"] != snapshot.round)
|
||||
return -1;
|
||||
// GUIDs remain strings; equal names are distinct players.
|
||||
guid = entry["guid"];
|
||||
if (!isDefined(snapshot.guids[guid]))
|
||||
{
|
||||
snapshot.guids[guid] = true;
|
||||
name = lb_display_text(entry["name"], 64);
|
||||
if (name == "")
|
||||
name = lb_display_text(guid, 64);
|
||||
snapshot.names[snapshot.names.size] = name;
|
||||
}
|
||||
}
|
||||
if (offset + entries.size >= snapshot.total)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
lb_uint(value)
|
||||
{
|
||||
return typeof(value) == "int" && value >= 0;
|
||||
}
|
||||
|
||||
lb_holder_lines(names)
|
||||
{
|
||||
lines = [];
|
||||
line = "";
|
||||
for (i = 0; i < names.size; i++)
|
||||
{
|
||||
if (line != "" && line.size + names[i].size + 2 > 100)
|
||||
{
|
||||
lines[lines.size] = line;
|
||||
line = "";
|
||||
}
|
||||
if (line != "")
|
||||
line += ", ";
|
||||
line += names[i];
|
||||
}
|
||||
if (line != "")
|
||||
lines[lines.size] = line;
|
||||
return lines;
|
||||
}
|
||||
|
||||
lb_display_text(value, limit)
|
||||
{
|
||||
output = "";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
ch = getSubStr(value, i, i + 1);
|
||||
code = level.tws_lb_bytes[ch];
|
||||
if (!isDefined(code) || code < 32 || code == 127)
|
||||
continue;
|
||||
if (ch == "^")
|
||||
{
|
||||
// Strip game colour escapes; never let a name inject formatting.
|
||||
if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2)))
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
width = 1;
|
||||
if (code >= 240)
|
||||
width = 4;
|
||||
else if (code >= 224)
|
||||
width = 3;
|
||||
else if (code >= 192)
|
||||
width = 2;
|
||||
if (output.size + width > limit)
|
||||
return output + "...";
|
||||
output += getSubStr(value, i, i + width);
|
||||
i += width - 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
lb_url_encode(value)
|
||||
{
|
||||
hex = "0123456789ABCDEF";
|
||||
encoded = "";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
ch = getSubStr(value, i, i + 1);
|
||||
if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch))
|
||||
encoded += ch;
|
||||
else
|
||||
{
|
||||
code = level.tws_lb_bytes[ch];
|
||||
high = int(code / 16);
|
||||
low = code % 16;
|
||||
encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1);
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
lb_contains(chars, ch)
|
||||
{
|
||||
if (ch == "")
|
||||
return false;
|
||||
for (j = 0; j < chars.size; j++)
|
||||
{
|
||||
if (getSubStr(chars, j, j + 1) == ch)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// jsonParse raises a script error on malformed input. Validate the complete
|
||||
// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies.
|
||||
// Yield during scanning so network data cannot monopolize a server frame.
|
||||
lb_json_valid(body)
|
||||
{
|
||||
if (typeof(body) != "string" || body.size == 0 || body.size >= 20480)
|
||||
return false;
|
||||
if (!lb_utf8_valid(body))
|
||||
return false;
|
||||
state = spawnStruct();
|
||||
state.body = body;
|
||||
state.pos = 0;
|
||||
state.yield_at = 256;
|
||||
if (!lb_json_value(state, 0))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
return state.pos == body.size;
|
||||
}
|
||||
|
||||
lb_utf8_valid(body)
|
||||
{
|
||||
for (i = 0; i < body.size; i++)
|
||||
{
|
||||
if (i % 256 == 0)
|
||||
wait 0.05;
|
||||
code = level.tws_lb_bytes[getSubStr(body, i, i + 1)];
|
||||
if (!isDefined(code))
|
||||
return false;
|
||||
if (code < 128)
|
||||
continue;
|
||||
count = 0;
|
||||
minimum = 128;
|
||||
maximum = 191;
|
||||
if (code >= 194 && code <= 223)
|
||||
count = 1;
|
||||
else if (code >= 224 && code <= 239)
|
||||
{
|
||||
count = 2;
|
||||
if (code == 224)
|
||||
minimum = 160;
|
||||
if (code == 237)
|
||||
maximum = 159;
|
||||
}
|
||||
else if (code >= 240 && code <= 244)
|
||||
{
|
||||
count = 3;
|
||||
if (code == 240)
|
||||
minimum = 144;
|
||||
if (code == 244)
|
||||
maximum = 143;
|
||||
}
|
||||
if (count == 0 || i + count >= body.size)
|
||||
return false;
|
||||
for (j = 1; j <= count; j++)
|
||||
{
|
||||
next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)];
|
||||
if (!isDefined(next) || next < minimum || next > maximum)
|
||||
return false;
|
||||
minimum = 128;
|
||||
maximum = 191;
|
||||
}
|
||||
i += count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
lb_json_char(state)
|
||||
{
|
||||
if (state.pos >= state.yield_at)
|
||||
{
|
||||
wait 0.05;
|
||||
state.yield_at = state.pos + 256;
|
||||
}
|
||||
if (state.pos >= state.body.size)
|
||||
return "";
|
||||
return getSubStr(state.body, state.pos, state.pos + 1);
|
||||
}
|
||||
|
||||
lb_json_space(state)
|
||||
{
|
||||
while (lb_contains(" \t\r\n", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
|
||||
lb_json_value(state, depth)
|
||||
{
|
||||
if (depth > 16)
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
ch = lb_json_char(state);
|
||||
if (ch == "\"")
|
||||
return lb_json_string(state);
|
||||
if (ch == "{" || ch == "[")
|
||||
{
|
||||
object = ch == "{";
|
||||
close = "]";
|
||||
if (object)
|
||||
close = "}";
|
||||
state.pos++;
|
||||
lb_json_space(state);
|
||||
if (lb_json_char(state) == close)
|
||||
{
|
||||
state.pos++;
|
||||
return true;
|
||||
}
|
||||
for (;;)
|
||||
{
|
||||
if (object)
|
||||
{
|
||||
if (!lb_json_string(state))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
if (lb_json_char(state) != ":")
|
||||
return false;
|
||||
state.pos++;
|
||||
}
|
||||
if (!lb_json_value(state, depth + 1))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (ch == close)
|
||||
return true;
|
||||
if (ch != ",")
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
}
|
||||
}
|
||||
literals = strTok("true,false,null", ",");
|
||||
for (i = 0; i < literals.size; i++)
|
||||
{
|
||||
literal = literals[i];
|
||||
if (state.pos + literal.size <= state.body.size &&
|
||||
getSubStr(state.body, state.pos, state.pos + literal.size) == literal)
|
||||
{
|
||||
state.pos += literal.size;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return lb_json_number(state);
|
||||
}
|
||||
|
||||
lb_json_string(state)
|
||||
{
|
||||
if (lb_json_char(state) != "\"")
|
||||
return false;
|
||||
state.pos++;
|
||||
while (state.pos < state.body.size)
|
||||
{
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (ch == "\"")
|
||||
return true;
|
||||
if (level.tws_lb_bytes[ch] < 32)
|
||||
return false;
|
||||
if (ch != "\\")
|
||||
continue;
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (lb_contains("\"\\/bfnrt", ch))
|
||||
continue;
|
||||
if (ch != "u")
|
||||
return false;
|
||||
code = lb_json_hex4(state);
|
||||
// Embedded NUL does not round-trip through engine strings.
|
||||
if (code <= 0 || (code >= 56320 && code <= 57343))
|
||||
return false;
|
||||
if (code >= 55296 && code <= 56319)
|
||||
{
|
||||
if (state.pos + 2 > state.body.size ||
|
||||
getSubStr(state.body, state.pos, state.pos + 2) != "\\u")
|
||||
return false;
|
||||
state.pos += 2;
|
||||
code = lb_json_hex4(state);
|
||||
if (code < 56320 || code > 57343)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
lb_json_hex4(state)
|
||||
{
|
||||
hex = "0123456789abcdef";
|
||||
result = 0;
|
||||
for (i = 0; i < 4; i++)
|
||||
{
|
||||
ch = toLower(lb_json_char(state));
|
||||
if (!lb_contains(hex, ch))
|
||||
return -1;
|
||||
for (j = 0; j < 16; j++)
|
||||
{
|
||||
if (getSubStr(hex, j, j + 1) == ch)
|
||||
break;
|
||||
}
|
||||
result = result * 16 + j;
|
||||
state.pos++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
lb_json_number(state)
|
||||
{
|
||||
start = state.pos;
|
||||
if (lb_json_char(state) == "-")
|
||||
state.pos++;
|
||||
ch = lb_json_char(state);
|
||||
if (ch == "0")
|
||||
state.pos++;
|
||||
else
|
||||
{
|
||||
if (!lb_contains("123456789", ch))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
if (lb_json_char(state) == ".")
|
||||
{
|
||||
state.pos++;
|
||||
if (!lb_contains("0123456789", lb_json_char(state)))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
if (lb_contains("eE", lb_json_char(state)))
|
||||
{
|
||||
state.pos++;
|
||||
if (lb_contains("+-", lb_json_char(state)))
|
||||
state.pos++;
|
||||
exponent = state.pos;
|
||||
if (!lb_contains("0123456789", lb_json_char(state)))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
// API counters/ratios never need huge exponents; prevent native overflow.
|
||||
if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38)
|
||||
return false;
|
||||
}
|
||||
return state.pos - start <= 32;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
tws_t5_lock.gsc — lobby g_password lock at milestone round. Existing players
|
||||
are unaffected; g_password is only checked at connect. Cleared on
|
||||
hud_game_over so the next lobby opens unlocked.
|
||||
*/
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
|
||||
lock_init();
|
||||
}
|
||||
|
||||
lock_init()
|
||||
{
|
||||
level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20);
|
||||
level.lock_active = false;
|
||||
|
||||
// recover from a prior lobby that crashed before clearing
|
||||
setDvar("g_password", "");
|
||||
|
||||
level thread lock_round_watcher();
|
||||
level thread lock_cleanup_watcher();
|
||||
}
|
||||
|
||||
lock_round_watcher()
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level waittill("hud_round_changed");
|
||||
|
||||
if (level.lock_active)
|
||||
continue;
|
||||
|
||||
if (!isDefined(level.round_number) || level.round_number < level.lock_round)
|
||||
continue;
|
||||
|
||||
// random per activation so the password can't be inferred from source
|
||||
password = "lock_" + getTime() + "_" + randomInt(1000000);
|
||||
setDvar("g_password", password);
|
||||
level.lock_active = true;
|
||||
|
||||
lock_announce();
|
||||
}
|
||||
}
|
||||
|
||||
lock_cleanup_watcher()
|
||||
{
|
||||
level waittill("hud_game_over");
|
||||
setDvar("g_password", "");
|
||||
level.lock_active = false;
|
||||
}
|
||||
|
||||
lock_announce()
|
||||
{
|
||||
players = getPlayers();
|
||||
for (i = 0; i < players.size; i++)
|
||||
players[i] iPrintLnBold("^3Server locked");
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/* tws_t5_telemetry.gsc - Electric Cherry game reports via tws-t5-gsc-utils. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
|
||||
if (GetDvar("tws_telemetry_enabled") == "0")
|
||||
{
|
||||
print("[tws_t5_telemetry] Disabled by tws_telemetry_enabled=0");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(level.telemetry_initialized))
|
||||
return;
|
||||
|
||||
level.telemetry_url = GetDvar("tws_telemetry_url");
|
||||
if (level.telemetry_url == "")
|
||||
level.telemetry_url = "https://ec.thewrightserver.net/api/telemetry";
|
||||
|
||||
if (getSubStr(toLower(level.telemetry_url), 0, 8) != "https://")
|
||||
{
|
||||
print("^1[tws_t5_telemetry] Telemetry disabled: tws_telemetry_url must use HTTPS");
|
||||
return;
|
||||
}
|
||||
|
||||
// Shared reporter secret; update both scripts when rotating it.
|
||||
// This must match Electric Cherry's EC_TELEMETRY_TOKEN environment variable.
|
||||
level.telemetry_token = "e03a03f7d22418f0c062b0b2a7317fd91b1194c4400e84b469866de501180dc2";
|
||||
if (level.telemetry_token.size < 32)
|
||||
{
|
||||
print("^1[tws_t5_telemetry] Telemetry disabled: embedded telemetry token must be at least 32 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_server = GetDvar("tws_server_id");
|
||||
if (level.telemetry_server == "")
|
||||
{
|
||||
print("^1[tws_t5_telemetry] Set tws_server_id to the registered Electric Cherry server name");
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_initialized = true;
|
||||
level.telemetry_started = false;
|
||||
level.telemetry_ended = false;
|
||||
level.telemetry_last_round = -1;
|
||||
level.telemetry_queue = [];
|
||||
level.telemetry_queue_read = 0;
|
||||
level.telemetry_queue_write = 0;
|
||||
level.telemetry_sequence = 0;
|
||||
level.telemetry_completed = 0;
|
||||
level.telemetry_failed = 0;
|
||||
level.telemetry_http_rejected = 0;
|
||||
level.telemetry_missing_diagnostics_warned = false;
|
||||
level.telemetry_failure_streak = 0;
|
||||
level.telemetry_last_completed = -1;
|
||||
level.telemetry_last_capture = getTime();
|
||||
level.telemetry_round_tick = getTime();
|
||||
level.telemetry_capture_event = "none";
|
||||
level.telemetry_sender_state = "starting";
|
||||
level.telemetry_inflight = undefined;
|
||||
|
||||
telemetry_log(1, "Initialized; waiting for match start. log_level=" + telemetry_log_level());
|
||||
|
||||
// Use the shared dispatcher; a second connected listener breaks T5 SP.
|
||||
scripts\sp\tws_t5_common::register_spawn_handler(::telemetry_game_start);
|
||||
|
||||
level thread telemetry_game_end_watcher();
|
||||
level thread telemetry_sender();
|
||||
level thread telemetry_round_watcher();
|
||||
level thread telemetry_watchdog();
|
||||
}
|
||||
|
||||
// First playable spawn starts the match, including maps with a round-0 intro.
|
||||
// Later spawns and joins must not reopen the match or repeat game_start.
|
||||
telemetry_game_start()
|
||||
{
|
||||
if (level.telemetry_started || level.telemetry_ended)
|
||||
return;
|
||||
|
||||
level.telemetry_started = true;
|
||||
level.telemetry_last_capture = getTime();
|
||||
telemetry_log(1, "Match start detected");
|
||||
telemetry_enqueue("game_start");
|
||||
level thread telemetry_heartbeat_watcher();
|
||||
}
|
||||
|
||||
// Start the interval with the match, independently of round changes or joins.
|
||||
telemetry_heartbeat_watcher()
|
||||
{
|
||||
level endon("telemetry_finished");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
wait (30);
|
||||
telemetry_report_round();
|
||||
telemetry_enqueue("heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
// Independent of HUD installation and player count. begin_spawning also
|
||||
// catches a start whose spawn predates our dispatcher, or has no players.
|
||||
telemetry_round_watcher()
|
||||
{
|
||||
level endon("telemetry_finished");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level.telemetry_round_tick = getTime();
|
||||
if (!level.telemetry_started && isDefined(level.flag) &&
|
||||
isDefined(level.flag["begin_spawning"]) && level.flag["begin_spawning"])
|
||||
{
|
||||
telemetry_log(1, "Starting from begin_spawning fallback");
|
||||
telemetry_game_start();
|
||||
}
|
||||
|
||||
telemetry_report_round();
|
||||
wait (0.5);
|
||||
}
|
||||
}
|
||||
|
||||
telemetry_report_round()
|
||||
{
|
||||
if (!level.telemetry_started || level.telemetry_ended ||
|
||||
!isDefined(level.round_number) || level.round_number < 0)
|
||||
return;
|
||||
|
||||
round = telemetry_nonnegative_int(level.round_number);
|
||||
if (round == level.telemetry_last_round)
|
||||
return;
|
||||
|
||||
level.telemetry_last_round = round;
|
||||
telemetry_enqueue("round_change");
|
||||
}
|
||||
|
||||
telemetry_game_end_watcher()
|
||||
{
|
||||
// One waiter consumes the first end signal, even if a map fires all three.
|
||||
level waittill_any("end_game", "game_ended", "intermission");
|
||||
telemetry_log(1, "Game-end signal received; stopping producers and draining queue");
|
||||
|
||||
// Preserve order even if the match ends before the next polling tick.
|
||||
telemetry_game_start();
|
||||
telemetry_report_round();
|
||||
level.telemetry_ended = true;
|
||||
level notify("telemetry_finished");
|
||||
telemetry_enqueue("game_end");
|
||||
}
|
||||
|
||||
telemetry_nonnegative_int(value)
|
||||
{
|
||||
if (!isDefined(value) || value < 0)
|
||||
return 0;
|
||||
|
||||
return int(value);
|
||||
}
|
||||
|
||||
telemetry_stat(player, name)
|
||||
{
|
||||
if (!isDefined(player.stats) || !isDefined(player.stats[name]))
|
||||
return 0;
|
||||
|
||||
return telemetry_nonnegative_int(player.stats[name]);
|
||||
}
|
||||
|
||||
telemetry_players_json(include_stats)
|
||||
{
|
||||
players = getPlayers();
|
||||
body = "[";
|
||||
|
||||
for (i = 0; i < players.size; i++)
|
||||
{
|
||||
player = players[i];
|
||||
if (!isDefined(player))
|
||||
{
|
||||
telemetry_log(0, "WARNING: skipping undefined player at roster index " + i);
|
||||
continue;
|
||||
}
|
||||
|
||||
data = [];
|
||||
if (isDefined(player.name))
|
||||
data["name"] = "" + player.name;
|
||||
else if (isDefined(player.playername))
|
||||
data["name"] = "" + player.playername;
|
||||
else
|
||||
{
|
||||
telemetry_log(0, "WARNING: skipping player without a name at roster index " + i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// getGuid returns a string. Never pass it through int or float.
|
||||
guid = player getGuid();
|
||||
if (!isDefined(guid) || guid == "" || data["name"] == "")
|
||||
{
|
||||
telemetry_log(0, "WARNING: skipping player with incomplete identity at roster index " + i);
|
||||
continue;
|
||||
}
|
||||
data["guid"] = "" + guid;
|
||||
|
||||
if (include_stats)
|
||||
{
|
||||
data["kills"] = telemetry_stat(player, "kills");
|
||||
data["headshots"] = telemetry_stat(player, "headshots");
|
||||
data["downs"] = telemetry_stat(player, "downs");
|
||||
data["revives"] = telemetry_stat(player, "revives");
|
||||
// Current spendable points, not the lifetime points-earned stat.
|
||||
data["score"] = telemetry_nonnegative_int(player.score);
|
||||
}
|
||||
|
||||
if (body != "[")
|
||||
body += ",";
|
||||
body += jsonSerialize(data);
|
||||
}
|
||||
|
||||
// t5-gsc-utils serializes an empty GSC array as null. Build only the
|
||||
// array delimiters ourselves; the serializer still escapes all strings.
|
||||
return body + "]";
|
||||
}
|
||||
|
||||
telemetry_enqueue(event_name)
|
||||
{
|
||||
level.telemetry_capture_event = event_name;
|
||||
telemetry_log(2, "Capturing event=" + event_name);
|
||||
// Snapshot and serialize before yielding, including during game over.
|
||||
body = "{\"event\":" + jsonSerialize(event_name);
|
||||
body += ",\"server\":" + jsonSerialize(level.telemetry_server);
|
||||
body += ",\"map\":" + jsonSerialize(GetDvar("mapname"));
|
||||
if (event_name != "game_start")
|
||||
body += ",\"round\":" + telemetry_nonnegative_int(level.round_number);
|
||||
body += ",\"players\":" + telemetry_players_json(event_name != "game_start") + "}";
|
||||
|
||||
entry = [];
|
||||
level.telemetry_sequence++;
|
||||
entry["id"] = level.telemetry_sequence;
|
||||
entry["event"] = event_name;
|
||||
entry["round"] = telemetry_nonnegative_int(level.round_number);
|
||||
entry["captured"] = getTime();
|
||||
entry["body"] = body;
|
||||
level.telemetry_queue[level.telemetry_queue_write] = entry;
|
||||
level.telemetry_queue_write++;
|
||||
level.telemetry_last_capture = getTime();
|
||||
level.telemetry_capture_event = "none";
|
||||
telemetry_log(2, "Queued " + telemetry_event_label(entry) + " pending=" + telemetry_pending());
|
||||
level notify("telemetry_queued");
|
||||
}
|
||||
|
||||
// A single worker keeps one request in flight. It survives game-over notifies
|
||||
// to drain the final snapshot; no retries, discovery, or beta-specific calls.
|
||||
telemetry_sender()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
if (level.telemetry_queue_read == level.telemetry_queue_write)
|
||||
{
|
||||
level.telemetry_queue = [];
|
||||
level.telemetry_queue_read = 0;
|
||||
level.telemetry_queue_write = 0;
|
||||
if (level.telemetry_ended)
|
||||
{
|
||||
level.telemetry_sender_state = "drained";
|
||||
telemetry_log(1, "Queue drained after game end; completed=" + level.telemetry_completed + " failed=" + level.telemetry_failed);
|
||||
level notify("telemetry_drained");
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_sender_state = "idle";
|
||||
level waittill("telemetry_queued");
|
||||
continue;
|
||||
}
|
||||
|
||||
entry = level.telemetry_queue[level.telemetry_queue_read];
|
||||
level.telemetry_queue[level.telemetry_queue_read] = undefined;
|
||||
level.telemetry_queue_read++;
|
||||
telemetry_send(entry);
|
||||
}
|
||||
}
|
||||
|
||||
telemetry_send(entry)
|
||||
{
|
||||
level.telemetry_inflight = entry;
|
||||
level.telemetry_request_started = getTime();
|
||||
level.telemetry_sender_state = "creating_request";
|
||||
label = telemetry_event_label(entry);
|
||||
telemetry_log(1, "Sending " + label + " queue_ms=" + (getTime() - entry["captured"]) + " pending=" + telemetry_pending());
|
||||
|
||||
headers = [];
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Authorization"] = "Bearer " + level.telemetry_token;
|
||||
|
||||
options = [];
|
||||
options["method"] = "POST";
|
||||
options["headers"] = headers;
|
||||
options["body"] = entry["body"];
|
||||
options["connect_timeout_ms"] = 5000;
|
||||
options["timeout_ms"] = 15000;
|
||||
|
||||
request = http::request(level.telemetry_url, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
telemetry_transport_failed(label + " request handle is undefined; skipping event");
|
||||
level.telemetry_inflight = undefined;
|
||||
level.telemetry_sender_state = "idle";
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_sender_state = "waiting_http";
|
||||
request waittill("done", response, success, error, diagnostics);
|
||||
elapsed = getTime() - level.telemetry_request_started;
|
||||
level.telemetry_last_completed = getTime();
|
||||
timing = telemetry_http_diagnostics(diagnostics);
|
||||
if (!isDefined(diagnostics) && !level.telemetry_missing_diagnostics_warned)
|
||||
{
|
||||
level.telemetry_missing_diagnostics_warned = true;
|
||||
telemetry_log(0, "WARNING: plugin returned no HTTP diagnostics; native timeouts are unverified. Install the TWS plugin build and restart the server process");
|
||||
}
|
||||
|
||||
// The plugin's success flag reports transport completion, not HTTP status.
|
||||
// No automatic retry: the receiver may already have accepted the event.
|
||||
if (!isDefined(success) || !success)
|
||||
{
|
||||
if (!isDefined(error))
|
||||
error = "unknown transport error";
|
||||
telemetry_transport_failed(label + " elapsed_ms=" + elapsed + timing + " error=" + telemetry_preview(error));
|
||||
}
|
||||
else
|
||||
{
|
||||
level.telemetry_completed++;
|
||||
if (level.telemetry_failure_streak > 0)
|
||||
telemetry_log(0, "Transport recovered after " + level.telemetry_failure_streak + " failures; " + label);
|
||||
level.telemetry_failure_streak = 0;
|
||||
if (isDefined(diagnostics) && isDefined(diagnostics["http_status"]))
|
||||
{
|
||||
if (diagnostics["http_status"] < 200 || diagnostics["http_status"] >= 300)
|
||||
{
|
||||
level.telemetry_http_rejected++;
|
||||
telemetry_log(0, "ERROR: HTTP rejected " + label + " elapsed_ms=" + elapsed + timing + " body=" + telemetry_preview(response));
|
||||
}
|
||||
else
|
||||
telemetry_log(1, "HTTP completed " + label + " elapsed_ms=" + elapsed + timing);
|
||||
}
|
||||
else
|
||||
telemetry_log(1, "Transport completed " + label + " elapsed_ms=" + elapsed + timing + " (API acceptance unverified)");
|
||||
if (!isDefined(response) || response == "")
|
||||
telemetry_log(0, "WARNING: empty API response for " + label);
|
||||
else
|
||||
telemetry_log(2, "Response " + label + " body=" + telemetry_preview(response));
|
||||
}
|
||||
|
||||
level.telemetry_inflight = undefined;
|
||||
level.telemetry_sender_state = "idle";
|
||||
}
|
||||
|
||||
// The native plugin appends diagnostics while preserving the old done arguments.
|
||||
// Curl timings are cumulative milestones, measured in real milliseconds.
|
||||
telemetry_http_diagnostics(diagnostics)
|
||||
{
|
||||
if (!isDefined(diagnostics))
|
||||
return " diagnostics=unavailable";
|
||||
|
||||
keys = strTok("http_status,curl_code,dns_ms,connect_ms,tls_ms,first_byte_ms,total_ms,worker_queue_ms,notify_ms,connect_timeout_ms,timeout_ms", ",");
|
||||
detail = "";
|
||||
for (i = 0; i < keys.size; i++)
|
||||
{
|
||||
key = keys[i];
|
||||
if (isDefined(diagnostics[key]))
|
||||
detail += " " + key + "=" + int(diagnostics[key]);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
telemetry_transport_failed(detail)
|
||||
{
|
||||
level.telemetry_failed++;
|
||||
level.telemetry_failure_streak++;
|
||||
telemetry_log(0, "ERROR: transport failed " + detail + " consecutive_failures=" + level.telemetry_failure_streak);
|
||||
}
|
||||
|
||||
telemetry_pending()
|
||||
{
|
||||
return level.telemetry_queue_write - level.telemetry_queue_read;
|
||||
}
|
||||
|
||||
telemetry_event_label(entry)
|
||||
{
|
||||
return "id=" + entry["id"] + " event=" + entry["event"] + " round=" + entry["round"];
|
||||
}
|
||||
|
||||
// Read live so diagnostics can be increased without restarting the map.
|
||||
telemetry_log_level()
|
||||
{
|
||||
raw = GetDvar("tws_telemetry_log_level");
|
||||
if (raw == "0")
|
||||
return 0;
|
||||
if (raw == "2")
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
telemetry_log(verbosity, message)
|
||||
{
|
||||
if (verbosity > telemetry_log_level())
|
||||
return;
|
||||
|
||||
print("[tws_t5_telemetry] server=" + level.telemetry_server + " map=" + GetDvar("mapname") + " time_ms=" + getTime() + " " + message);
|
||||
}
|
||||
|
||||
// Escape newlines/control characters and bound response/error output. Do not
|
||||
// jsonParse arbitrary replies: a proxy may return HTML or malformed JSON.
|
||||
telemetry_preview(value)
|
||||
{
|
||||
if (!isDefined(value))
|
||||
return "<undefined>";
|
||||
value = "" + value;
|
||||
if (value.size > 256)
|
||||
value = getSubStr(value, 0, 256) + "... [truncated]";
|
||||
return jsonSerialize(value);
|
||||
}
|
||||
|
||||
// Independent of producers and the HTTP wait; survives game over until drain.
|
||||
// Native requests have deadlines. Warn without opening overlapping POSTs
|
||||
// that could arrive out of order or exhaust its native worker pool.
|
||||
telemetry_watchdog()
|
||||
{
|
||||
level endon("telemetry_drained");
|
||||
for (;;)
|
||||
{
|
||||
wait (30);
|
||||
now = getTime();
|
||||
pending = telemetry_pending();
|
||||
completion_age = -1;
|
||||
if (level.telemetry_last_completed >= 0)
|
||||
completion_age = now - level.telemetry_last_completed;
|
||||
|
||||
telemetry_log(1, "Health started=" + level.telemetry_started + " ended=" + level.telemetry_ended +
|
||||
" sender=" + level.telemetry_sender_state + " capturing=" + level.telemetry_capture_event +
|
||||
" pending=" + pending + " queued_total=" + level.telemetry_sequence +
|
||||
" completed=" + level.telemetry_completed + " failed=" + level.telemetry_failed + " http_rejected=" + level.telemetry_http_rejected +
|
||||
" last_done_ms_ago=" + completion_age);
|
||||
|
||||
if (isDefined(level.telemetry_inflight))
|
||||
{
|
||||
age = now - level.telemetry_request_started;
|
||||
if (age >= 30000)
|
||||
telemetry_log(0, "WARNING: HTTP stalled " + telemetry_event_label(level.telemetry_inflight) +
|
||||
" elapsed_ms=" + age + " pending=" + pending + " stage=" + level.telemetry_sender_state +
|
||||
"; native deadline exceeded; check plugin version, worker queue and completion delivery");
|
||||
}
|
||||
else if (pending > 0)
|
||||
{
|
||||
oldest = level.telemetry_queue[level.telemetry_queue_read];
|
||||
if (now - oldest["captured"] >= 30000)
|
||||
telemetry_log(0, "WARNING: sender is not consuming queued reports; " + telemetry_event_label(oldest));
|
||||
}
|
||||
|
||||
if (!level.telemetry_ended)
|
||||
{
|
||||
if (now - level.telemetry_round_tick > 5000)
|
||||
telemetry_log(0, "WARNING: round watcher has stopped advancing");
|
||||
if (level.telemetry_started && now - level.telemetry_last_capture >= 65000)
|
||||
telemetry_log(0, "WARNING: no snapshot captured for " + (now - level.telemetry_last_capture) +
|
||||
"ms; capture_event=" + level.telemetry_capture_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user