Import T5-AIO and T6 into combined repo
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# tws-t5-aio notes
|
||||
|
||||
Reference for the `tws_*.gsc` files in this repo. Platform quirks and
|
||||
load-bearing decisions that show up as invariants in the code.
|
||||
|
||||
## Install
|
||||
|
||||
Copy all `tws_*.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
|
||||
|
||||
## Platform quirks (T5 SP engine, Plutonium)
|
||||
|
||||
- **One `level waittill("connected")` only.** `tws_common` owns it. A
|
||||
second listener breaks the T5 SP connect handshake. Per-player work goes
|
||||
through `tws_common::register_spawn_handler` and gets dispatched after
|
||||
`spawned_player`.
|
||||
- **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`, in addition
|
||||
to file IO, console commands and MySQL. Telemetry requires 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).
|
||||
- **Bank writes DB-first, memory-second.** Every mutation writes to the DB
|
||||
and only updates `self.bank` / `self.score` after the write confirms. A
|
||||
dead DB can never create phantom money. Deposits use `INSERT ... ON
|
||||
DUPLICATE KEY UPDATE`; withdrawals gate on `WHERE balance >= ?` so the DB
|
||||
itself blocks overdrafts under concurrent commands.
|
||||
- **Bank command surface is `command::add_sv` (server console), not chat.**
|
||||
SP-engine chat is unreliable and `chat::register_command` isn't available
|
||||
in `t5-gsc-utils`.
|
||||
|
||||
## Current state / caveats
|
||||
|
||||
- `bank_load` in `tws_bank.gsc` is a **diagnostic stub**: bare minimum to
|
||||
isolate what specifically kills the connect handshake. Do not restore the
|
||||
stripped `getGuid` / MySQL body until the stub passes join cleanly.
|
||||
- DB credentials in `tws_db.gsc` are hardcoded for the initial fleet test.
|
||||
Move to dvars (`tws_db_host` / `tws_db_user` / `tws_db_password` /
|
||||
`tws_db_name`) before this repo goes public — otherwise the fleet's DB
|
||||
password lives in source.
|
||||
|
||||
## Module map
|
||||
|
||||
| File | Role | Fires notifies | Reads notifies |
|
||||
|---|---|---|---|
|
||||
| `tws_common` | Shared config, HUD helpers, spawn-handler registry, single connect listener + dispatcher | — | — |
|
||||
| `tws_db` | MariaDB pool config + reachability probe | `db_probe_done` | — |
|
||||
| `tws_hud` | Timer row, zombie count, health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) |
|
||||
| `tws_bank` | Persistent bank + `bank` / `deposit` / `withdraw` commands | — | `db_probe_done` |
|
||||
| `tws_afk` | `.afk` / `.back` chat toggle, owns its [AFK] indicator | — | — |
|
||||
| `tws_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` |
|
||||
| `tws_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` |
|
||||
| `tws_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` |
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
# tws-t5-aio
|
||||
|
||||
Custom HUD + persistent bank + AFK toggle + immediate join + lobby lock + game telemetry for Plutonium T5
|
||||
(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files,
|
||||
each installable / removable in isolation (with `tws_common` as the shared
|
||||
hub).
|
||||
|
||||
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [tws_common.gsc](tws_common.gsc) | Shared config, HUD helpers, spawn-handler registry, single `connected` listener + dispatcher |
|
||||
| [tws_db.gsc](tws_db.gsc) | MariaDB pool config + reachability probe |
|
||||
| [tws_hud.gsc](tws_hud.gsc) | Bottom-left HUD stack: timers, zombie count, health bar |
|
||||
| [tws_bank.gsc](tws_bank.gsc) | Persistent per-player bank + console commands |
|
||||
| [tws_afk.gsc](tws_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator |
|
||||
| [tws_join.gsc](tws_join.gsc) | `.join` lets newcomers spawn before the current round ends |
|
||||
| [tws_lock.gsc](tws_lock.gsc) | Lobby `g_password` lock at a milestone round |
|
||||
| [tws_telemetry.gsc](tws_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
|
||||
|
||||
## Dependency graph
|
||||
|
||||
Arrows read "depends on" — solid means the source names the target with
|
||||
`scripts\sp\tws_common::...`, dashed means the source waits on a level
|
||||
notify fired by the target. Transitive edges are elided (so `tws_bank`
|
||||
and `tws_lock` reach `tws_common` through `tws_db` / `tws_hud` on the
|
||||
graph, but they also call `tws_common` directly).
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
common[tws_common<br/><i>config + helpers + dispatcher</i>]
|
||||
|
||||
db[tws_db]
|
||||
hud[tws_hud]
|
||||
afk[tws_afk]
|
||||
join[tws_join]
|
||||
bank[tws_bank]
|
||||
lock[tws_lock]
|
||||
telemetry[tws_telemetry]
|
||||
|
||||
db --> common
|
||||
hud --> common
|
||||
afk --> common
|
||||
join --> common
|
||||
telemetry --> common
|
||||
|
||||
bank -. db_probe_done .-> db
|
||||
lock -. hud_round_changed<br/>hud_game_over .-> hud
|
||||
```
|
||||
|
||||
- `tws_common` is the only module named by other files at compile time.
|
||||
- All other cross-module coupling flows through level notifies
|
||||
(`db_probe_done`, `hud_round_changed`, `hud_match_started`,
|
||||
`hud_game_over`) or through spawn handlers registered with
|
||||
`tws_common::register_spawn_handler`.
|
||||
- Any file except `tws_common` can be removed; the rest still load.
|
||||
`tws_afk` has no downstream consumers — pull it independently.
|
||||
`tws_bank` needs `tws_db`; `tws_lock` needs `tws_hud`.
|
||||
|
||||
## Runtime notifies
|
||||
|
||||
| Notify | Fired by | Read by |
|
||||
|---|---|---|
|
||||
| `db_probe_done` | `tws_db` | `tws_bank` (via `bank_deferred_start`) |
|
||||
| `hud_round_changed` | `tws_hud` | `tws_hud` (round-timer reset), `tws_lock` |
|
||||
| `hud_match_started` | `tws_hud` | `tws_hud` (match-timer reveal) |
|
||||
| `hud_game_over` | `tws_hud` | `tws_hud` (freeze timers), `tws_lock` (clear password) |
|
||||
|
||||
---
|
||||
|
||||
## tws_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
|
||||
- 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` |
|
||||
| `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).
|
||||
|
||||
---
|
||||
|
||||
## tws_db.gsc
|
||||
|
||||
**Features**
|
||||
- One MariaDB connection pool for the whole script (used by bank now,
|
||||
leaderboard later)
|
||||
- Reachability probe: round-trips `SELECT 1` and flips `level.db_online`
|
||||
- Fires `db_probe_done` so waiters can proceed regardless of outcome
|
||||
|
||||
**Provides**
|
||||
| Level var / notify | Meaning |
|
||||
|---|---|
|
||||
| `level.db_online` | `true` iff the probe query returned without error |
|
||||
| `level.db_probe_done` | `true` once the probe has completed (either way) |
|
||||
| notify `db_probe_done` | Fired once after probe completion |
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_common::ensure_config()`
|
||||
- `t5-gsc-utils` (`mysql::set_config`, `mysql::query`)
|
||||
|
||||
**Config (currently hardcoded — TODO: dvarize before repo goes public)**
|
||||
- Host `mariadb`, port `3306`, DB `tws_zm`, user `josh`, password `joshster`
|
||||
|
||||
---
|
||||
|
||||
## tws_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_common::` — `ensure_config`, `make_text_elem`,
|
||||
`make_shader_elem`, `destroy_on_disconnect`, `format_mmss`,
|
||||
`register_spawn_handler`
|
||||
- Every `level.hud_*` config value from `tws_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_bank.gsc
|
||||
|
||||
Persistent per-player bank. Balances survive server hops within the fleet
|
||||
because rows are keyed on XUID.
|
||||
|
||||
**Features**
|
||||
- Server-console commands: `bank`, `deposit <amount>`, `withdraw <amount>`
|
||||
- DB-first / memory-second: `self.bank` and `self.score` only move after
|
||||
the DB write confirms
|
||||
- Deposits: `INSERT ... ON DUPLICATE KEY UPDATE` — lazy row create, atomic
|
||||
- Withdrawals: `WHERE balance >= ?` — DB blocks overdrafts under concurrency
|
||||
- Score-drift refund guard on deposit
|
||||
- `bank_load` currently a **diagnostic stub** (bare-minimum to isolate the
|
||||
connect-handshake crash — do not restore the stripped body until it passes
|
||||
join)
|
||||
|
||||
**Provides**
|
||||
| Command | Args | Effect |
|
||||
|---|---|---|
|
||||
| `bank` | — | Prints current `Bank` and `Score` to the caller |
|
||||
| `deposit` | `<amount>` | Score → bank, DB-first with refund on race |
|
||||
| `withdraw` | `<amount>` | Bank → score, DB-first with overdraft guard |
|
||||
|
||||
Per-player state: `self.bank` (int), `self.bank_unavailable` (bool),
|
||||
`self.bank_guid` (set in the full impl — currently unset by the stub).
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_common::` — `ensure_config`, `register_spawn_handler`
|
||||
- `tws_db` — waits on `db_probe_done`, gates on `level.db_online`
|
||||
- `t5-gsc-utils` — `mysql::query`, `mysql::prepared_statement`,
|
||||
`command::add_sv`
|
||||
- Stock BO1: `self.score`
|
||||
- Dvar: `tws_server_id` (optional; labels `last_server` on bank rows)
|
||||
- External: MariaDB schema — `bank(guid, balance, last_name, last_server)`
|
||||
|
||||
Spawn handler registered: `::bank_load`.
|
||||
|
||||
---
|
||||
|
||||
## tws_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_common::` — `ensure_config`, `make_text_elem`,
|
||||
`destroy_on_disconnect`, `register_spawn_handler`
|
||||
- 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_join.gsc
|
||||
|
||||
Type `.join` in chat while spectating after joining a match in progress to
|
||||
spawn immediately. Install with `tws_common.gsc`; no HUD or DB 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_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_common::` — `ensure_config`, `int_dvar_or`
|
||||
- `tws_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_lock` | `20` | Round at which the lobby locks |
|
||||
| `tws_server_id` | `tws_bank`, `tws_telemetry` | Required for telemetry | Exact registered Electric Cherry server name; also used as the bank label |
|
||||
| `tws_telemetry_enabled` | `tws_telemetry` | `1` | Set to `0` to disable telemetry at init |
|
||||
| `tws_telemetry_url` | `tws_telemetry` | `http://192.168.1.199:3000/api/telemetry` | Electric Cherry telemetry endpoint for JSON POSTs |
|
||||
| `tws_telemetry_log_level` | `tws_telemetry` | `1` | Read live: `0` warnings/errors/recovery only, `1` lifecycle/request/health logs, `2` also capture/queue logs and response previews |
|
||||
|
||||
## tws_telemetry.gsc
|
||||
|
||||
Uses `t5-gsc-utils` HTTP/JSON components to POST directly to Electric Cherry's
|
||||
`/api/telemetry`. Install alongside `tws_common.gsc` in `storage/t5/scripts/sp/`.
|
||||
No HUD or database dependency. Reports run whether or not a beta is active.
|
||||
|
||||
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 "http://192.168.1.199:3000/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`, 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. A pending request delays later reports; the current plugin has no
|
||||
request timeout option. A map reload can discard queued reports.
|
||||
|
||||
Transport failures log to the server console. The plugin does not expose
|
||||
HTTP status codes, so its success flag 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, 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, failures, 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 cancel or retry stuck requests: the current plugin offers neither
|
||||
timeout nor cancellation through GSC, and starting another request could
|
||||
reorder events or accumulate native workers. A stall remains blocked until
|
||||
the plugin completes it; these logs identify that condition, not fix it.
|
||||
|
||||
Requires a plugin build containing
|
||||
[`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 HUD/DB modules 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. Verify 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)** — required.
|
||||
`tws_db` and `tws_bank` need the `mysql::` namespace; `tws_bank` also
|
||||
needs `command::add_sv`.
|
||||
- **MariaDB / MySQL server** reachable from the Plutonium container as
|
||||
`mariadb:3306` (currently hardcoded — see `tws_db.gsc`).
|
||||
- **Schema** — `bank(guid VARCHAR PRIMARY KEY, balance INT, last_name
|
||||
VARCHAR, last_server VARCHAR)`. `tws_bank` lazily inserts rows on first
|
||||
deposit.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
tws_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_common::ensure_config();
|
||||
|
||||
afk_init();
|
||||
|
||||
scripts\sp\tws_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_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_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();
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
tws_bank.gsc — persistent per-player bank, MariaDB-backed. See NOTES.md
|
||||
for the DB-first / spawn-dispatch / command surface invariants.
|
||||
*/
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_common::ensure_config();
|
||||
|
||||
bank_init();
|
||||
|
||||
scripts\sp\tws_common::register_spawn_handler(::bank_load);
|
||||
}
|
||||
|
||||
bank_init()
|
||||
{
|
||||
if (!isDefined(level.db_probe_done) || !level.db_probe_done)
|
||||
level thread bank_deferred_start();
|
||||
else
|
||||
bank_start();
|
||||
}
|
||||
|
||||
bank_deferred_start()
|
||||
{
|
||||
level waittill("db_probe_done");
|
||||
bank_start();
|
||||
}
|
||||
|
||||
bank_start()
|
||||
{
|
||||
if (!level.db_online)
|
||||
return;
|
||||
|
||||
bank_register_commands();
|
||||
}
|
||||
|
||||
bank_register_commands()
|
||||
{
|
||||
command::add_sv("bank", ::bank_cmd_bank);
|
||||
command::add_sv("deposit", ::bank_cmd_deposit);
|
||||
command::add_sv("withdraw", ::bank_cmd_withdraw);
|
||||
}
|
||||
|
||||
// DIAGNOSTIC STUB — bare minimum to isolate the connect-handshake crash.
|
||||
// Do not restore the getGuid / mysql body until this passes join.
|
||||
bank_load()
|
||||
{
|
||||
self endon("disconnect");
|
||||
self iPrintLnBold("^2bank_load: entered");
|
||||
self.bank = 0;
|
||||
self.bank_unavailable = false;
|
||||
self iPrintLnBold("^2bank_load: done");
|
||||
}
|
||||
|
||||
bank_available(player)
|
||||
{
|
||||
if (!level.db_online)
|
||||
{
|
||||
player iPrintLnBold("^1Bank offline");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (is_true(player.bank_unavailable))
|
||||
{
|
||||
player iPrintLnBold("^1Bank unavailable for your account");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isDefined(player.bank))
|
||||
{
|
||||
player iPrintLnBold("^3Bank still loading, try again");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
bank_cmd_bank(args)
|
||||
{
|
||||
if (!bank_available(self))
|
||||
return;
|
||||
|
||||
self iPrintLnBold("Bank: " + self.bank + " Score: " + self.score);
|
||||
}
|
||||
|
||||
bank_cmd_deposit(args)
|
||||
{
|
||||
if (!bank_available(self))
|
||||
return;
|
||||
|
||||
if (args.size < 2)
|
||||
{
|
||||
self iPrintLnBold("Usage: deposit <amount>");
|
||||
return;
|
||||
}
|
||||
|
||||
amount = int(args[1]);
|
||||
|
||||
if (amount <= 0)
|
||||
{
|
||||
self iPrintLnBold("^1Amount must be positive");
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount > self.score)
|
||||
{
|
||||
self iPrintLnBold("^1Not enough score");
|
||||
return;
|
||||
}
|
||||
|
||||
self thread bank_deposit_write(amount);
|
||||
}
|
||||
|
||||
bank_cmd_withdraw(args)
|
||||
{
|
||||
if (!bank_available(self))
|
||||
return;
|
||||
|
||||
if (args.size < 2)
|
||||
{
|
||||
self iPrintLnBold("Usage: withdraw <amount>");
|
||||
return;
|
||||
}
|
||||
|
||||
amount = int(args[1]);
|
||||
|
||||
if (amount <= 0)
|
||||
{
|
||||
self iPrintLnBold("^1Amount must be positive");
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount > self.bank)
|
||||
{
|
||||
self iPrintLnBold("^1Insufficient balance");
|
||||
return;
|
||||
}
|
||||
|
||||
self thread bank_withdraw_write(amount);
|
||||
}
|
||||
|
||||
// upsert: first deposit creates the row, later ones increment
|
||||
bank_deposit_write(amount)
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
name = bank_display_name(self);
|
||||
server_id = bank_server_id();
|
||||
|
||||
q = mysql::prepared_statement(
|
||||
"INSERT INTO bank (guid, balance, last_name, last_server) VALUES (?, ?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance), " +
|
||||
"last_name = VALUES(last_name), last_server = VALUES(last_server)",
|
||||
self.bank_guid, amount, name, server_id);
|
||||
|
||||
q waittill("done", result, affected_rows, error);
|
||||
|
||||
if (isDefined(error) && error != "")
|
||||
{
|
||||
self iPrintLnBold("^1Deposit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// score can change between dispatch and write completing — refund if so
|
||||
if (amount > self.score)
|
||||
{
|
||||
mysql::prepared_statement(
|
||||
"UPDATE bank SET balance = balance - ? WHERE guid = ?",
|
||||
amount, self.bank_guid);
|
||||
self iPrintLnBold("^1Deposit rolled back (score changed)");
|
||||
return;
|
||||
}
|
||||
|
||||
self.score -= amount;
|
||||
self.bank += amount;
|
||||
self iPrintLnBold("Deposited " + amount + " Bank: " + self.bank);
|
||||
}
|
||||
|
||||
// balance guard lives in the WHERE so concurrent commands can't overdraw
|
||||
bank_withdraw_write(amount)
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
q = mysql::prepared_statement(
|
||||
"UPDATE bank SET balance = balance - ? WHERE guid = ? AND balance >= ?",
|
||||
amount, self.bank_guid, amount);
|
||||
|
||||
q waittill("done", result, affected_rows, error);
|
||||
|
||||
if (isDefined(error) && error != "")
|
||||
{
|
||||
self iPrintLnBold("^1Withdraw failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(affected_rows) || affected_rows == 0)
|
||||
{
|
||||
self iPrintLnBold("^1Insufficient balance");
|
||||
self thread bank_load(); // re-sync in case memory drifted from DB
|
||||
return;
|
||||
}
|
||||
|
||||
self.score += amount;
|
||||
self.bank -= amount;
|
||||
self iPrintLnBold("Withdrew " + amount + " Bank: " + self.bank);
|
||||
}
|
||||
|
||||
bank_display_name(player)
|
||||
{
|
||||
if (isDefined(player.name))
|
||||
return player.name;
|
||||
if (isDefined(player.playername))
|
||||
return player.playername;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bank_server_id()
|
||||
{
|
||||
id = GetDvar("tws_server_id");
|
||||
if (id == "")
|
||||
return "unknown";
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/* tws_common.gsc — shared config, HUD helpers, spawn-handler registry, connect dispatcher. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
common_config();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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,47 @@
|
||||
/* tws_db.gsc — MariaDB pool + reachability probe. Fires db_probe_done. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_common::ensure_config();
|
||||
|
||||
db_init();
|
||||
}
|
||||
|
||||
// TODO: move creds to dvars before this repo goes public
|
||||
db_init()
|
||||
{
|
||||
level.db_online = false;
|
||||
level.db_probe_done = false;
|
||||
|
||||
cfg = spawnStruct();
|
||||
cfg.host = "mariadb";
|
||||
cfg.user = "josh";
|
||||
cfg.password = "joshster";
|
||||
cfg.port = 3306;
|
||||
cfg.database = "tws_zm";
|
||||
|
||||
mysql::set_config(cfg);
|
||||
|
||||
level thread db_probe();
|
||||
}
|
||||
|
||||
// set_config is silent on failure; only a real query surfaces the error
|
||||
db_probe()
|
||||
{
|
||||
q = mysql::query("SELECT 1 AS ok");
|
||||
q waittill("done", result, affected_rows, error);
|
||||
|
||||
if (isDefined(error) && error != "")
|
||||
level.db_online = false;
|
||||
else
|
||||
level.db_online = true;
|
||||
|
||||
level.db_probe_done = true;
|
||||
level notify("db_probe_done");
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
/* tws_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_common::ensure_config();
|
||||
|
||||
level thread round_watcher();
|
||||
level thread game_over_watcher();
|
||||
|
||||
scripts\sp\tws_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_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_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_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_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_common::format_mmss(getTime() - level.match_start_time));
|
||||
|
||||
if (isDefined(round_elem) && isDefined(level.round_start_time))
|
||||
round_elem SetText(scripts\sp\tws_common::format_mmss(getTime() - level.round_start_time));
|
||||
}
|
||||
|
||||
hud_zombie_counter()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
label = scripts\sp\tws_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_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_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_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_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_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_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_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_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);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/* tws_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_common::ensure_config();
|
||||
scripts\sp\tws_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,67 @@
|
||||
/*
|
||||
tws_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_common::ensure_config();
|
||||
|
||||
lock_init();
|
||||
}
|
||||
|
||||
lock_init()
|
||||
{
|
||||
level.lock_round = scripts\sp\tws_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,414 @@
|
||||
/* tws_telemetry.gsc - Electric Cherry game reports via t5-gsc-utils. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
|
||||
scripts\sp\tws_common::ensure_config();
|
||||
|
||||
if (GetDvar("tws_telemetry_enabled") == "0")
|
||||
{
|
||||
print("[tws_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 = "http://192.168.1.199:3000/api/telemetry";
|
||||
|
||||
level.telemetry_server = GetDvar("tws_server_id");
|
||||
if (level.telemetry_server == "")
|
||||
{
|
||||
print("^1[tws_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_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_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";
|
||||
|
||||
options = [];
|
||||
options["method"] = "POST";
|
||||
options["headers"] = headers;
|
||||
options["body"] = entry["body"];
|
||||
|
||||
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);
|
||||
elapsed = getTime() - level.telemetry_request_started;
|
||||
level.telemetry_last_completed = getTime();
|
||||
|
||||
// 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 + " 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;
|
||||
telemetry_log(1, "Transport completed " + label + " elapsed_ms=" + elapsed + " (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";
|
||||
}
|
||||
|
||||
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_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.
|
||||
// The plugin cannot cancel requests. 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 +
|
||||
" 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 +
|
||||
"; waiting for plugin completion (no cancellation API)");
|
||||
}
|
||||
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