commit f77cc7bdeeda44591c5642cb95e526e5ff13a1df Author: josh Date: Wed Sep 23 23:33:09 2026 -0400 Import T5-AIO and T6 into combined repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dabadb --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..67da018 --- /dev/null +++ b/README.md @@ -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. diff --git a/t5/.gitignore b/t5/.gitignore new file mode 100644 index 0000000..fe0e71b --- /dev/null +++ b/t5/.gitignore @@ -0,0 +1,13 @@ +# OS junk +.DS_Store +Thumbs.db +desktop.ini + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*~ + +# Claude Code +.claude/ diff --git a/t5/NOTES.md b/t5/NOTES.md new file mode 100644 index 0000000..3e662a3 --- /dev/null +++ b/t5/NOTES.md @@ -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 ` — Plutainer runs as UID 1000 +- `docker exec 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` | diff --git a/t5/README.md b/t5/README.md new file mode 100644 index 0000000..56a0cbb --- /dev/null +++ b/t5/README.md @@ -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
config + helpers + dispatcher] + + 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
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 `, `withdraw ` +- 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` | `` | Score → bank, DB-first with refund on race | +| `withdraw` | `` | 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. diff --git a/t5/tws_afk.gsc b/t5/tws_afk.gsc new file mode 100644 index 0000000..6ab93a9 --- /dev/null +++ b/t5/tws_afk.gsc @@ -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(); + } +} diff --git a/t5/tws_bank.gsc b/t5/tws_bank.gsc new file mode 100644 index 0000000..305f96e --- /dev/null +++ b/t5/tws_bank.gsc @@ -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 "); + 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 "); + 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; +} diff --git a/t5/tws_common.gsc b/t5/tws_common.gsc new file mode 100644 index 0000000..b100d76 --- /dev/null +++ b/t5/tws_common.gsc @@ -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; +} diff --git a/t5/tws_db.gsc b/t5/tws_db.gsc new file mode 100644 index 0000000..d3d9ded --- /dev/null +++ b/t5/tws_db.gsc @@ -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"); +} diff --git a/t5/tws_hud.gsc b/t5/tws_hud.gsc new file mode 100644 index 0000000..f370381 --- /dev/null +++ b/t5/tws_hud.gsc @@ -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); + } +} diff --git a/t5/tws_join.gsc b/t5/tws_join.gsc new file mode 100644 index 0000000..9ef6fe4 --- /dev/null +++ b/t5/tws_join.gsc @@ -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; +} diff --git a/t5/tws_lock.gsc b/t5/tws_lock.gsc new file mode 100644 index 0000000..6e2e4d7 --- /dev/null +++ b/t5/tws_lock.gsc @@ -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"); +} diff --git a/t5/tws_telemetry.gsc b/t5/tws_telemetry.gsc new file mode 100644 index 0000000..86e7e38 --- /dev/null +++ b/t5/tws_telemetry.gsc @@ -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 ""; + 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); + } + } +} diff --git a/t6/.gitignore b/t6/.gitignore new file mode 100644 index 0000000..36ba7c5 --- /dev/null +++ b/t6/.gitignore @@ -0,0 +1,3 @@ +.research/ +compiled/ +__pycache__/ diff --git a/t6/NOTES.md b/t6/NOTES.md new file mode 100644 index 0000000..30d0e2d --- /dev/null +++ b/t6/NOTES.md @@ -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. diff --git a/t6/README.md b/t6/README.md new file mode 100644 index 0000000..ffc8f44 --- /dev/null +++ b/t6/README.md @@ -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). diff --git a/t6/tools/check.ps1 b/t6/tools/check.ps1 new file mode 100644 index 0000000..c08f8a6 --- /dev/null +++ b/t6/tools/check.ps1 @@ -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.' diff --git a/t6/tws_t6_afk.gsc b/t6/tws_t6_afk.gsc new file mode 100644 index 0000000..6574abd --- /dev/null +++ b/t6/tws_t6_afk.gsc @@ -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(); +} diff --git a/t6/tws_t6_common.gsc b/t6/tws_t6_common.gsc new file mode 100644 index 0000000..e1f4b25 --- /dev/null +++ b/t6/tws_t6_common.gsc @@ -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; +} diff --git a/t6/tws_t6_hud.gsc b/t6/tws_t6_hud.gsc new file mode 100644 index 0000000..a3db37e --- /dev/null +++ b/t6/tws_t6_hud.gsc @@ -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); + } +} diff --git a/t6/tws_t6_lock.gsc b/t6/tws_t6_lock.gsc new file mode 100644 index 0000000..7a7ba0b --- /dev/null +++ b/t6/tws_t6_lock.gsc @@ -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"); +} diff --git a/t6/tws_t6_telemetry.gsc b/t6/tws_t6_telemetry.gsc new file mode 100644 index 0000000..4a6f98e --- /dev/null +++ b/t6/tws_t6_telemetry.gsc @@ -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; +}