Organize scripts and add TWS plugins with HTTP timeouts
Vendor pinned T5 and T6 plugin sources under the new plugins layout. Add native HTTP deadlines, status and timing diagnostics, telemetry integration, and build/test tooling.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# T6 port notes
|
||||
|
||||
T6 is ported from T5. Both source trees contain common, HUD, AFK, join, lock,
|
||||
telemetry and leaderboard modules; neither includes bank or database modules.
|
||||
|
||||
## 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)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` sources from Plutonium's `player.guid` engine property (T5 uses `player getGuid()` method); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | T6 has no `getGuid` method but exposes the persistent identity as `.guid` on the player entity; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
|
||||
| Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way |
|
||||
|
||||
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.
|
||||
|
||||
Common also listens for `.help` through the native `say` notification, independent
|
||||
of first-spawn initialization so newcomers can view commands while spectating.
|
||||
Each loaded command module registers its command and description with
|
||||
`register_help_command()` during `init()`. Lazy registry initialization preserves
|
||||
registrations regardless of module initialization order. Help uses client HUD
|
||||
elements below the health bar, sharing the HUD's anchors to avoid overlap with
|
||||
timers, zombie count and health numbers. Text is created once per connection and
|
||||
reused; each request restarts a 12-second visibility timer. Disconnect cleanup
|
||||
is independent of that timer. The command handlers retain their existing listeners.
|
||||
|
||||
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 existing optional T5 dvars remain. Leaderboard adds the three settings
|
||||
documented in the [shared README](../../README.md#map-record-announcements).
|
||||
`common_config()` and `ensure_config()` are original T5 in-memory HUD helpers,
|
||||
not an external configuration system.
|
||||
|
||||
## Leaderboard join announcements
|
||||
|
||||
The module is `tws_t6_leaderboard.gsc` in T6 and `tws_t5_leaderboard.gsc` in T5.
|
||||
Common's existing
|
||||
connection listener now also dispatches optional connection handlers after a
|
||||
one-frame yield. Leaderboard waits for player readiness and uses a level-owned
|
||||
HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep
|
||||
`hasSpawned` false, so T6 readiness uses session state and the intro-screen flag;
|
||||
T5 sets `hasSpawned` for both playable and spectator spawns.
|
||||
|
||||
T6's active `scr_zm_ui_gametype` / `scr_zm_map_start_location` fields come from
|
||||
[stock `_zm.gsc`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/zombies/_zm.gsc).
|
||||
Mode/location combinations follow the
|
||||
[T6 server configurations](https://github.com/xerxes-at/T6ServerConfigs/tree/master/localappdata/Plutonium/storage/t6/gamesettings)
|
||||
and Electric Cherry's map catalogue. Unknown selections require an explicit API
|
||||
key instead of falling back to another variant sharing the engine map.
|
||||
|
||||
Validation for this change: 135 fixture/static checks pass using the actual
|
||||
data-handling functions from both scripts with JavaScript engine-value shims.
|
||||
All seven T6 scripts compile and assemble using a local Intel macOS driver built
|
||||
from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was
|
||||
not executed here. T5 compilation and live plugin/gameplay checks were not run;
|
||||
gsc-tool has no T5 backend. The production API could not be reached from this
|
||||
environment. See the shared README for the live validation cases.
|
||||
|
||||
## 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 the [TWS build](../plugins/README.md) based on v3.1.1,
|
||||
which appends diagnostics and enforces native HTTP deadlines. 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: `tools/check.ps1` compiles all seven files for T6 PC and checks shared
|
||||
references and entry points. It requires PowerShell and gsc-tool. The `.help`
|
||||
change passed the static entry-point/reference checks, but compilation and live
|
||||
gameplay were not run. 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.
|
||||
6. Type `.help` and `.HELP` before first spawn, during play and while AFK: see
|
||||
`.help`, `.afk`, `.back` and `.join` with descriptions, only on the requesting
|
||||
player's screen below the health bar. Take damage so health numbers appear
|
||||
and verify they do not overlap help. Repeat the command before 12 seconds
|
||||
elapse: one list should remain visible until 12 seconds after the latest
|
||||
request. Reopen after expiry and reconnect; verify no duplicates or leftovers.
|
||||
Remove the AFK or Join module, restart the map and confirm its commands are
|
||||
absent. Restore the modules and confirm `.afk`, `.back` and `.join` still
|
||||
behave as before.
|
||||
@@ -0,0 +1,170 @@
|
||||
# TWS T6 Zombies scripts
|
||||
|
||||
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
|
||||
just like T5. The telemetry token is embedded in the reporter script; no
|
||||
separate token config or database setup is required.
|
||||
|
||||
| File | Role | Dependencies |
|
||||
| --- | --- | --- |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | 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` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy all seven `tws_t6_*.gsc` files into
|
||||
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
|
||||
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
|
||||
in `Plutonium/plugins/` and restart the server process. The other five scripts
|
||||
do not require that plugin.
|
||||
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
|
||||
the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6).
|
||||
4. Restart the map to load script updates.
|
||||
|
||||
If upgrading from the earlier leaderboard filename, **remove
|
||||
`tws_leaderboard.gsc` from the T6 server script directory** before installing
|
||||
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
||||
|
||||
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. The same rule applies to leaderboard.
|
||||
|
||||
## Map record announcements
|
||||
|
||||
`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials.
|
||||
It works independently of telemetry and HUD, and privately announces records
|
||||
once per connection, including newcomers waiting in spectator mode.
|
||||
|
||||
Map selection uses stock `level.scr_zm_ui_gametype` and
|
||||
`level.scr_zm_map_start_location`, falling back to the corresponding
|
||||
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
|
||||
`zstandard` + `town` selects `t6:zm_transit:town`, while `zclassic` + `transit`
|
||||
selects TranZit. Survival, Grief and Turned variants follow Electric Cherry's
|
||||
catalogue. An unknown mode/location is logged instead of guessing from the engine
|
||||
map. Use `tws_leaderboard_map_key` for a custom registered selection if needed.
|
||||
|
||||
The plugin must expose `httpPost` (with the GET method override), `jsonParse`,
|
||||
`typeof` and `formatString`. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
|
||||
apply to both versions. All T6 script filenames use the `tws_t6_` prefix.
|
||||
|
||||
## Chat commands
|
||||
|
||||
Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and
|
||||
a short description of each. The list is shown only to the requesting player,
|
||||
including spectators who have not spawned. Commands are case-insensitive.
|
||||
Help appears below the health bar for 12 seconds, clear of the timers, zombie
|
||||
counter and health display. Typing either command again restarts that display time.
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
|
||||
AFK and Join register their commands during `init()` through
|
||||
`tws_t6_common::register_help_command(command, description)`. If a module is
|
||||
omitted, its commands are omitted from `.help`. Register new commands alongside
|
||||
their module's initialization so the list stays current.
|
||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||
cached map record privately. Repeating it restarts the record from the first page;
|
||||
help temporarily hides the record until its own display ends.
|
||||
|
||||
## 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
|
||||
`https://ec.thewrightserver.net/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. An embedded token matching the service's
|
||||
`EC_TELEMETRY_TOKEN` (at least 32 characters) and an HTTPS URL are required as well.
|
||||
|
||||
The dvars already present in T5 are still supported: `tws_lock_round`,
|
||||
`tws_server_id`, `tws_telemetry_url`, and `tws_telemetry_enabled` (`0` disables
|
||||
telemetry). Earlier settings still override defaults; remove any old HTTP URL
|
||||
override when upgrading. The reporter token lives in `level.telemetry_token`.
|
||||
|
||||
## Round telemetry
|
||||
|
||||
Telemetry runs directly in GSC using tws-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 "https://ec.thewrightserver.net/api/telemetry"
|
||||
```
|
||||
|
||||
The script embeds the reporter token in `level.telemetry_token`; it must match
|
||||
the API service's `EC_TELEMETRY_TOKEN` environment variable. Follow the shared
|
||||
[authentication setup](../../README.md#telemetry-authentication-t5-and-t6). Every
|
||||
request includes `Authorization: Bearer <token>` and `Content-Type:
|
||||
application/json`. Token rotation requires updating and redeploying both reporter
|
||||
scripts. Token and endpoint changes require a map restart.
|
||||
|
||||
It sends four event types over the life of a match, mirroring the T5 script:
|
||||
|
||||
| Event | When | Round in payload |
|
||||
| --- | --- | --- |
|
||||
| `game_start` | First playable spawn | omitted |
|
||||
| `heartbeat` | Every 30s while the match is in progress | current round |
|
||||
| `round_change` | Every new positive round | new round |
|
||||
| `game_end` | `end_game` / `game_ended` / `intermission` | final round |
|
||||
|
||||
Each event payload includes `event`, `server`, `map`, `round` (except `game_start`)
|
||||
and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives`
|
||||
and `score` per player. `guid` reads Plutonium's `player.guid` engine property
|
||||
(T5 uses the `player getGuid()` method — T6 has no such method, but the field is
|
||||
equivalent for identity purposes). `kills`/`headshots`/`downs`/`revives` read
|
||||
from T6's stock match counters in `player.pers[]`; missing or negative values
|
||||
are reported as `0`. `score` is the player's current spendable `player.score`.
|
||||
|
||||
Delivery is asynchronous with a sequenced FIFO queue and one in-flight request.
|
||||
The queue survives the game-end signal so the final snapshot drains. There is no
|
||||
automatic retry: the receiver may already have accepted the event. Transport
|
||||
success reports the HTTP call finished, not that the API accepted it. The TWS
|
||||
plugin enforces 5-second connection and 15-second transfer deadlines, then
|
||||
releases a timed-out request so the sender can advance. Telemetry logs HTTP
|
||||
status and native timing diagnostics; non-2xx responses increment `http_rejected`
|
||||
separately from transport failures. Older plugins trigger a warning that native
|
||||
deadlines are unverified. Increase
|
||||
`tws_telemetry_log_level` to `2` for per-event capture/queue/response logging;
|
||||
set to `0` to silence non-error output.
|
||||
|
||||
## `.join` for newcomers
|
||||
|
||||
Late connectors who never got a spawn can type `.join` in chat to leave spectator
|
||||
mode before the round ends, once per connection. `.join` is rejected if the player
|
||||
has already played, if the game is not running, if no active player is alive, or
|
||||
during intermission. The script hooks the stock `spectator_respawn` path via
|
||||
`level.custom_spawnplayer`; it does not skip the map-authored spawn callback if
|
||||
another mod installed one first.
|
||||
|
||||
## Validation
|
||||
|
||||
To compile all seven scripts for T6 PC with gsc-tool:
|
||||
|
||||
```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).
|
||||
@@ -0,0 +1,35 @@
|
||||
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_*.gsc' | Sort-Object Name)
|
||||
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc')
|
||||
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
|
||||
$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 seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 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();
|
||||
scripts\zm\tws_t6_common::register_help_command(".afk", "Pause with protection");
|
||||
scripts\zm\tws_t6_common::register_help_command(".back", "Return from AFK");
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* tws_t6_common.gsc — shared config, HUD helpers, connect/spawn/help registries. */
|
||||
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
main()
|
||||
{
|
||||
precacheShader("white");
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
common_config();
|
||||
register_help_command(".help", "List available commands");
|
||||
|
||||
level thread help_chat_listener();
|
||||
level thread on_player_connect_dispatch();
|
||||
}
|
||||
|
||||
common_config()
|
||||
{
|
||||
if (isDefined(level.hud_x))
|
||||
return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Connection handlers also serve newcomers still waiting in spectator mode.
|
||||
register_connect_handler(fn)
|
||||
{
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
level.tws_connect_handlers = [];
|
||||
|
||||
level.tws_connect_handlers[level.tws_connect_handlers.size] = fn;
|
||||
}
|
||||
|
||||
// Modules register their own commands, regardless of init order.
|
||||
register_help_command(command, description)
|
||||
{
|
||||
if (!isDefined(level.tws_help_commands))
|
||||
level.tws_help_commands = [];
|
||||
|
||||
entry = spawnStruct();
|
||||
entry.command = command;
|
||||
entry.description = description;
|
||||
level.tws_help_commands[level.tws_help_commands.size] = entry;
|
||||
}
|
||||
|
||||
help_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
|
||||
command = toLower(message);
|
||||
if (command == ".help" || command == ".commands")
|
||||
player show_help();
|
||||
}
|
||||
}
|
||||
|
||||
show_help()
|
||||
{
|
||||
// Cache the text per connection; repeated SetText calls consume config strings.
|
||||
if (!isDefined(self.tws_help_elems))
|
||||
{
|
||||
elems = [];
|
||||
// Use the same anchors as the HUD, with room below the health numbers.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
title = make_text_elem(self, level.hud_x, y,
|
||||
"left", level.hud_font_mid, level.hud_c_afk);
|
||||
title SetText("Commands");
|
||||
elems[elems.size] = title;
|
||||
|
||||
for (i = 0; i < level.tws_help_commands.size; i++)
|
||||
{
|
||||
entry = level.tws_help_commands[i];
|
||||
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
elem SetText("^3" + entry.command + "^7 - " + entry.description);
|
||||
elems[elems.size] = elem;
|
||||
}
|
||||
|
||||
self.tws_help_elems = elems;
|
||||
self thread destroy_on_disconnect(elems);
|
||||
}
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 1;
|
||||
}
|
||||
|
||||
// Only the expiry timer is replaced; the say listener and cleanup stay alive.
|
||||
self notify("tws_help_refresh");
|
||||
self thread hide_help_after_delay();
|
||||
}
|
||||
|
||||
hide_help_after_delay()
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_help_refresh");
|
||||
|
||||
wait 12;
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
player thread on_player_connected_dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
on_player_connected_dispatch()
|
||||
{
|
||||
self endon("disconnect");
|
||||
// Leave the connection handshake before invoking optional modules.
|
||||
wait 0.05;
|
||||
|
||||
if (!isDefined(level.tws_connect_handlers))
|
||||
return;
|
||||
|
||||
for (i = 0; i < level.tws_connect_handlers.size; i++)
|
||||
self thread [[ level.tws_connect_handlers[i] ]]();
|
||||
}
|
||||
|
||||
// handlers own their own self endon("disconnect")
|
||||
on_player_spawned_dispatch()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
self waittill("spawned_player");
|
||||
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
return;
|
||||
|
||||
for (i = 0; i < level.tws_spawn_handlers.size; i++)
|
||||
self thread [[ level.tws_spawn_handlers[i] ]]();
|
||||
}
|
||||
|
||||
make_text_elem(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = NewClientHudElem(player);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* tws_t6_join.gsc - .join lets a newcomer leave spectator mode before round end. */
|
||||
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
// Plutonium probes both entry points; runtime setup belongs in init.
|
||||
main()
|
||||
{
|
||||
println("[tws_t6_join] main reached");
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
println("[tws_t6_join] init reached");
|
||||
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
scripts\zm\tws_t6_common::register_help_command(".join", "Join mid-round (newcomers only)");
|
||||
scripts\zm\tws_t6_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");
|
||||
|
||||
// The spawn handler flag is set on first playable spawn only; spectator
|
||||
// spawns do not trigger it. Belt-and-suspenders against re-triggering.
|
||||
if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used))
|
||||
{
|
||||
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.
|
||||
// T6 stock does not populate self.initialized the way T5 SP zombiemode
|
||||
// does; use sessionstate as the readiness signal.
|
||||
if (!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) || is_true(level.gameended) ||
|
||||
!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 the stock seeding of self.spectator_respawn
|
||||
// (stock asserts it in spectator_respawn); pick a random map spawn point
|
||||
// as a fallback so the stock callback can proceed.
|
||||
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. Preserve any map-specific
|
||||
// spawn callback if another mod already installed one.
|
||||
if (!isDefined(level.custom_spawnplayer))
|
||||
level.custom_spawnplayer = maps\mp\zombies\_zm::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\mp\zombies\_zm_laststand::player_is_in_laststand()))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */
|
||||
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
main()
|
||||
{
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar("tws_leaderboard_enabled") == "0")
|
||||
return;
|
||||
|
||||
level.tws_lb_game = "t6";
|
||||
level.tws_lb_url = GetDvar("tws_leaderboard_url");
|
||||
if (level.tws_lb_url == "")
|
||||
level.tws_lb_url = "https://ec.thewrightserver.net/api/leaderboard";
|
||||
if (getSubStr(toLower(level.tws_lb_url), 0, 8) != "https://")
|
||||
{
|
||||
lb_log("Disabled: tws_leaderboard_url must use HTTPS");
|
||||
return;
|
||||
}
|
||||
|
||||
level.tws_lb_override = GetDvar("tws_leaderboard_map_key");
|
||||
level.tws_lb_pending = undefined;
|
||||
level.tws_lb_cache = undefined;
|
||||
level.tws_lb_attempt = 0;
|
||||
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
|
||||
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
|
||||
level thread lb_chat_listener();
|
||||
level thread lb_worker();
|
||||
}
|
||||
|
||||
lb_worker()
|
||||
{
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
// The same byte table supports UTF-8 validation and URL percent encoding.
|
||||
level.tws_lb_bytes = [];
|
||||
for (i = 1; i < 256; i++)
|
||||
level.tws_lb_bytes[formatString("%c", i)] = i;
|
||||
wait 1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
// A timed-out native request cannot be cancelled. Never overlap it.
|
||||
while (isDefined(level.tws_lb_pending))
|
||||
wait 1;
|
||||
|
||||
snapshot = lb_refresh();
|
||||
level.tws_lb_attempt++;
|
||||
if (isDefined(snapshot))
|
||||
{
|
||||
snapshot.saved_at = getTime();
|
||||
level.tws_lb_cache = snapshot;
|
||||
wait 300;
|
||||
}
|
||||
else
|
||||
{
|
||||
lb_log("Refresh failed; retaining the last complete record, if available");
|
||||
wait 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lb_welcome()
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_requested");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
if (isDefined(self.tws_lb_welcomed))
|
||||
return;
|
||||
self.tws_lb_welcomed = true;
|
||||
|
||||
// Spectator readiness matters too: do not wait for a playable spawn.
|
||||
while (!isDefined(self.sessionstate) ||
|
||||
(self.sessionstate != "playing" && self.sessionstate != "spectator"))
|
||||
wait 0.25;
|
||||
// T6 keeps hasSpawned false for newcomers in spectator mode.
|
||||
while (isDefined(level.passed_introscreen) && !level.passed_introscreen)
|
||||
wait 0.25;
|
||||
wait 5;
|
||||
|
||||
while (!isDefined(level.tws_lb_cache) && level.tws_lb_attempt == 0)
|
||||
wait 0.25;
|
||||
|
||||
self lb_request(false);
|
||||
}
|
||||
|
||||
lb_chat_listener()
|
||||
{
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
if (toLower(message) == ".leaders")
|
||||
player thread lb_request(true);
|
||||
}
|
||||
}
|
||||
|
||||
lb_cached_record()
|
||||
{
|
||||
if (!isDefined(level.tws_lb_cache))
|
||||
return undefined;
|
||||
snapshot = level.tws_lb_cache;
|
||||
// Do not advertise an indefinitely stale record during an outage.
|
||||
if (getTime() - snapshot.saved_at > 900000)
|
||||
return undefined;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
lb_request(manual)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
snapshot = lb_cached_record();
|
||||
if (!isDefined(snapshot))
|
||||
{
|
||||
if (manual)
|
||||
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
|
||||
return;
|
||||
}
|
||||
// A manual replay replaces any welcome announcement still waiting to run.
|
||||
if (manual)
|
||||
self notify("tws_lb_requested");
|
||||
self lb_show_hud(snapshot);
|
||||
}
|
||||
|
||||
lb_show_hud(snapshot)
|
||||
{
|
||||
// Stop the previous display and its cleanup threads before replacing it.
|
||||
self notify("tws_lb_hud_done");
|
||||
if (isDefined(self.tws_lb_elems))
|
||||
lb_hud_destroy(self.tws_lb_elems);
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
// Match .help's position, typography and row spacing below the health bar.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
elems = [];
|
||||
title = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
|
||||
title.alpha = 0;
|
||||
if (snapshot.round == 0)
|
||||
title SetText("No recorded high round yet");
|
||||
else
|
||||
title SetText("Record: Round " + snapshot.round);
|
||||
elems[elems.size] = title;
|
||||
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
row.alpha = 0;
|
||||
elems[elems.size] = row;
|
||||
}
|
||||
|
||||
// Cleanup runs independently of the display and ends when it is replaced.
|
||||
self.tws_lb_elems = elems;
|
||||
self thread lb_hud_disconnect(elems);
|
||||
self thread lb_hud_end_game(elems, "end_game");
|
||||
self thread lb_hud_end_game(elems, "game_ended");
|
||||
self thread lb_hud_end_game(elems, "intermission");
|
||||
|
||||
offset = 0;
|
||||
for (;;)
|
||||
{
|
||||
// Text changes only once per page, never during the visibility loop.
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = elems[i + 1];
|
||||
if (offset + i < snapshot.lines.size)
|
||||
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
|
||||
else
|
||||
row SetText("");
|
||||
}
|
||||
|
||||
remaining = 12;
|
||||
while (remaining > 0)
|
||||
{
|
||||
visible = !self lb_hud_help_visible();
|
||||
for (i = 0; i < elems.size; i++)
|
||||
elems[i].alpha = visible;
|
||||
wait 0.1;
|
||||
// Pause the page while .help occupies the same area.
|
||||
if (visible)
|
||||
remaining -= 0.1;
|
||||
}
|
||||
offset += 3;
|
||||
if (offset >= snapshot.lines.size)
|
||||
break;
|
||||
}
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_help_visible()
|
||||
{
|
||||
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
|
||||
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
|
||||
}
|
||||
|
||||
lb_hud_disconnect(elems)
|
||||
{
|
||||
self endon("tws_lb_hud_done");
|
||||
self waittill("disconnect");
|
||||
lb_hud_destroy(elems);
|
||||
}
|
||||
|
||||
lb_hud_end_game(elems, event)
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
level waittill(event);
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_destroy(elems)
|
||||
{
|
||||
for (i = 0; i < elems.size; i++)
|
||||
{
|
||||
if (isDefined(elems[i]))
|
||||
elems[i] Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
lb_refresh()
|
||||
{
|
||||
selection = lb_selection();
|
||||
if (!isDefined(selection))
|
||||
{
|
||||
lb_log("Cannot identify this map variant; set tws_leaderboard_map_key to its Electric Cherry key");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map keys are discovered and checked against the API's available maps.
|
||||
discovery = lb_get("&limit=1&offset=0");
|
||||
if (!isDefined(discovery) || !lb_page_valid(discovery, 0, 1))
|
||||
return undefined;
|
||||
if (!lb_resolve_map(selection, discovery["maps"]))
|
||||
return undefined;
|
||||
|
||||
snapshot = spawnStruct();
|
||||
snapshot.name = lb_display_text(selection.name, 64);
|
||||
snapshot.round = 0;
|
||||
snapshot.names = [];
|
||||
snapshot.guids = [];
|
||||
snapshot.lines = [];
|
||||
snapshot.total = -1;
|
||||
snapshot.last_round = 2147483647;
|
||||
|
||||
// No map option means no completed match has been recorded for this map.
|
||||
if (!selection.found)
|
||||
return snapshot;
|
||||
|
||||
offset = 0;
|
||||
for (;;)
|
||||
{
|
||||
page = lb_get("&map=" + lb_url_encode(selection.key) + "&limit=3&offset=" + offset);
|
||||
if (!isDefined(page) || !lb_page_valid(page, offset, 3))
|
||||
return undefined;
|
||||
result = lb_take_page(snapshot, page, selection.key, offset);
|
||||
if (result < 0)
|
||||
{
|
||||
lb_log("Invalid or changing leaderboard; discarding incomplete refresh");
|
||||
return undefined;
|
||||
}
|
||||
if (result == 1)
|
||||
{
|
||||
snapshot.lines = lb_holder_lines(snapshot.names);
|
||||
return snapshot;
|
||||
}
|
||||
offset += page["entries"].size;
|
||||
if (offset > 1000000)
|
||||
{
|
||||
lb_log("Leaderboard exceeds the API pagination limit");
|
||||
return undefined;
|
||||
}
|
||||
wait 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
lb_selection()
|
||||
{
|
||||
selection = spawnStruct();
|
||||
selection.map = toLower(GetDvar("mapname"));
|
||||
selection.name = selection.map;
|
||||
selection.key = level.tws_lb_override;
|
||||
selection.explicit = selection.key != "";
|
||||
if (selection.explicit)
|
||||
return selection;
|
||||
|
||||
mode = toLower(GetDvar("ui_gametype"));
|
||||
location = toLower(GetDvar("ui_zm_mapstartlocation"));
|
||||
if (isDefined(level.scr_zm_ui_gametype))
|
||||
mode = toLower(level.scr_zm_ui_gametype);
|
||||
if (isDefined(level.scr_zm_map_start_location))
|
||||
location = toLower(level.scr_zm_map_start_location);
|
||||
if ((location == "" || location == "default") && isDefined(level.default_start_location))
|
||||
location = toLower(level.default_start_location);
|
||||
return lb_t6_selection(selection, mode, location);
|
||||
}
|
||||
|
||||
// Runtime mode/location -> Electric Cherry's registered variant catalogue.
|
||||
// Never select the sole available zm_transit option by engine map alone.
|
||||
lb_t6_selection(selection, mode, location)
|
||||
{
|
||||
rows = [];
|
||||
rows[0] = "zm_transit|zclassic|transit|transit|TranZit";
|
||||
rows[1] = "zm_transit|zstandard|town|town|Town Survival";
|
||||
rows[2] = "zm_transit|zstandard|farm|farm|Farm Survival";
|
||||
rows[3] = "zm_transit|zstandard|transit|busdepot|Bus Depot Survival";
|
||||
rows[4] = "zm_nuked|zstandard|nuked|nuked|Nuketown";
|
||||
rows[5] = "zm_highrise|zclassic|rooftop|highrise|Die Rise";
|
||||
rows[6] = "zm_prison|zclassic|prison|prison|Mob of the Dead";
|
||||
rows[7] = "zm_buried|zclassic|processing|buried|Buried";
|
||||
rows[8] = "zm_tomb|zclassic|tomb|tomb|Origins";
|
||||
rows[9] = "zm_transit|zgrief|town|towngrief|Town Grief";
|
||||
rows[10] = "zm_transit|zgrief|farm|farmgrief|Farm Grief";
|
||||
rows[11] = "zm_transit|zgrief|transit|busdepotgrief|Bus Depot Grief";
|
||||
rows[12] = "zm_prison|zgrief|cellblock|cellblock|Cell Block Grief";
|
||||
rows[13] = "zm_buried|zgrief|street|buriedgrief|Buried Grief";
|
||||
rows[14] = "zm_transit_dr|zcleansed|diner|diner|Diner Turned";
|
||||
rows[15] = "zm_buried|zcleansed|street|buriedturned|Buried Turned";
|
||||
for (i = 0; i < rows.size; i++)
|
||||
{
|
||||
parts = strTok(rows[i], "|");
|
||||
if (selection.map == parts[0] && mode == parts[1] && location == parts[2])
|
||||
{
|
||||
selection.key = "t6:" + selection.map + ":" + parts[3];
|
||||
selection.name = parts[4];
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// All HTTP and parsing work belongs to the level worker, never the join thread.
|
||||
lb_get(query)
|
||||
{
|
||||
options = [];
|
||||
options["method"] = "GET";
|
||||
headers = [];
|
||||
headers["Accept"] = "application/json";
|
||||
options["headers"] = headers;
|
||||
url = level.tws_lb_url + "?game=" + level.tws_lb_game + "&metric=highestRound" + query;
|
||||
// v3.1.1 accepts the GET method override despite the alias name.
|
||||
request = httpPost(url, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
lb_log("HTTP request could not be created");
|
||||
return undefined;
|
||||
}
|
||||
pending = spawnStruct();
|
||||
pending.done = false;
|
||||
level.tws_lb_pending = pending;
|
||||
level thread lb_collect(request, pending);
|
||||
started = getTime();
|
||||
while (!pending.done && getTime() - started < 15000)
|
||||
wait 0.1;
|
||||
if (!pending.done)
|
||||
{
|
||||
lb_log("HTTP timed out; waiting for native completion before requesting again");
|
||||
return undefined;
|
||||
}
|
||||
if (!isDefined(pending.success) || !pending.success)
|
||||
{
|
||||
lb_log("HTTP transport failed");
|
||||
return undefined;
|
||||
}
|
||||
// Plugins expose neither HTTP status nor response headers. Reject error
|
||||
// bodies by grammar and success-schema checks, not the transport flag.
|
||||
if (!lb_json_valid(pending.body))
|
||||
{
|
||||
lb_log("Rejected malformed, oversized or non-JSON HTTP response");
|
||||
return undefined;
|
||||
}
|
||||
return jsonParse(pending.body);
|
||||
}
|
||||
|
||||
lb_collect(request, pending)
|
||||
{
|
||||
// Keep draining after game over; ending a GSC thread cannot cancel HTTP.
|
||||
request waittill("done", body, success, error);
|
||||
pending.body = body;
|
||||
pending.success = success;
|
||||
pending.done = true;
|
||||
level.tws_lb_pending = undefined;
|
||||
}
|
||||
|
||||
lb_log(message)
|
||||
{
|
||||
println("[tws_t6_leaderboard] " + message);
|
||||
}
|
||||
|
||||
// Pure data helpers below are exercised by tools/check_leaderboard.mjs.
|
||||
lb_resolve_map(selection, maps)
|
||||
{
|
||||
selection.found = false;
|
||||
matches = 0;
|
||||
// T5 requires initialization before the loop, even with matches == 1 below.
|
||||
chosen = undefined;
|
||||
for (i = 0; i < maps.size; i++)
|
||||
{
|
||||
item = maps[i];
|
||||
if (typeof(item) != "array" || typeof(item["game"]) != "string" ||
|
||||
typeof(item["map"]) != "string" || typeof(item["key"]) != "string" ||
|
||||
typeof(item["name"]) != "string")
|
||||
return false;
|
||||
if (item["game"] != level.tws_lb_game || item["map"] != selection.map)
|
||||
continue;
|
||||
if (selection.key != "" && item["key"] != selection.key)
|
||||
continue;
|
||||
matches++;
|
||||
chosen = item;
|
||||
}
|
||||
if (matches > 1 || (matches == 0 && selection.explicit))
|
||||
{
|
||||
lb_log("Map key is absent, ambiguous or belongs to another map/game");
|
||||
return false;
|
||||
}
|
||||
if (matches == 1)
|
||||
{
|
||||
selection.key = chosen["key"];
|
||||
selection.name = chosen["name"];
|
||||
selection.found = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
lb_page_valid(page, offset, limit)
|
||||
{
|
||||
if (typeof(page) != "array" || typeof(page["entries"]) != "array" ||
|
||||
typeof(page["maps"]) != "array" || typeof(page["summary"]) != "array")
|
||||
return false;
|
||||
if (typeof(page["metric"]) != "string" || !lb_uint(page["offset"]) || !lb_uint(page["limit"]))
|
||||
return false;
|
||||
if (page["metric"] != "highestRound" || page["offset"] != offset || page["limit"] != limit)
|
||||
return false;
|
||||
return lb_uint(page["total"]) && lb_uint(page["summary"]["highestRound"]) &&
|
||||
page["entries"].size <= limit;
|
||||
}
|
||||
|
||||
// -1 rejects the whole refresh, 0 requests another page, 1 is complete.
|
||||
lb_take_page(snapshot, page, key, offset)
|
||||
{
|
||||
entries = page["entries"];
|
||||
if (offset == 0)
|
||||
{
|
||||
snapshot.round = page["summary"]["highestRound"];
|
||||
snapshot.total = page["total"];
|
||||
}
|
||||
if (snapshot.round != page["summary"]["highestRound"] || snapshot.total != page["total"])
|
||||
return -1;
|
||||
if (entries.size == 0)
|
||||
{
|
||||
if (offset == 0 && snapshot.total == 0 && snapshot.round == 0)
|
||||
return 1;
|
||||
return -1;
|
||||
}
|
||||
if (snapshot.round < 2 || offset + entries.size > snapshot.total)
|
||||
return -1;
|
||||
for (i = 0; i < entries.size; i++)
|
||||
{
|
||||
entry = entries[i];
|
||||
if (typeof(entry) != "array" || !lb_uint(entry["highestRound"]) || !lb_uint(entry["rank"]))
|
||||
return -1;
|
||||
value = entry["highestRound"];
|
||||
if (value > snapshot.last_round || value > snapshot.round)
|
||||
return -1;
|
||||
snapshot.last_round = value;
|
||||
if (value < snapshot.round)
|
||||
{
|
||||
if (snapshot.names.size == 0)
|
||||
return -1;
|
||||
return 1;
|
||||
}
|
||||
if (entry["rank"] != 1 || typeof(entry["guid"]) != "string" || entry["guid"] == "" ||
|
||||
typeof(entry["name"]) != "string" || typeof(entry["records"]) != "array")
|
||||
return -1;
|
||||
evidence = entry["records"]["highestRound"];
|
||||
if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" ||
|
||||
typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"]))
|
||||
return -1;
|
||||
if (evidence["game"] != level.tws_lb_game ||
|
||||
evidence["mapKey"] != key || evidence["value"] != snapshot.round)
|
||||
return -1;
|
||||
// GUIDs remain strings; equal names are distinct players.
|
||||
guid = entry["guid"];
|
||||
if (!isDefined(snapshot.guids[guid]))
|
||||
{
|
||||
snapshot.guids[guid] = true;
|
||||
name = lb_display_text(entry["name"], 64);
|
||||
if (name == "")
|
||||
name = lb_display_text(guid, 64);
|
||||
snapshot.names[snapshot.names.size] = name;
|
||||
}
|
||||
}
|
||||
if (offset + entries.size >= snapshot.total)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
lb_uint(value)
|
||||
{
|
||||
return typeof(value) == "int" && value >= 0;
|
||||
}
|
||||
|
||||
lb_holder_lines(names)
|
||||
{
|
||||
lines = [];
|
||||
line = "";
|
||||
for (i = 0; i < names.size; i++)
|
||||
{
|
||||
if (line != "" && line.size + names[i].size + 2 > 100)
|
||||
{
|
||||
lines[lines.size] = line;
|
||||
line = "";
|
||||
}
|
||||
if (line != "")
|
||||
line += ", ";
|
||||
line += names[i];
|
||||
}
|
||||
if (line != "")
|
||||
lines[lines.size] = line;
|
||||
return lines;
|
||||
}
|
||||
|
||||
lb_display_text(value, limit)
|
||||
{
|
||||
output = "";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
ch = getSubStr(value, i, i + 1);
|
||||
code = level.tws_lb_bytes[ch];
|
||||
if (!isDefined(code) || code < 32 || code == 127)
|
||||
continue;
|
||||
if (ch == "^")
|
||||
{
|
||||
// Strip game colour escapes; never let a name inject formatting.
|
||||
if (i + 1 < value.size && lb_contains("0123456789", getSubStr(value, i + 1, i + 2)))
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
width = 1;
|
||||
if (code >= 240)
|
||||
width = 4;
|
||||
else if (code >= 224)
|
||||
width = 3;
|
||||
else if (code >= 192)
|
||||
width = 2;
|
||||
if (output.size + width > limit)
|
||||
return output + "...";
|
||||
output += getSubStr(value, i, i + width);
|
||||
i += width - 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
lb_url_encode(value)
|
||||
{
|
||||
hex = "0123456789ABCDEF";
|
||||
encoded = "";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
ch = getSubStr(value, i, i + 1);
|
||||
if (lb_contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ch))
|
||||
encoded += ch;
|
||||
else
|
||||
{
|
||||
code = level.tws_lb_bytes[ch];
|
||||
high = int(code / 16);
|
||||
low = code % 16;
|
||||
encoded += "%" + getSubStr(hex, high, high + 1) + getSubStr(hex, low, low + 1);
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
lb_contains(chars, ch)
|
||||
{
|
||||
if (ch == "")
|
||||
return false;
|
||||
for (j = 0; j < chars.size; j++)
|
||||
{
|
||||
if (getSubStr(chars, j, j + 1) == ch)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// jsonParse raises a script error on malformed input. Validate the complete
|
||||
// grammar, UTF-8 and escapes first, including truncated bodies and bad proxies.
|
||||
// Yield during scanning so network data cannot monopolize a server frame.
|
||||
lb_json_valid(body)
|
||||
{
|
||||
if (typeof(body) != "string" || body.size == 0 || body.size >= 20480)
|
||||
return false;
|
||||
if (!lb_utf8_valid(body))
|
||||
return false;
|
||||
state = spawnStruct();
|
||||
state.body = body;
|
||||
state.pos = 0;
|
||||
state.yield_at = 256;
|
||||
if (!lb_json_value(state, 0))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
return state.pos == body.size;
|
||||
}
|
||||
|
||||
lb_utf8_valid(body)
|
||||
{
|
||||
for (i = 0; i < body.size; i++)
|
||||
{
|
||||
if (i % 256 == 0)
|
||||
wait 0.05;
|
||||
code = level.tws_lb_bytes[getSubStr(body, i, i + 1)];
|
||||
if (!isDefined(code))
|
||||
return false;
|
||||
if (code < 128)
|
||||
continue;
|
||||
count = 0;
|
||||
minimum = 128;
|
||||
maximum = 191;
|
||||
if (code >= 194 && code <= 223)
|
||||
count = 1;
|
||||
else if (code >= 224 && code <= 239)
|
||||
{
|
||||
count = 2;
|
||||
if (code == 224)
|
||||
minimum = 160;
|
||||
if (code == 237)
|
||||
maximum = 159;
|
||||
}
|
||||
else if (code >= 240 && code <= 244)
|
||||
{
|
||||
count = 3;
|
||||
if (code == 240)
|
||||
minimum = 144;
|
||||
if (code == 244)
|
||||
maximum = 143;
|
||||
}
|
||||
if (count == 0 || i + count >= body.size)
|
||||
return false;
|
||||
for (j = 1; j <= count; j++)
|
||||
{
|
||||
next = level.tws_lb_bytes[getSubStr(body, i + j, i + j + 1)];
|
||||
if (!isDefined(next) || next < minimum || next > maximum)
|
||||
return false;
|
||||
minimum = 128;
|
||||
maximum = 191;
|
||||
}
|
||||
i += count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
lb_json_char(state)
|
||||
{
|
||||
if (state.pos >= state.yield_at)
|
||||
{
|
||||
wait 0.05;
|
||||
state.yield_at = state.pos + 256;
|
||||
}
|
||||
if (state.pos >= state.body.size)
|
||||
return "";
|
||||
return getSubStr(state.body, state.pos, state.pos + 1);
|
||||
}
|
||||
|
||||
lb_json_space(state)
|
||||
{
|
||||
while (lb_contains(" \t\r\n", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
|
||||
lb_json_value(state, depth)
|
||||
{
|
||||
if (depth > 16)
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
ch = lb_json_char(state);
|
||||
if (ch == "\"")
|
||||
return lb_json_string(state);
|
||||
if (ch == "{" || ch == "[")
|
||||
{
|
||||
object = ch == "{";
|
||||
close = "]";
|
||||
if (object)
|
||||
close = "}";
|
||||
state.pos++;
|
||||
lb_json_space(state);
|
||||
if (lb_json_char(state) == close)
|
||||
{
|
||||
state.pos++;
|
||||
return true;
|
||||
}
|
||||
for (;;)
|
||||
{
|
||||
if (object)
|
||||
{
|
||||
if (!lb_json_string(state))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
if (lb_json_char(state) != ":")
|
||||
return false;
|
||||
state.pos++;
|
||||
}
|
||||
if (!lb_json_value(state, depth + 1))
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (ch == close)
|
||||
return true;
|
||||
if (ch != ",")
|
||||
return false;
|
||||
lb_json_space(state);
|
||||
}
|
||||
}
|
||||
literals = strTok("true,false,null", ",");
|
||||
for (i = 0; i < literals.size; i++)
|
||||
{
|
||||
literal = literals[i];
|
||||
if (state.pos + literal.size <= state.body.size &&
|
||||
getSubStr(state.body, state.pos, state.pos + literal.size) == literal)
|
||||
{
|
||||
state.pos += literal.size;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return lb_json_number(state);
|
||||
}
|
||||
|
||||
lb_json_string(state)
|
||||
{
|
||||
if (lb_json_char(state) != "\"")
|
||||
return false;
|
||||
state.pos++;
|
||||
while (state.pos < state.body.size)
|
||||
{
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (ch == "\"")
|
||||
return true;
|
||||
if (level.tws_lb_bytes[ch] < 32)
|
||||
return false;
|
||||
if (ch != "\\")
|
||||
continue;
|
||||
ch = lb_json_char(state);
|
||||
state.pos++;
|
||||
if (lb_contains("\"\\/bfnrt", ch))
|
||||
continue;
|
||||
if (ch != "u")
|
||||
return false;
|
||||
code = lb_json_hex4(state);
|
||||
// Embedded NUL does not round-trip through engine strings.
|
||||
if (code <= 0 || (code >= 56320 && code <= 57343))
|
||||
return false;
|
||||
if (code >= 55296 && code <= 56319)
|
||||
{
|
||||
if (state.pos + 2 > state.body.size ||
|
||||
getSubStr(state.body, state.pos, state.pos + 2) != "\\u")
|
||||
return false;
|
||||
state.pos += 2;
|
||||
code = lb_json_hex4(state);
|
||||
if (code < 56320 || code > 57343)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
lb_json_hex4(state)
|
||||
{
|
||||
hex = "0123456789abcdef";
|
||||
result = 0;
|
||||
for (i = 0; i < 4; i++)
|
||||
{
|
||||
ch = toLower(lb_json_char(state));
|
||||
if (!lb_contains(hex, ch))
|
||||
return -1;
|
||||
for (j = 0; j < 16; j++)
|
||||
{
|
||||
if (getSubStr(hex, j, j + 1) == ch)
|
||||
break;
|
||||
}
|
||||
result = result * 16 + j;
|
||||
state.pos++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
lb_json_number(state)
|
||||
{
|
||||
start = state.pos;
|
||||
if (lb_json_char(state) == "-")
|
||||
state.pos++;
|
||||
ch = lb_json_char(state);
|
||||
if (ch == "0")
|
||||
state.pos++;
|
||||
else
|
||||
{
|
||||
if (!lb_contains("123456789", ch))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
if (lb_json_char(state) == ".")
|
||||
{
|
||||
state.pos++;
|
||||
if (!lb_contains("0123456789", lb_json_char(state)))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
}
|
||||
if (lb_contains("eE", lb_json_char(state)))
|
||||
{
|
||||
state.pos++;
|
||||
if (lb_contains("+-", lb_json_char(state)))
|
||||
state.pos++;
|
||||
exponent = state.pos;
|
||||
if (!lb_contains("0123456789", lb_json_char(state)))
|
||||
return false;
|
||||
while (lb_contains("0123456789", lb_json_char(state)))
|
||||
state.pos++;
|
||||
// API counters/ratios never need huge exponents; prevent native overflow.
|
||||
if (state.pos - exponent > 2 || int(getSubStr(state.body, exponent, state.pos)) > 38)
|
||||
return false;
|
||||
}
|
||||
return state.pos - start <= 32;
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/* tws_t6_telemetry.gsc - Zombies game reports via tws-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: tws-t6-gsc-utils with deadlines/diagnostics)");
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
println("[tws_t6_telemetry] init reached");
|
||||
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
|
||||
if (GetDvar("tws_telemetry_enabled") == "0")
|
||||
{
|
||||
println("[tws_t6_telemetry] Disabled by tws_telemetry_enabled=0");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(level.telemetry_initialized))
|
||||
return;
|
||||
|
||||
level.telemetry_url = GetDvar("tws_telemetry_url");
|
||||
if (level.telemetry_url == "")
|
||||
level.telemetry_url = "https://ec.thewrightserver.net/api/telemetry";
|
||||
|
||||
if (getSubStr(toLower(level.telemetry_url), 0, 8) != "https://")
|
||||
{
|
||||
println("^1[tws_t6_telemetry] Telemetry disabled: tws_telemetry_url must use HTTPS");
|
||||
return;
|
||||
}
|
||||
|
||||
// Shared reporter secret; update both scripts when rotating it.
|
||||
// This must match Electric Cherry's EC_TELEMETRY_TOKEN environment variable.
|
||||
level.telemetry_token = "e03a03f7d22418f0c062b0b2a7317fd91b1194c4400e84b469866de501180dc2";
|
||||
if (level.telemetry_token.size < 32)
|
||||
{
|
||||
println("^1[tws_t6_telemetry] Telemetry disabled: embedded telemetry token must be at least 32 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_server = GetDvar("tws_server_id");
|
||||
if (level.telemetry_server == "" || level.telemetry_server.size > 255)
|
||||
{
|
||||
println("^1[tws_t6_telemetry] Set tws_server_id to the exact registered server name (1-255 characters)");
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_initialized = true;
|
||||
level.telemetry_started = false;
|
||||
level.telemetry_ended = false;
|
||||
level.telemetry_last_round = -1;
|
||||
level.telemetry_queue = [];
|
||||
level.telemetry_queue_read = 0;
|
||||
level.telemetry_queue_write = 0;
|
||||
level.telemetry_sequence = 0;
|
||||
level.telemetry_completed = 0;
|
||||
level.telemetry_failed = 0;
|
||||
level.telemetry_http_rejected = 0;
|
||||
level.telemetry_missing_diagnostics_warned = false;
|
||||
level.telemetry_failure_streak = 0;
|
||||
level.telemetry_last_completed = -1;
|
||||
level.telemetry_last_capture = getTime();
|
||||
level.telemetry_round_tick = getTime();
|
||||
level.telemetry_capture_event = "none";
|
||||
level.telemetry_sender_state = "starting";
|
||||
level.telemetry_inflight = undefined;
|
||||
|
||||
telemetry_log(1, "Initialized; waiting for match start. log_level=" + telemetry_log_level());
|
||||
|
||||
// Route through the shared dispatcher; a duplicate connected listener
|
||||
// would fight the T6 common spawn dispatcher.
|
||||
scripts\zm\tws_t6_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 that pre-seed round 1.
|
||||
// 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. Poll for a positive round
|
||||
// with any player present as a fallback in case the dispatcher spawn hook missed
|
||||
// the first spawn (late-attach or stock ordering quirk).
|
||||
telemetry_round_watcher()
|
||||
{
|
||||
level endon("telemetry_finished");
|
||||
|
||||
for (;;)
|
||||
{
|
||||
level.telemetry_round_tick = getTime();
|
||||
if (!level.telemetry_started && isDefined(level.round_number) &&
|
||||
level.round_number >= 1 && getPlayers().size > 0)
|
||||
{
|
||||
telemetry_log(1, "Starting from round_watcher 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);
|
||||
}
|
||||
|
||||
// T6 stock initializes and updates match counters in player.pers[] through
|
||||
// _globallogic_score::initpersstat / incpersstat; getpersstat reads this array.
|
||||
telemetry_stat(player, name)
|
||||
{
|
||||
if (!isDefined(player.pers) || !isDefined(player.pers[name]))
|
||||
return 0;
|
||||
|
||||
return telemetry_nonnegative_int(player.pers[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;
|
||||
}
|
||||
|
||||
// T6 exposes the persistent per-player identity as the .guid property
|
||||
// (Plutonium engine field; not a method call and not from the plugin).
|
||||
// With a linked Plutonium account it is the account ID; guest/local
|
||||
// sessions fall back to the client slot number, which is expected.
|
||||
// Force to string so the receiver never sees a numeric guid.
|
||||
guid = player.guid;
|
||||
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);
|
||||
}
|
||||
|
||||
// t6-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);
|
||||
}
|
||||
}
|
||||
|
||||
// v3.1.1 global alias: httpPost(url, options) with options["method"|"headers"|"body"].
|
||||
// The plugin exposes no cancellation API and no HTTP status code; treat the
|
||||
// success flag as transport completion only and never overlap requests.
|
||||
telemetry_send(entry)
|
||||
{
|
||||
level.telemetry_inflight = entry;
|
||||
level.telemetry_request_started = getTime();
|
||||
level.telemetry_sender_state = "creating_request";
|
||||
label = telemetry_event_label(entry);
|
||||
telemetry_log(1, "Sending " + label + " queue_ms=" + (getTime() - entry["captured"]) + " pending=" + telemetry_pending());
|
||||
|
||||
headers = [];
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Authorization"] = "Bearer " + level.telemetry_token;
|
||||
|
||||
options = [];
|
||||
options["method"] = "POST";
|
||||
options["headers"] = headers;
|
||||
options["body"] = entry["body"];
|
||||
options["connect_timeout_ms"] = 5000;
|
||||
options["timeout_ms"] = 15000;
|
||||
|
||||
request = httpPost(level.telemetry_url, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
telemetry_transport_failed(label + " request handle is undefined; skipping event");
|
||||
level.telemetry_inflight = undefined;
|
||||
level.telemetry_sender_state = "idle";
|
||||
return;
|
||||
}
|
||||
|
||||
level.telemetry_sender_state = "waiting_http";
|
||||
request waittill("done", response, success, error, diagnostics);
|
||||
elapsed = getTime() - level.telemetry_request_started;
|
||||
level.telemetry_last_completed = getTime();
|
||||
timing = telemetry_http_diagnostics(diagnostics);
|
||||
if (!isDefined(diagnostics) && !level.telemetry_missing_diagnostics_warned)
|
||||
{
|
||||
level.telemetry_missing_diagnostics_warned = true;
|
||||
telemetry_log(0, "WARNING: plugin returned no HTTP diagnostics; native timeouts are unverified. Install the TWS plugin build and restart the server process");
|
||||
}
|
||||
|
||||
// The plugin's success flag reports transport completion, not HTTP status.
|
||||
// No automatic retry: the receiver may already have accepted the event.
|
||||
if (!isDefined(success) || !success)
|
||||
{
|
||||
if (!isDefined(error))
|
||||
error = "unknown transport error";
|
||||
telemetry_transport_failed(label + " elapsed_ms=" + elapsed + timing + " error=" + telemetry_preview(error));
|
||||
}
|
||||
else
|
||||
{
|
||||
level.telemetry_completed++;
|
||||
if (level.telemetry_failure_streak > 0)
|
||||
telemetry_log(0, "Transport recovered after " + level.telemetry_failure_streak + " failures; " + label);
|
||||
level.telemetry_failure_streak = 0;
|
||||
if (isDefined(diagnostics) && isDefined(diagnostics["http_status"]))
|
||||
{
|
||||
if (diagnostics["http_status"] < 200 || diagnostics["http_status"] >= 300)
|
||||
{
|
||||
level.telemetry_http_rejected++;
|
||||
telemetry_log(0, "ERROR: HTTP rejected " + label + " elapsed_ms=" + elapsed + timing + " body=" + telemetry_preview(response));
|
||||
}
|
||||
else
|
||||
telemetry_log(1, "HTTP completed " + label + " elapsed_ms=" + elapsed + timing);
|
||||
}
|
||||
else
|
||||
telemetry_log(1, "Transport completed " + label + " elapsed_ms=" + elapsed + timing + " (API acceptance unverified)");
|
||||
if (!isDefined(response) || response == "")
|
||||
telemetry_log(0, "WARNING: empty API response for " + label);
|
||||
else
|
||||
telemetry_log(2, "Response " + label + " body=" + telemetry_preview(response));
|
||||
}
|
||||
|
||||
level.telemetry_inflight = undefined;
|
||||
level.telemetry_sender_state = "idle";
|
||||
}
|
||||
|
||||
// The native plugin appends diagnostics while preserving the old done arguments.
|
||||
// Curl timings are cumulative milestones, measured in real milliseconds.
|
||||
telemetry_http_diagnostics(diagnostics)
|
||||
{
|
||||
if (!isDefined(diagnostics))
|
||||
return " diagnostics=unavailable";
|
||||
|
||||
keys = strTok("http_status,curl_code,dns_ms,connect_ms,tls_ms,first_byte_ms,total_ms,worker_queue_ms,notify_ms,connect_timeout_ms,timeout_ms", ",");
|
||||
detail = "";
|
||||
for (i = 0; i < keys.size; i++)
|
||||
{
|
||||
key = keys[i];
|
||||
if (isDefined(diagnostics[key]))
|
||||
detail += " " + key + "=" + int(diagnostics[key]);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
telemetry_transport_failed(detail)
|
||||
{
|
||||
level.telemetry_failed++;
|
||||
level.telemetry_failure_streak++;
|
||||
telemetry_log(0, "ERROR: transport failed " + detail + " consecutive_failures=" + level.telemetry_failure_streak);
|
||||
}
|
||||
|
||||
telemetry_pending()
|
||||
{
|
||||
return level.telemetry_queue_write - level.telemetry_queue_read;
|
||||
}
|
||||
|
||||
telemetry_event_label(entry)
|
||||
{
|
||||
return "id=" + entry["id"] + " event=" + entry["event"] + " round=" + entry["round"];
|
||||
}
|
||||
|
||||
// Read live so diagnostics can be increased without restarting the map.
|
||||
telemetry_log_level()
|
||||
{
|
||||
raw = GetDvar("tws_telemetry_log_level");
|
||||
if (raw == "0")
|
||||
return 0;
|
||||
if (raw == "2")
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
telemetry_log(verbosity, message)
|
||||
{
|
||||
if (verbosity > telemetry_log_level())
|
||||
return;
|
||||
|
||||
println("[tws_t6_telemetry] server=" + level.telemetry_server + " map=" + GetDvar("mapname") + " time_ms=" + getTime() + " " + message);
|
||||
}
|
||||
|
||||
// Escape newlines/control characters and bound response/error output. Do not
|
||||
// jsonParse arbitrary replies: a proxy may return HTML or malformed JSON.
|
||||
telemetry_preview(value)
|
||||
{
|
||||
if (!isDefined(value))
|
||||
return "<undefined>";
|
||||
value = "" + value;
|
||||
if (value.size > 256)
|
||||
value = getSubStr(value, 0, 256) + "... [truncated]";
|
||||
return jsonSerialize(value);
|
||||
}
|
||||
|
||||
// Independent of producers and the HTTP wait; survives game over until drain.
|
||||
// Native requests have deadlines. Warn without opening overlapping POSTs
|
||||
// that could arrive out of order or exhaust its native worker pool.
|
||||
telemetry_watchdog()
|
||||
{
|
||||
level endon("telemetry_drained");
|
||||
for (;;)
|
||||
{
|
||||
wait (30);
|
||||
now = getTime();
|
||||
pending = telemetry_pending();
|
||||
completion_age = -1;
|
||||
if (level.telemetry_last_completed >= 0)
|
||||
completion_age = now - level.telemetry_last_completed;
|
||||
|
||||
telemetry_log(1, "Health started=" + level.telemetry_started + " ended=" + level.telemetry_ended +
|
||||
" sender=" + level.telemetry_sender_state + " capturing=" + level.telemetry_capture_event +
|
||||
" pending=" + pending + " queued_total=" + level.telemetry_sequence +
|
||||
" completed=" + level.telemetry_completed + " failed=" + level.telemetry_failed + " http_rejected=" + level.telemetry_http_rejected +
|
||||
" last_done_ms_ago=" + completion_age);
|
||||
|
||||
if (isDefined(level.telemetry_inflight))
|
||||
{
|
||||
age = now - level.telemetry_request_started;
|
||||
if (age >= 30000)
|
||||
telemetry_log(0, "WARNING: HTTP stalled " + telemetry_event_label(level.telemetry_inflight) +
|
||||
" elapsed_ms=" + age + " pending=" + pending + " stage=" + level.telemetry_sender_state +
|
||||
"; native deadline exceeded; check plugin version, worker queue and completion delivery");
|
||||
}
|
||||
else if (pending > 0)
|
||||
{
|
||||
oldest = level.telemetry_queue[level.telemetry_queue_read];
|
||||
if (now - oldest["captured"] >= 30000)
|
||||
telemetry_log(0, "WARNING: sender is not consuming queued reports; " + telemetry_event_label(oldest));
|
||||
}
|
||||
|
||||
if (!level.telemetry_ended)
|
||||
{
|
||||
if (now - level.telemetry_round_tick > 5000)
|
||||
telemetry_log(0, "WARNING: round watcher has stopped advancing");
|
||||
if (level.telemetry_started && now - level.telemetry_last_capture >= 65000)
|
||||
telemetry_log(0, "WARNING: no snapshot captured for " + (now - level.telemetry_last_capture) +
|
||||
"ms; capture_event=" + level.telemetry_capture_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user