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
+20
View File
@@ -0,0 +1,20 @@
# OS junk
.DS_Store
Thumbs.db
desktop.ini
# Editor / IDE
.vscode/
.idea/
*.swp
*~
# Claude Code
.claude/
# Build / cache
compiled/
__pycache__/
# Research scratch (T6 legacy)
.research/
+35
View File
@@ -0,0 +1,35 @@
# tws-zombies-scripts
Combined working repo for the TWS Plutonium Zombies GSC scripts across two games.
## Layout
- [t5/](t5/) — Plutonium **T5** (Call of Duty: Black Ops 1) Zombies scripts. Most-developed baseline. See [t5/README.md](t5/README.md) and [t5/NOTES.md](t5/NOTES.md).
- [t6/](t6/) — Plutonium **T6** (Call of Duty: Black Ops 2) Zombies scripts. Ported from T5, catching up on feature parity. See [t6/README.md](t6/README.md), [t6/NOTES.md](t6/NOTES.md), and [t6/tools/check.ps1](t6/tools/check.ps1).
## Parity
T5 and T6 are separate source trees, not a shared library — GSC namespaces, includes, and stock APIs differ enough between the two engines that a shared file is not practical. Feature parity is enforced pairwise: a change landed in `t5/` should be considered for a companion change in `t6/`, adapted to T6 conventions.
Modules currently in each tree:
| Module | T5 | T6 |
|------------|-------------------------------|---------------------------------|
| common | `t5/tws_common.gsc` | `t6/tws_t6_common.gsc` |
| hud | `t5/tws_hud.gsc` | `t6/tws_t6_hud.gsc` |
| afk | `t5/tws_afk.gsc` | `t6/tws_t6_afk.gsc` |
| lock | `t5/tws_lock.gsc` | `t6/tws_t6_lock.gsc` |
| telemetry | `t5/tws_telemetry.gsc` | `t6/tws_t6_telemetry.gsc` |
| join | `t5/tws_join.gsc` | `t6/tws_t6_join.gsc` |
| db | `t5/tws_db.gsc` | *(not ported)* |
| bank | `t5/tws_bank.gsc` | *(not ported)* |
`db` and `bank` are T5-only for now.
## Validating T6
```powershell
powershell -File t6/tools/check.ps1
```
Requires `gsc-tool` 1.4.10 (T6 PC) on PATH.
+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);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
.research/
compiled/
__pycache__/
+98
View File
@@ -0,0 +1,98 @@
# T6 port notes
Baseline: the current files in `C:\Users\josh1\Documents\Code\TWS-T5-AIO`.
The T5 originals were not modified. DB, bank, their schema and the added example
config have been removed from this project.
## Differences retained for T6 and requested fixes
| Area | Difference from T5 | Reason |
| --- | --- | --- |
| Paths | `maps/mp/_utility`, `scripts/zm/tws_t6_common` | T6 namespaces and requested filenames |
| Loading | No T5 `zombiemode` dvar gate; each module has `main()` and `init()` | Zombies-only folder loading; avoid r5346 missing-main warnings |
| HUD positioning | Fullscreen screen anchors, same 10-unit margin | Requested fix for inset HUD |
| HUD rendering | Precached white shader, local max-health fallback, floating-point ratio, `getPlayers()` | T6 rendering/state handling without changing player max health |
| Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters |
| Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen |
| AFK | Maintain control freeze, disable guns/grenades, guard player state and stale grace threads | Requested movement/shooting fix; retain `.afk` / `.back` and two-second grace |
| Telemetry | Global `httpPost(url, options)`; no unnecessary common dependency | Avoid raw-loader `http.gsc` and obsolete common-path lookup errors |
Common retains the T5 HUD constants, helpers and first-spawn dispatcher.
HUD again owns the `hud_game_over` notification fan-out. Lock again waits on HUD
notifications, applies T5's random password, and clears it on startup/game over.
The added password-preservation dvars and independent lock watcher are gone.
AFK again listens to the native `say` notification, so it no longer needs plugin
chat registration. The additional T6 controls are only held while AFK; `.back`
releases them before the protection grace period. Restoration is deferred during
death/laststand/intermission until active play. Other mods controlling the same
invulnerability or weapon flags can still conflict and need live testing.
The four optional T5 dvars remain; no new configuration switches are introduced.
`common_config()` and `ensure_config()` are original T5 in-memory HUD helpers,
not an external configuration system.
## Verified primary sources
- [Plutonium loading guide](https://plutonium.pw/docs/modding/loading-mods/): T6
source scripts load from `storage/t6/scripts/zm`.
- [Entry-point lifecycle](https://plutonium.pw/docs/modding/gsc/new-scripting-features/):
`main()` runs early; `init()` runs after gametype startup. Empty `main()` must
not call `init()` and duplicate initialization.
- [Plutonium changelog, r2234](https://plutonium.pw/docs/changelog/): documents T6
`level waittill("say", message, player)`.
- [Stock T6 Zombies](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/zombies/_zm.gsc):
round state, `end_game`, invulnerability, and stock control resets.
- [Stock zombie utility](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/zombies/_zm_utility.gsc):
map-aware enemy counting.
- [Released plugin HTTP implementation](https://github.com/alicealys/t6-gsc-utils/blob/v3.1.1/src/component/http.cpp):
global `httpPost(url, options)` takes an options array with `body` and `headers`.
Its positional README example does not match this implementation.
Completion is `done(response, success, error)`; success reports transport only.
- [Plugin JSON implementation](https://github.com/alicealys/t6-gsc-utils/blob/v3.1.1/src/component/json.cpp):
`jsonSerialize` handles the event payload.
Do not create a dummy `http.gsc`. An unresolved `httpPost`/`jsonSerialize` means
the installed server/plugin must be checked. Installing a DLL requires a full
server-process restart, not just a map restart.
## Telemetry reboot investigation
The T5 and T6 implementations are not runtime-verified equivalents. In particular,
[T6 plugin v3.0.4](https://github.com/alicealys/t6-gsc-utils/blob/v3.0.4/src/component/http.cpp)
expects `httpPost(url, body, headers)` and notifies only the response body;
v3.1.1 expects `httpPost(url, options)` and also provides success/error values.
The current script targets v3.1.1. Switching the function name does not establish
compatibility with an unknown installed DLL.
Console markers identify `main`, `init`, round serialization, HTTP invocation,
handle return and completion. They do not log the URL or response body. If startup
fails before `main`, inspect the first linker/plugin error, not the last supervisor
restart message. If no occupied positive round was observed, this script has not
called JSON serialization or HTTP yet.
Earlier live validation: the operator confirmed successful server startup and
Fleet delivery with the original direct reporter. The earlier reboot cause was
not established; logging alone should not be treated as a proven fix.
To recover the server while investigating, move `tws_t6_telemetry.gsc` outside the
autoload directory. The enable dvar cannot bypass unresolved native functions
at link time. The other four TWS modules can run without telemetry.
## Checks
Local: all five files pass gsc-tool 1.4.10 T6 PC compilation; shared references and
entry points are checked by `tools/check.ps1`. No live server was run for this
baseline simplification. Compiler success does not certify runtime linking.
Live checks after deploying:
1. Join, late-join, die/respawn, and reconnect: one HUD per player, correct corner
position, health/Juggernog changes, zombies remaining, timers frozen at game over.
2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid
toggles, a round transition, death/respawn and game over.
3. Lock fires at the threshold and clears on game over/restart. HUD must be installed.
T5 semantics intentionally clear an existing password at startup.
4. Telemetry sends the initial occupied round and each new round to the existing
endpoint. Check server ID/player count, same-round joins and an unreachable API.
5. Repeat on the actual server maps, especially afterlife and special-enemy rounds.
+84
View File
@@ -0,0 +1,84 @@
# TWS T6 Zombies scripts
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
just like T5. There is no separate TWS config file or database setup.
| File | Role | Dependencies |
| --- | --- | --- |
| `tws_t6_common.gsc` | Shared HUD values, helpers and first-spawn dispatcher | Stock T6 |
| `tws_t6_hud.gsc` | Match/round timers, name, zombie count and health bar | Common |
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common |
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | HTTP JSON round updates, enabled by default | t6-gsc-utils |
## Install
1. Copy these five `tws_t6_*.gsc` files into `Plutonium/storage/t6/scripts/zm/`.
2. For telemetry, install [t6-gsc-utils v3.1.1](https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.1)
in `Plutonium/plugins/` and restart the server process. The other four scripts
do not require that plugin.
3. Restart the map to load script updates.
If updating an existing deployment, **remove `tws_t6_db.gsc` and
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
will not remove those old scripts. Remove any `exec server.example.cfg` added for
the earlier version. Neither a database nor that config file is used anymore.
Source `.gsc` files load directly under Plutonium's
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
Keep the filenames and `scripts/zm` location so shared references resolve.
To omit telemetry, remove its script; disabling it at runtime does not remove
its plugin dependency during linking.
## Defaults, matching T5
- HUD values live in `tws_t6_common::common_config()`. That function and
`ensure_config()` came from T5; they initialize in-script values and do not
load a config file. The requested fullscreen corner alignment is retained.
- AFK uses T5's native `level waittill("say", message, player)` listener.
`.back` restores movement and weapons immediately, then ends protection after
two seconds. The T6 control/weapon fix remains in place.
- Lock uses T5's HUD round/game-over notifications. Default threshold is 20.
As in T5, it clears `g_password` at startup and game over.
- Telemetry is enabled by default and posts to
`http://192.168.1.199:3000/api/telemetry`. Set `tws_server_id` to the exact,
case-sensitive Electric Cherry registry name; missing or overlong names
disable reporting with a console diagnostic.
The optional dvars already present in T5 are still supported: `tws_lock_round`,
`tws_server_id`, `tws_telemetry_url`, and `tws_telemetry_enabled` (`0` disables
telemetry). None require a separate config file. If earlier settings are already
set on the server, they still override these defaults.
## Round telemetry
Telemetry runs directly in GSC using t6-gsc-utils. Configure the exact Electric
Cherry registry name and full endpoint in your existing server configuration:
```cfg
set tws_server_id "t6_zombie_town_1"
set tws_telemetry_url "http://192.168.1.199:3000/api/telemetry"
```
It sends the first occupied positive round and subsequent round changes:
```json
{"event":"round_change","server":"t6_zombie_town_1","round":17,"players":4}
```
Delivery is asynchronous, with one in-flight request and one replaceable pending
observation. Transport failures retry at most three times (1/2/5 seconds), retiring
snapshots older than 15 seconds or superseded by a newer round. Transport success
does not establish API acceptance. These reports update Fleet; they do not send
beta boot, session, feature or match-completion evidence.
## Validation
All five scripts compile for T6 PC with gsc-tool 1.4.10. To repeat:
```powershell
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
```
Compilation does not verify engine/plugin linking or gameplay. T6 differences,
research sources and live checks are in [NOTES.md](NOTES.md).
+34
View File
@@ -0,0 +1,34 @@
param(
[Parameter(Mandatory = $true)]
[string]$CompilerPath
)
$ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_t6_*.gsc' | Sort-Object Name)
if ($files.Count -ne 5) { throw 'Expected the five TWS modules: common, HUD, AFK, lock, telemetry.' }
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
foreach ($file in $files) {
$source = Get-Content -LiteralPath $file.FullName -Raw
foreach ($entry in @('main', 'init')) {
if ($source -notmatch "(?m)^$entry\(\)\s*\r?\n\{") {
throw "Missing $entry entry point in $($file.Name)"
}
}
if ($source -match 'scripts\\sp\\|maps\\_utility|#"zombiemode"') {
throw "T5-only reference in $($file.Name)"
}
foreach ($reference in [regex]::Matches($source, 'scripts\\zm\\tws_t6_common::([a-zA-Z_][a-zA-Z_0-9]*)')) {
if ($reference.Groups[1].Value -notin $functions) {
throw "Missing common function: $($reference.Groups[1].Value)"
}
}
$output = (& $compiler -m comp -g t6 -s pc -y $file.FullName 2>&1 | Out-String)
# gsc-tool can return zero even when compilation emits an error.
if ($LASTEXITCODE -ne 0 -or $output -match '(?i)error|failed' -or $output -notmatch 'compiled t6/') {
throw "Compile failed for $($file.Name):`n$output"
}
Write-Output "PASS $($file.Name)"
}
Write-Output 'All five T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
+142
View File
@@ -0,0 +1,142 @@
/* T5-style .afk / .back listener with T6 control and weapon blocking. */
#include maps\mp\_utility;
#include common_scripts\utility;
// Plutonium probes both entry points; runtime setup belongs in init.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
afk_init();
scripts\zm\tws_t6_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");
self.tws_afk_generation = 0;
self.hud_afk_on = false;
self.tws_afk_protected = false;
self.tws_afk_controls_locked = false;
elem = scripts\zm\tws_t6_common::make_text_elem(self,
level.hud_x + 42, level.hud_row_match, "left",
level.hud_font_small, level.hud_c_afk);
elem SetText("[AFK]");
elem.alpha = 0;
self.hud_afk_elem = elem;
elems = [];
elems[0] = elem;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
for (;;)
{
if (self.tws_afk_protected && !scripts\zm\tws_t6_common::player_active(self))
self afk_release();
if (self.hud_afk_on)
self afk_lock_controls();
else if (self.tws_afk_controls_locked)
self afk_restore_controls();
wait 0.05;
}
}
afk_enter()
{
if (!isDefined(self.hud_afk_on) || self.hud_afk_on)
return;
if (!scripts\zm\tws_t6_common::player_active(self))
return;
// Do not take ownership of stock ignoreme (laststand, afterlife, etc.).
if (is_true(self.ignoreme) && !self.tws_afk_protected)
return;
self.tws_afk_generation++;
self.hud_afk_on = true;
self.tws_afk_protected = true;
self.ignoreme = true;
self enableInvulnerability();
self afk_lock_controls();
self.hud_afk_elem.alpha = 1;
self iPrintLnBold("AFK - type .back to return");
}
afk_exit()
{
self endon("disconnect");
if (!isDefined(self.hud_afk_on) || !self.hud_afk_on)
return;
self.hud_afk_on = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
self.tws_afk_generation++;
generation = self.tws_afk_generation;
self iPrintLnBold("Back in the fight");
wait level.afk_grace_seconds;
if (generation == self.tws_afk_generation && self.tws_afk_protected)
self afk_release();
}
afk_release()
{
self.tws_afk_generation++;
self.hud_afk_on = false;
self.tws_afk_protected = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
// Stock scripts own protection during laststand/intermission/death.
if (scripts\zm\tws_t6_common::player_active(self))
{
self.ignoreme = false;
self disableInvulnerability();
}
}
afk_lock_controls()
{
// Stock gameplay can clear the freeze flag after AFK entry. Maintain it
// while AFK and explicitly block guns and offhand weapons as well.
self.tws_afk_controls_locked = true;
self freezeControls(true);
self disableWeapons();
self disableOffhandWeapons();
}
afk_restore_controls()
{
if (!self.tws_afk_controls_locked)
return;
// Leave laststand/intermission controls to stock scripts. If the player
// respawns, the watcher restores our controls once they are active again.
if (!scripts\zm\tws_t6_common::player_active(self))
return;
self.tws_afk_controls_locked = false;
self freezeControls(false);
self enableWeapons();
self enableOffhandWeapons();
}
+190
View File
@@ -0,0 +1,190 @@
/* tws_t6_common.gsc — shared config, HUD helpers, spawn-handler registry, connect dispatcher. */
#include maps\mp\_utility;
#include common_scripts\utility;
main()
{
precacheShader("white");
}
init()
{
common_config();
level thread on_player_connect_dispatch();
}
common_config()
{
if (isDefined(level.hud_x))
return;
// Small inset from the physical screen corner (fullscreen HUD coordinates).
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;
}
// Shared dispatcher: one HUD instance and account load per connection.
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);
// Fullscreen bypasses the safe-area inset of left/top screen anchors.
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
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 = "fullscreen";
elem.vertAlign = "fullscreen";
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;
}
// T6 state guard used by the AFK control fix.
player_active(player)
{
if (is_true(level.intermission) || is_true(level.gameended))
return false;
if (!isDefined(player) || !isAlive(player))
return false;
if (!isDefined(player.sessionstate) || player.sessionstate != "playing")
return false;
if (player maps\mp\zombies\_zm_laststand::player_is_in_laststand())
return false;
if (isDefined(player.is_zombie) && player.is_zombie)
return false;
return true;
}
+345
View File
@@ -0,0 +1,345 @@
/* tws_t6_hud.gsc - configurable HUD stack: timers, zombie count, health bar. */
#include maps\mp\_utility;
#include common_scripts\utility;
// Plutonium probes both entry points; runtime setup belongs in init.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
level thread round_watcher();
level thread game_over_watcher();
scripts\zm\tws_t6_common::register_spawn_handler(::hud_start);
}
hud_start()
{
self endon("disconnect");
if (isDefined(level.tws_ended) && level.tws_ended)
return;
self thread hud_timer_row();
self thread hud_zombie_counter();
self thread hud_health_display();
}
// Same game-over notification fan-out as T5.
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.tws_ended = true;
level notify("hud_game_over");
}
// Poll stock round state; the first player spawn seeds pre-set round 1.
round_watcher()
{
level endon("hud_game_over");
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\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_match,
"left", level.hud_font_small, level.hud_c_dim);
match.alpha = 0; // revealed by match_timer_bind when round 1 opens
round = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_round,
"left", level.hud_font_small, level.hud_c_dim);
round SetTimerUp(0);
if (isDefined(level.round_start_time))
round SetTimerUp(max(0, (getTime() - level.round_start_time) / 1000.0));
// name only shows in co-op — your own name is noise in solo
name = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_name,
"left", level.hud_font_small, level.hud_c_name);
if (isDefined(self.name))
name SetText(self.name);
else
name SetText(self.playername);
name.alpha = 0;
elems = [];
elems[elems.size] = match;
elems[elems.size] = round;
elems[elems.size] = name;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
self thread reset_timer_each_round(round);
self thread match_timer_bind(match);
self thread freeze_timers_on_game_over(match, round);
coop = -1;
for (;;)
{
is_coop = 0;
players = getPlayers();
if (players.size > 1)
is_coop = 1;
if (is_coop != coop)
{
name.alpha = is_coop;
coop = is_coop;
}
wait (1);
}
}
reset_timer_each_round(timer)
{
level endon("hud_game_over");
self endon("disconnect");
for (;;)
{
level waittill("hud_round_changed");
if (isDefined(timer))
timer SetTimerUp(0);
}
}
// T6 timer seed includes elapsed time for late joiners.
match_timer_bind(elem)
{
self endon("disconnect");
if (!isDefined(level.match_start_time))
level waittill("hud_match_started");
if (isDefined(elem))
{
elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0));
elem.alpha = 1;
}
}
/*
Level-scope trigger only — per-player death/health polling is intercepted
by the zombiemode scripts. Freeze value math relies on the timers being
anchored at first spawn / first round transition, not level init.
*/
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\zm\tws_t6_common::format_mmss(getTime() - level.match_start_time));
if (isDefined(round_elem) && isDefined(level.round_start_time))
round_elem SetText(scripts\zm\tws_t6_common::format_mmss(getTime() - level.round_start_time));
}
hud_zombie_counter()
{
self endon("disconnect");
label = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_x, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_label);
label SetText("Zombies");
count = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_count, level.hud_row_count,
"left", level.hud_font_large, level.hud_c_count);
count SetValue(0);
elems = [];
elems[elems.size] = label;
elems[elems.size] = count;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
last = -1;
for (;;)
{
remaining = 0;
if (isDefined(level.zombie_team))
remaining = maps\mp\zombies\_zm_utility::get_current_zombie_count();
if (isDefined(level.zombie_total))
remaining += max(0, level.zombie_total);
if (remaining != last)
{
count SetValue(remaining);
last = remaining;
}
wait (level.hud_rate);
}
}
hud_health_display()
{
self endon("disconnect");
bar_width = level.hud_bar_width;
// -2 on both axes so the back frames the bar with 2px padding
back = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2);
back.alpha = 0.5;
back.color = (0, 0, 0);
back SetShader("white", bar_width + 4, level.hud_bar_height + 4);
bar = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x, level.hud_row_bar);
bar.color = level.hud_c_high;
bar SetShader("white", bar_width, level.hud_bar_height);
/*
"87/160" is three elements, not a rebuilt string. SetText allocates
a config string per call; per-tick rebuilds crash the server.
cur is right-aligned so digits grow leftward and the slash + max
never shift.
*/
cur = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 26, level.hud_row_bar + 3,
"right", level.hud_font_mid, level.hud_c_high);
cur SetValue(self.health);
slash = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 30, level.hud_row_bar + 3,
"center", level.hud_font_mid, level.hud_c_high);
slash SetText("/");
max_hp = scripts\zm\tws_t6_common::make_text_elem(
self, level.hud_col_health + 34, level.hud_row_bar + 3,
"left", level.hud_font_mid, level.hud_c_high);
max_hp SetValue(100);
cur.alpha = 0;
slash.alpha = 0;
max_hp.alpha = 0;
elems = [];
elems[elems.size] = back;
elems[elems.size] = bar;
elems[elems.size] = cur;
elems[elems.size] = slash;
elems[elems.size] = max_hp;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
last_width = bar_width;
last_health = -1;
last_max = -1;
last_shown = -1;
for (;;)
{
maximum = 100;
if (isDefined(self.maxhealth) && self.maxhealth > 0)
maximum = self.maxhealth;
frac = self.health / (maximum * 1.0);
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 (maximum != last_max)
{
max_hp SetValue(maximum);
last_max = maximum;
}
wait (level.hud_rate);
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
tws_t6_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\mp\_utility;
#include common_scripts\utility;
// T6 loader probes main and init; initialize only once.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
lock_init();
}
lock_init()
{
level.lock_round = scripts\zm\tws_t6_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");
}
+163
View File
@@ -0,0 +1,163 @@
/* tws_t6_telemetry.gsc - direct HTTP round telemetry via t6-gsc-utils. */
#include maps\mp\_utility;
#include common_scripts\utility;
// Plutonium probes both entry points; runtime setup belongs in init.
main()
{
println("[tws_t6_telemetry] main reached (HTTP API target: t6-gsc-utils v3.1.1)");
}
init()
{
println("[tws_t6_telemetry] init reached");
// Loaded only from scripts/zm; no shared HUD configuration is needed.
// Enabled when unset; explicitly set 0 to opt out.
if (GetDvar("tws_telemetry_enabled") == "0")
{
println("[tws_t6_telemetry] disabled");
return;
}
// Repeated init in the same VM must not create competing senders.
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 == "" || level.telemetry_server.size > 255)
{
println("[tws_t6_telemetry] Set tws_server_id to the exact registered server name (1-255 characters)");
return;
}
level.telemetry_initialized = true;
level.telemetry_pending = undefined;
println("[tws_t6_telemetry] watching for an occupied positive round");
level thread telemetry_delivery_worker();
level thread telemetry_round_watcher();
}
// Independent of HUD installation. Report the first occupied positive round,
// including round 1 when the map pre-seeds it, then each observed transition.
telemetry_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = -1;
for (;;)
{
if (isDefined(level.round_number) && level.round_number >= 1 &&
level.round_number != last_round)
{
players = getPlayers();
if (players.size > 0)
{
last_round = level.round_number;
// Snapshot values before handing off to the HTTP thread.
data = [];
data["event"] = "round_change";
data["round"] = int(last_round);
data["server"] = level.telemetry_server;
data["players"] = players.size;
// No runId field (including null/empty), boot, session or
// feature events in Fleet-only mode. Keep just the newest
// pending observation so an outage cannot build a backlog.
pending = spawnStruct();
println("[tws_t6_telemetry] serializing round " + last_round);
pending.body = jsonSerialize(data);
pending.observed_at = getTime();
level.telemetry_pending = pending;
}
}
wait (0.5);
}
}
telemetry_delivery_worker()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
backoff = [];
backoff[0] = 1;
backoff[1] = 2;
backoff[2] = 5;
for (;;)
{
if (!isDefined(level.telemetry_pending))
{
wait (0.5);
continue;
}
pending = level.telemetry_pending;
level.telemetry_pending = undefined;
// One in-flight request plus one replaceable pending observation.
// A newer observation always retires retries of an older round.
for (attempt = 0; attempt < 4; attempt++)
{
if (isDefined(level.telemetry_pending) ||
getTime() - pending.observed_at > 15000)
break;
if (telemetry_send(pending.body))
break;
if (attempt < 3)
wait (backoff[attempt]);
}
}
}
// Returns transport completion only, NEVER beta/API acceptance. The released
// plugin exposes body/success/error but no HTTP status or cancellation. Do not
// start a replacement request on a script timeout: the old one could still
// commit later and overwrite Fleet with a stale round. Gameplay never waits
// on this worker. Malformed/HTML bodies are not passed to throwing jsonParse.
telemetry_send(body)
{
headers = [];
headers["Content-Type"] = "application/json";
options = [];
options["method"] = "POST";
options["headers"] = headers;
options["body"] = body;
// v3.1.1 global alias: options array, NOT positional body/headers.
// Avoid the raw loader treating the http namespace as http.gsc.
println("[tws_t6_telemetry] calling httpPost");
request = httpPost(level.telemetry_url, options);
println("[tws_t6_telemetry] HTTP handle returned; waiting for completion");
request waittill("done", response, success, error);
println("[tws_t6_telemetry] HTTP completion received");
// The plugin's success flag reports transport completion, not HTTP status.
// Unscoped round reports replace Fleet state; retrying the same snapshot
// cannot duplicate beta evidence. HTTP errors cannot be classified here,
// so only reported transport failures receive bounded retries.
if (!isDefined(success) || !success)
{
if (!isDefined(error))
error = "unknown transport error";
print("^1[tws_t6_telemetry] Round telemetry failed: " + error);
return false;
}
println("[tws_t6_telemetry] Transport completed; API acceptance is not verified by this plugin");
return true;
}