Import T5-AIO and T6 into combined repo

This commit is contained in:
2026-09-23 23:33:09 -04:00
commit f77cc7bdee
22 changed files with 3256 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# OS junk
.DS_Store
Thumbs.db
desktop.ini
# Editor / IDE
.vscode/
.idea/
*.swp
*~
# Claude Code
.claude/
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+171
View File
@@ -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;
}
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+67
View File
@@ -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");
}
+414
View File
@@ -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);
}
}
}