Compare commits

...
2 Commits
Author SHA1 Message Date
josh caaaa102a4 Allow solo and last-active-player AFK 2026-10-17 22:19:04 -04:00
josh c19bef6dc6 Add proximity-based map record reminders 2026-10-17 21:54:13 -04:00
15 changed files with 853 additions and 174 deletions
+71
View File
@@ -6,6 +6,77 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-17 ## 2026-10-17
### Allow solo and last-active-player AFK
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Remove the teammate requirement before reservation and after start
authorization on both engines. Solo players, the last active player and
players whose teammates are already AFK or awaiting authorization can enter.
- Remove the AFK team watcher that ended matches when no non-AFK survivor
remained. All players may be AFK together, and teammate death/disconnect
does not cancel protection. Keep reserve accounting, the local deadline,
cooldown, player-state checks and normal stock match-end settlement.
- Compatibility: no new dependency requirement. Existing common `1.4.0+`,
telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and
host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same
policy change using their existing engine-specific state checks.
- Validation: 47 AFK script behavior checks passed, including solo entry,
unavailable/pending teammates, teammate loss during authorization, all-AFK
play, solo return/expiration and stock match-end cleanup. The changed T6
script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries.
Portable checks use engine shims; live T5/T6 validation remains required.
No Windows plugin build, live server test or deployment was performed.
- Deployment: copy the updated AFK script for each installed game and restart
the map. No full server-process restart or additional deployment action is
required for this script-only update. See the [AFK guide](docs/afk.md) for
the updated live validation cases.
### Increase map-record reminders as players approach the target
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_leaderboard.gsc` | `1.2.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.2.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_common.gsc` | `1.5.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.5.0` | `t6/scripts/tws_t6_common.gsc` |
- Add matching T5/T6 round-start reminders using the latest usable cached map
record. Show at gaps of 20, 15, 10, 8 and 6 rounds, then every round for the
final five. Show matching/overtake milestones, then stop reminders after the
first overtake for the rest of the match. Milestones distinguish reaching a round
from saving the result after match completion; `.leaders` remains available.
Listen to the stock `start_of_round` event so reminders follow the actual
start, including on T6 where the counter advances during the round break.
Ignore duplicate starts and counter rewinds within the match.
- Common renders a compact record header and one proximity/milestone line for
six seconds, using two nonarchived elements in the existing service area.
Keep full join/`.leaders` announcements, all tied holders and 12-second pages.
Skip pending or active help/records, unready players and recent welcome/manual
record requests; opening help discards a reminder. Release elements on idle,
interruption, disconnect and game end. Both games work without optional HUD.
- Reuse the cache without round-triggered HTTP requests. Empty, unavailable or
expired records suppress reminders; skipped reminders are not queued.
- Compatibility: leaderboard `1.2.0` requires matching common `1.5.0+`.
There is no new native-plugin, backend or upstream dependency requirement.
Unchanged HUD, AFK and other scripts retain their individual versions.
- Validation: 169 leaderboard, 14 core HUD, 72 service HUD, 42 bank, 17 join,
23 telemetry and 39 AFK portable checks passed, as did both moderation
script checks. All nine T6 scripts compiled/assembled with upstream gsc-tool
`8f1d6bd` libraries; the final changed common/leaderboard sources were rechecked
after the event-timing fix. JSON uniqueness, all 18 script manifest entries,
version/changelog consistency, local documentation links and `git diff --check`
passed. Portable checks use engine shims; no live Plutonium validation or
backend integration was performed. T5 has no gsc-tool compiler backend.
- Deployment: copy the updated leaderboard and common scripts together for
each installed game, then restart the map. No full server-process restart is
required for this script-only update. No deployment performed. Live T5/T6
rendering, readiness and round-transition validation remain required; see
[record validation](README.md#map-record-announcements).
### Persistent AFK reserve and immediate expiration (Refs #12) ### Persistent AFK reserve and immediate expiration (Refs #12)
| Component | Version | Affected source | | Component | Version | Affected source |
+59 -8
View File
@@ -409,14 +409,18 @@ followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. Help pauses a visible record and resumes its remaining 12 seconds per page. Help pauses a visible record and resumes its remaining
page time when help ends. `.leaders` during help replaces the pending record page time when help ends. `.leaders` during help replaces the pending record
with a fresh replay from page one. One service is visible at a time. Services with a fresh replay from page one. Round-start reminders use just the record
remain private and work when the HUD module is omitted. header and one proximity or milestone line for six seconds. They skip busy
services; opening help discards a reminder instead of resuming it later.
One service is visible at a time. Services remain private and work when the
HUD module is omitted.
Both games allocate only the active service view and release it when idle or Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's before switching between help and records. Pages reuse the active view's
elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements counter; records use four, or five with a multi-page counter. Hidden elements
still occupy slots. The controller and pending requests are separate from still occupy slots. Compact reminders use two nonarchived elements.
The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements. display lifetime, so an idle service holds no HUD elements.
HUD `1.4.2` requires matching common `1.4.0+` and HUD `1.4.2` requires matching common `1.4.0+` and
@@ -426,7 +430,8 @@ avoiding dynamic counter strings in the text config-string pool. Numeric HP
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
the map; no full server-process restart is required. On older Plutonium the map; no full server-process restart is required. On older Plutonium
revisions, update clients and server and fully restart them before deploying. revisions, update clients and server and fully restart them before deploying.
Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires plugin `1.4.0+`, Leaderboard `1.2.0` requires matching common `1.5.0+` for round-start reminders.
AFK `2.0.0` requires plugin `1.4.0+`,
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
leaderboard plugin requirements still apply. Common retains its older panel leaderboard plugin requirements still apply. Common retains its older panel
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
@@ -435,8 +440,9 @@ with the same integer shader-width handling.
### HUD validation ### HUD validation
Run `node tools/check_hud.mjs` for portable pagination, service arbitration, Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
request replacement and cleanup checks, and `node tools/check_leaderboard.mjs` reminder rendering, request replacement and cleanup checks, and
for record data handling. Run `node tools/check_bank.mjs`, `node tools/check_leaderboard.mjs` for record data handling and reminder cadence.
Run `node tools/check_bank.mjs`,
`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged `node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged
command/lifecycle regressions. These command/lifecycle regressions. These
checks use engine shims and do not prove GSC VM behavior. Follow the checks use engine shims and do not prove GSC VM behavior. Follow the
@@ -488,7 +494,10 @@ Before rollout, validate both engines in Plutonium:
## Map record announcements ## Map record announcements
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6, Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`. **along with common `1.5.0+`** for that game when using leaderboard `1.2.0`.
Copy both scripts for each installed game and restart the map. This update
adds no native-plugin or backend requirement and needs no full server-process
restart when existing leaderboard dependencies are already installed.
The module privately announces the current map's fleet-wide highest recorded The module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers. round and every tied holder once per connection, including spectator newcomers.
Type `.leaders` to replay the record privately from the latest usable cache. Type `.leaders` to replay the record privately from the latest usable cache.
@@ -530,6 +539,32 @@ the announcement finishes. Both games release their view elements when idle or
changing service kinds. Disconnect or game end cleans up any remaining service changing service kinds. Disconnect or game end cleans up any remaining service
elements. elements.
At round start, reminders become more frequent as the current round approaches
the latest usable cached record. The cadence is measured back from that record:
| Rounds below the record | Reminder rounds |
| --- | --- |
| More than 20 | None |
| 11–20 | At gaps of 20 and 15 rounds |
| 6–10 | At gaps of 10, 8 and 6 rounds |
| 1–5 | Every round |
| Record reached | Matching-round milestone |
| First round above the record | Overtake milestone, then stop for the match |
For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and
45–49, with milestones at 50 and 51. Each uses the existing record header and
one short proximity or milestone line for six seconds, without holder pages.
Milestones describe the round reached; the new result is recorded only after
the match ends. The full join announcement and `.leaders` retain all holders
and their 12-second pages. `.leaders` remains available after the overtake.
Reminders require player readiness and skip players with a pending welcome,
help or record view, or a welcome/manual record request in the last 30 seconds.
Skipped reminders are not queued. Opening help discards an active reminder.
Empty, unavailable and expired snapshots produce no reminders. The existing
cache worker supplies all record data; round starts do not trigger HTTP requests.
Both games use the same behavior, including with the optional HUD omitted.
One level worker fetches the map list and pages of three players, stopping when One level worker fetches the map list and pages of three players, stopping when
the highest-round tie ends. Successful snapshots refresh every five minutes. the highest-round tie ends. Successful snapshots refresh every five minutes.
Failures retry after 30 seconds and retain the last complete snapshot for up to Failures retry after 30 seconds and retain the last complete snapshot for up to
@@ -562,6 +597,19 @@ page: each should start one private announcement from the first page. Repeat
while help is open, then disconnect or end the game during a replay to check while help is open, then disconnect or end the game during a replay to check
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help. cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
For reminders, use a known record on each engine and advance through all cadence
boundaries, the matching round and the first higher round. Confirm later rounds
remain quiet while `.leaders` still works, including after a cache refresh.
Check a record not divisible by five to verify the cadence follows the record.
Keep help or a full record open at an eligible round, join immediately before
that round, and request `.leaders` within 30 seconds of it; no reminder should
replace those views or appear later as a queued message. Open help during a
reminder and confirm it does not resume. Verify six-second dismissal, two-element
cleanup on disconnect/game end, and the same behavior without HUD installed.
Repeat with an empty or expired cache and compare request logs: eligible rounds
must add no HTTP traffic. Live T5/T6 rendering and round-transition validation
remain required even when portable checks and T6 compilation pass.
## Telemetry authentication (T5 and T6) ## Telemetry authentication (T5 and T6)
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
@@ -613,7 +661,10 @@ never reused. Native plugin updates require a full game-server process restart.
## Persistent AFK reserves (T5 and T6) ## Persistent AFK reserves (T5 and T6)
See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior, See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior,
recovery, coordinated EC deployment and the required live validation. AFK `2.0.0` recovery, coordinated EC deployment and the required live validation. AFK `2.1.0`
requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`, requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`,
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
is required when installing the updated plugins. is required when installing the updated plugins.
Solo and last-active-player AFK are allowed, including all players being AFK
together. Upgrading only the AFK scripts from `2.0.0` requires a map restart,
with no new dependencies or full server-process restart.
+19 -12
View File
@@ -30,18 +30,19 @@ immediately, including in the damage callback; the script restores its own contr
weapons and zombie-targeting state. Expiration has no spectator transition, purchase weapons and zombie-targeting state. Expiration has no spectator transition, purchase
or extra grace. Repeating `.afk` does not restart the timer or reserve twice. or extra grace. Repeating `.afk` does not restart the timer or reserve twice.
Entry rejects downed/dead/spectating players, stock protection/control restrictions, AFK `2.1.0` allows solo players and the last active player to enter; all players
and the last active player. A default 15-second reentry cooldown prevents repeated may be AFK at the same time. Entry still rejects downed/dead/spectating players
and stock protection/control restrictions. A default 15-second reentry cooldown prevents repeated
combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage
callback passes through when it does not own protection, preserving stock game callback passes through when it does not own protection, preserving stock game
invulnerability. Other mods that restrict controls should set invulnerability. Other mods that restrict controls should set
`player.tws_afk_external_controls` while their restriction applies. `player.tws_afk_external_controls` while their restriction applies.
AFK leaves player session state unchanged, so stock player-count zombie scaling AFK leaves player session state unchanged, so stock player-count zombie scaling
continues to include AFK players. If the last non-AFK active player dies or leaves, continues to include AFK players. Losing the last non-AFK teammate does not end
the scripts release AFK sessions and send the stock end-game signal. Stock afterlife, protected breaks or force a match end. Authorized time continues to count down;
`no_end_game_check` and Grief reset recovery defer this behavior. A lobby with no normal expiration and stock match-end cleanup still apply. Verify these rules
AFK player is unaffected. Verify these rules on each deployed map/mode. on each deployed map/mode.
## Rewards ## Rewards
@@ -90,13 +91,17 @@ Recheck callback contracts if the stock runtime changes.
2. Update all EC host agents to **1.12.0+** before enabling these scripts. This 2. Update all EC host agents to **1.12.0+** before enabling these scripts. This
adds AFK settlement before deleting/rebuilding stopped runtime storage. adds AFK settlement before deleting/rebuilding stopped runtime storage.
3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK 3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK
**2.0.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and **2.1.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and
Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes. Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes.
4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must 4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must
establish the current match before AFK commands can be authorized. establish the current match before AFK commands can be authorized.
5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory, 5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory,
on persistent writable storage with one process per server/endpoint scope. on persistent writable storage with one process per server/endpoint scope.
Upgrading AFK scripts from `2.0.0` to `2.1.0` adds no dependency requirement:
copy the matching script for each game and restart the map. No full server-process
restart is required for this script-only update.
| Game dvar | Default | Meaning | | Game dvar | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `tws_afk_enabled` | enabled | `0` disables new AFK service startup | | `tws_afk_enabled` | enabled | `0` disables new AFK service startup |
@@ -127,7 +132,7 @@ process crash can lose progress that had not yet reached the local journal.
Already accepted rewards and journaled snapshots persist. Already accepted rewards and journaled snapshots persist.
Protection starts only after reservation and start acknowledgments, a final Protection starts only after reservation and start acknowledgments, a final
player/lobby recheck, and a durable native clock start. Once authorized, an EC player-state recheck, and a durable native clock start. Once authorized, an EC
outage cannot shorten protection; a local monotonic deadline still ends it at zero. outage cannot shorten protection; a local monotonic deadline still ends it at zero.
Manual return, disconnect, death and match end stop accounting immediately. A Manual return, disconnect, death and match end stop accounting immediately. A
failed stop-journal write freezes the original stop time for retry rather than failed stop-journal write freezes the original stop time for retry rather than
@@ -182,14 +187,16 @@ Before production activation, validate **both engines**:
Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and
restart. Verify never-started refunds, durable exact stops and unconfirmed crash restart. Verify never-started refunds, durable exact stops and unconfirmed crash
holds. Correct only a proven stopped session and confirm the ledger audit. holds. Correct only a proven stopped session and confirm the ledger audit.
- Reject the last active player's entry. Kill/disconnect the last active teammate - Enter as the only connected player and as the last active player with downed,
while another is AFK; validate normal end reporting and all AFK settlement. dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the
Exercise stock recovery exceptions and confirm expected zombie scaling. last active teammate, including while reserve/start replies are pending; verify
protection continues without a forced match end. Check solo return/expiration,
normal stock match-end settlement, afterlife/Grief recovery and zombie scaling.
- Rebuild/remove with journals from both games, retire the instance credential, - Rebuild/remove with journals from both games, retire the instance credential,
interrupt the agent after a lost reply, and retry. Runtime deletion must wait for interrupt the agent after a lost reply, and retry. Runtime deletion must wait for
acknowledged recovery and cannot authorize a new purchase. acknowledged recovery and cannot authorize a new purchase.
Validation performed for this change: 39 AFK behavior checks, 14 core HUD and Validation performed for the initial `2.0.0` change: 39 AFK behavior checks, 14 core HUD and
56 service HUD checks passed. All nine T6 scripts compiled and assembled with 56 service HUD checks passed. All nine T6 scripts compiled and assembled with
the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306) the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306)
using its T6 PC compiler/assembler library in a temporary local driver. No Windows using its T6 PC compiler/assembler library in a temporary local driver. No Windows
+23 -6
View File
@@ -16,7 +16,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends | | [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round | | [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | | [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection | | [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands | | [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ | | [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
@@ -89,8 +89,8 @@ the installed command list in pages of nine rows in aligned rows. Services remai
core in place and work without the HUD module installed. See the core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page (20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements. requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
@@ -99,13 +99,16 @@ HUD `1.4.2` requires common `1.4.0+` and
`SetTextUnlimited` counter, updated only when the count changes. Deploy the `SetTextUnlimited` counter, updated only when the count changes. Deploy the
updated HUD script and restart the map on r5334+; no full server-process updated HUD script and restart the map on r5334+; no full server-process
restart is required. Older Plutonium revisions need client/server updates and restart is required. Older Plutonium revisions need client/server updates and
full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching full restarts first. Leaderboard `1.2.0` requires common `1.5.0+` for its
round-start reminders. AFK `2.0.0` requires the matching
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md). plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
## Map record announcements ## Map record announcements
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
`storage/t5/scripts/sp/`, then restart the map. Leaderboard requires a `storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.2.0` requires
common `1.5.0+`; this script update adds no native-plugin or backend dependency
and requires no full server-process restart. Leaderboard requires a
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing [t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade `http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
requires a full server-process restart. To omit leaderboard, remove its script; requires a full server-process restart. To omit leaderboard, remove its script;
@@ -117,6 +120,16 @@ the same private announcement as playable joins. T5 matches its engine map to
the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements) the API's map list. [Shared configuration, caching and validation](../../README.md#map-record-announcements)
apply to both games. apply to both games.
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
6 rounds, then every round for the final five, followed by matching/overtake
milestones. After the first overtake, reminders stop for that match; `.leaders`
still works. A reminder shows the record and one short status line for six
seconds. Join and manual announcements keep all holders and 12-second pages.
Reminders skip busy help/record views, unready players, recent welcome/manual
requests and empty/unavailable/expired records. Help discards a reminder instead
of pausing it. No extra HTTP requests are made. Follow the shared live checks
for cadence, interruption, cleanup and operation without the optional HUD.
## Player commands ## Player commands
Type `.help` or `.commands` in chat to see available player commands and a short description. Type `.help` or `.commands` in chat to see available player commands and a short description.
@@ -327,7 +340,11 @@ Chat uses `level waittill("say")`; the independent central countdown stays visib
with `.hud` hidden. Activation rejects laststand before changing state. A stock with `.hud` hidden. Activation rejects laststand before changing state. A stock
damage callback owns only AFK protection; expiration immediately relinquishes it damage callback owns only AFK protection; expiration immediately relinquishes it
and AFK-owned controls/targeting, while manual `.back` keeps its separate two-second and AFK-owned controls/targeting, while manual `.back` keeps its separate two-second
grace. Spawn handler `::afk_spawn` binds contribution and lifecycle watchers. grace. AFK `2.1.0` permits solo and last-active-player entry, including all players
being AFK together; teammate loss does not force a match end. Upgrade from `2.0.0`
by copying the script and restarting the map; no new dependencies or full
server-process restart are required. Spawn handler `::afk_spawn` binds contribution
and lifecycle watchers.
--- ---
+1 -60
View File
@@ -45,7 +45,6 @@ init()
level thread afk_startup(); level thread afk_startup();
level thread afk_end_watcher(); level thread afk_end_watcher();
level thread afk_round_watcher(); level thread afk_round_watcher();
level thread afk_team_watcher();
} }
afk_startup() afk_startup()
@@ -408,11 +407,6 @@ afk_activate(player, state)
afk_say(state, "Wait briefly after returning before entering AFK again."); afk_say(state, "Wait briefly after returning before entering AFK again.");
return; return;
} }
if (afk_other_active(player) == 0)
{
afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight.");
return;
}
data = afk_base(state, "reserve"); data = afk_base(state, "reserve");
data["session_id"] = telemetryNewMatch(); data["session_id"] = telemetryNewMatch();
data["seconds"] = 604800; data["seconds"] = 604800;
@@ -459,7 +453,7 @@ afk_activate(player, state)
reply = afk_http("start", start_body, start["request_id"]); reply = afk_http("start", start_body, start["request_id"]);
if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session ||
reply["authorized_seconds"] != authorized || !state.connected || reply["authorized_seconds"] != authorized || !state.connected ||
state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) state.cancelled || !afk_can_enter(player))
{ {
afk_stop_state(state, "never_started"); afk_stop_state(state, "never_started");
afk_say(state, "AFK entry cancelled. No protected time used."); afk_say(state, "AFK entry cancelled. No protected time used.");
@@ -710,59 +704,6 @@ afk_end_watcher()
level.afk_ended = true; level.afk_ended = true;
} }
afk_other_active(excluded)
{
count = 0;
players = getPlayers();
for (i = 0; i < players.size; i++)
{
player = players[i];
if (isDefined(excluded) && player == excluded)
continue;
if (!afk_playing(player) || is_true(player.hud_afk_on))
continue;
if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending)
continue;
count++;
}
return count;
}
afk_team_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
for (;;)
{
players = getPlayers();
protected = 0;
survivors = 0;
stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief);
for (i = 0; i < players.size; i++)
{
player = players[i];
if (is_true(player.afterlife))
stock_recovery = true;
if (is_true(player.hud_afk_on))
protected++;
else if (afk_playing(player))
survivors++;
}
if (protected > 0 && survivors == 0 && !stock_recovery)
{
for (i = 0; i < players.size; i++)
if (is_true(players[i].hud_afk_on))
players[i] afk_finish("match_end", false);
// AFK does not change sessionstate/player count or zombie scaling.
// The verified stock end signal prevents AFK bodies prolonging play.
level notify("end_game");
return;
}
wait 0.05;
}
}
// Both stock engines notify the credited attacker, with the actual dead zombie. // Both stock engines notify the credited attacker, with the actual dead zombie.
// Never infer contributions from spendable score, score_total, or bank actions. // Never infer contributions from spendable score, score_total, or bank actions.
afk_kill_watcher() afk_kill_watcher()
+38 -1
View File
@@ -170,6 +170,9 @@ show_help()
self service_ensure(); self service_ensure();
state = self.tws_service; state = self.tws_service;
service_advance(state, getTime()); service_advance(state, getTime());
// Round-start notices are timely hints, not requests to resume after help.
if (isDefined(state.record) && state.record.kind == "reminder")
state.record = undefined;
state.generation++; state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation); state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state); service_render(state);
@@ -192,6 +195,29 @@ show_record(snapshot)
service_render(state); service_render(state);
} }
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows) service_page_count(count, rows)
{ {
return int((max(count, 1) + rows - 1) / rows); return int((max(count, 1) + rows - 1) / rows);
@@ -205,6 +231,8 @@ service_request(kind, pages, generation)
request.page = 0; request.page = 0;
request.pages = pages; request.pages = pages;
request.remaining = 12000; request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request; return request;
} }
@@ -351,7 +379,10 @@ service_view_create(state, request)
state.record_title = service_text(self, state, level.hud_service_right, state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count); level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++) rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right, state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch, level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label); "right", 1.05, "", level.hud_c_label);
@@ -467,6 +498,12 @@ service_render(state)
state.record_title.alpha = 1; state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0) if (!isDefined(snapshot) || snapshot.round == 0)
return; return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++) for (i = 0; i < 3; i++)
{ {
index = page * 3 + i; index = page * 3 + i;
+86 -1
View File
@@ -1,4 +1,4 @@
/* tws_t5_leaderboard.gsc - private map record announcements and .leaders replay. */ /* tws_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\_utility; #include maps\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -24,10 +24,12 @@ init()
level.tws_lb_pending = undefined; level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined; level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0; level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again"); scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome); scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener(); level thread lb_chat_listener();
level thread lb_worker(); level thread lb_worker();
level thread lb_round_watcher();
} }
lb_worker() lb_worker()
@@ -64,6 +66,88 @@ lb_worker()
} }
} }
lb_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = 0;
for (;;)
{
// T6 advances its counter before the between-round pause is over.
// Both engines notify the actual start; never synthesize a late start.
level waittill("start_of_round");
if (!isDefined(level.round_number) || level.round_number <= last_round)
continue;
last_round = level.round_number;
lb_round_start(last_round);
}
}
lb_round_start(round)
{
if (isDefined(level.tws_lb_chase_finished))
return;
snapshot = lb_cached_record();
if (!isDefined(snapshot) || snapshot.round < 2)
return;
message = lb_round_message(round, snapshot.round);
if (message == "")
return;
// Celebrate at most once, even if a later refresh raises the fleet record.
// Busy clients skip the milestone too; no delayed mid-round announcements.
if (round > snapshot.round)
level.tws_lb_chase_finished = true;
players = getPlayers();
for (i = 0; i < players.size; i++)
players[i] lb_remind_player(snapshot, message);
}
lb_round_message(round, record)
{
if (round < 1 || record < 2)
return "";
gap = record - round;
if (gap < 0)
return "Recorded high round passed!";
if (gap == 0)
return "Record round reached!";
if (gap > 20)
return "";
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
if (gap > 10 && gap % 5 != 0)
return "";
if (gap > 5 && gap <= 10 && gap % 2 != 0)
return "";
if (gap == 1)
return "1 round away from matching it";
return gap + " rounds away from matching it";
}
lb_player_ready(player)
{
// A welcome attempt (even with no record) or manual request releases this
// gate. Give that full announcement at least 30 seconds before a reminder.
if (!isDefined(player.tws_lb_last_request) ||
getTime() - player.tws_lb_last_request < 30000)
return false;
if (!isDefined(player.sessionstate) ||
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
return false;
if (isDefined(player.hasSpawned) && !player.hasSpawned)
return false;
return true;
}
lb_remind_player(snapshot, message)
{
if (!lb_player_ready(self))
return;
self scripts\sp\tws_t5_common::show_record_reminder(snapshot, message);
}
lb_welcome() lb_welcome()
{ {
self endon("disconnect"); self endon("disconnect");
@@ -128,6 +212,7 @@ lb_request(manual)
// This also applies when the cache is unavailable. // This also applies when the cache is unavailable.
if (manual) if (manual)
self notify("tws_lb_requested"); self notify("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record(); snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual) if (!isDefined(snapshot) && !manual)
return; return;
+24 -5
View File
@@ -13,7 +13,7 @@ private server token.
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD | | `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_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_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 1.2.0+, tws-t6-gsc-utils | | `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 | | `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ | | `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
@@ -102,8 +102,8 @@ the installed command list in pages of nine rows in aligned rows. Services remai
core in place and work without the HUD module installed. See the core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter; the standard AFK command set uses multiple pages), or four for records (five with a multi-page (20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements. requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
@@ -112,7 +112,8 @@ HUD `1.4.2` requires common `1.4.0+` and
`SetTextUnlimited` counter, updated only when the count changes. Deploy the `SetTextUnlimited` counter, updated only when the count changes. Deploy the
updated HUD script and restart the map on r5334+; no full server-process updated HUD script and restart the map on r5334+; no full server-process
restart is required. Older Plutonium revisions need client/server updates and restart is required. Older Plutonium revisions need client/server updates and
full restarts first. Leaderboard `1.1.0` remains compatible. AFK `2.0.0` requires the matching full restarts first. Leaderboard `1.2.0` requires common `1.5.0+` for its
round-start reminders. AFK `2.0.0` requires the matching
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md). plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
## Map record announcements ## Map record announcements
@@ -121,6 +122,21 @@ plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
It works independently of telemetry and HUD, and privately announces records It works independently of telemetry and HUD, and privately announces records
once per connection, including newcomers waiting in spectator mode. once per connection, including newcomers waiting in spectator mode.
Leaderboard `1.2.0` requires common `1.5.0+`. Copy both scripts to
`Plutonium/storage/t6/scripts/zm/` and restart the map. This script update adds
no native-plugin or backend dependency and requires no full server-process
restart when the existing plugin is installed.
Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
6 rounds, then every round for the final five, followed by matching/overtake
milestones. After the first overtake, reminders stop for that match; `.leaders`
still works. A reminder shows the record and one short status line for six
seconds. Join and manual announcements keep all holders and 12-second pages.
Reminders skip busy help/record views, unready players, recent welcome/manual
requests and empty/unavailable/expired records. Help discards a reminder instead
of pausing it. No extra HTTP requests are made. Follow the shared live checks
for cadence, interruption, cleanup and operation without the optional HUD.
Map selection uses stock `level.scr_zm_ui_gametype` and Map selection uses stock `level.scr_zm_ui_gametype` and
`level.scr_zm_map_start_location`, falling back to the corresponding `level.scr_zm_map_start_location`, falling back to the corresponding
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example, `ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
@@ -284,7 +300,10 @@ configuration, persistence, action results and deployment checks.
## Persistent AFK reserve ## Persistent AFK reserve
AFK `2.0.0` uses EC authorization and a local central countdown. See the AFK `2.1.0` uses EC authorization and a local central countdown. See the
[shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery [shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery
and live checks. Install plugin `1.4.0+` and fully restart the server process. and live checks. Install plugin `1.4.0+` and fully restart the server process.
The countdown remains visible with `.hud` hidden; expiration has no grace period. The countdown remains visible with `.hud` hidden; expiration has no grace period.
Solo and last-active-player entry are allowed, including all players being AFK
together; teammate loss does not force a match end. Upgrading from `2.0.0` adds
no dependencies and requires only copying the script and restarting the map.
+1 -60
View File
@@ -43,7 +43,6 @@ init()
level thread afk_startup(); level thread afk_startup();
level thread afk_end_watcher(); level thread afk_end_watcher();
level thread afk_round_watcher(); level thread afk_round_watcher();
level thread afk_team_watcher();
} }
afk_startup() afk_startup()
@@ -405,11 +404,6 @@ afk_activate(player, state)
afk_say(state, "Wait briefly after returning before entering AFK again."); afk_say(state, "Wait briefly after returning before entering AFK again.");
return; return;
} }
if (afk_other_active(player) == 0)
{
afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight.");
return;
}
data = afk_base(state, "reserve"); data = afk_base(state, "reserve");
data["session_id"] = telemetryNewMatch(); data["session_id"] = telemetryNewMatch();
data["seconds"] = 604800; data["seconds"] = 604800;
@@ -456,7 +450,7 @@ afk_activate(player, state)
reply = afk_http("start", start_body, start["request_id"]); reply = afk_http("start", start_body, start["request_id"]);
if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session ||
reply["authorized_seconds"] != authorized || !state.connected || reply["authorized_seconds"] != authorized || !state.connected ||
state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) state.cancelled || !afk_can_enter(player))
{ {
afk_stop_state(state, "never_started"); afk_stop_state(state, "never_started");
afk_say(state, "AFK entry cancelled. No protected time used."); afk_say(state, "AFK entry cancelled. No protected time used.");
@@ -707,59 +701,6 @@ afk_end_watcher()
level.afk_ended = true; level.afk_ended = true;
} }
afk_other_active(excluded)
{
count = 0;
players = getPlayers();
for (i = 0; i < players.size; i++)
{
player = players[i];
if (isDefined(excluded) && player == excluded)
continue;
if (!afk_playing(player) || is_true(player.hud_afk_on))
continue;
if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending)
continue;
count++;
}
return count;
}
afk_team_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
for (;;)
{
players = getPlayers();
protected = 0;
survivors = 0;
stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief);
for (i = 0; i < players.size; i++)
{
player = players[i];
if (is_true(player.afterlife))
stock_recovery = true;
if (is_true(player.hud_afk_on))
protected++;
else if (afk_playing(player))
survivors++;
}
if (protected > 0 && survivors == 0 && !stock_recovery)
{
for (i = 0; i < players.size; i++)
if (is_true(players[i].hud_afk_on))
players[i] afk_finish("match_end", false);
// AFK does not change sessionstate/player count or zombie scaling.
// The verified stock end signal prevents AFK bodies prolonging play.
level notify("end_game");
return;
}
wait 0.05;
}
}
// Both stock engines notify the credited attacker, with the actual dead zombie. // Both stock engines notify the credited attacker, with the actual dead zombie.
// Never infer contributions from spendable score, score_total, or bank actions. // Never infer contributions from spendable score, score_total, or bank actions.
afk_kill_watcher() afk_kill_watcher()
+38 -1
View File
@@ -166,6 +166,9 @@ show_help()
self service_ensure(); self service_ensure();
state = self.tws_service; state = self.tws_service;
service_advance(state, getTime()); service_advance(state, getTime());
// Round-start notices are timely hints, not requests to resume after help.
if (isDefined(state.record) && state.record.kind == "reminder")
state.record = undefined;
state.generation++; state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation); state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state); service_render(state);
@@ -188,6 +191,29 @@ show_record(snapshot)
service_render(state); service_render(state);
} }
// Automatic round-start hints yield to any existing service and never queue.
show_record_reminder(snapshot, message)
{
if (isDefined(level.tws_service_ended) || !isDefined(snapshot) ||
!isDefined(snapshot.round) || snapshot.round <= 0 ||
!isDefined(message) || message == "")
return false;
self service_ensure();
state = self.tws_service;
if (state.closed)
return false;
service_advance(state, getTime());
if (isDefined(state.help) || isDefined(state.record))
return false;
state.generation++;
state.record = service_request("reminder", 1, state.generation);
state.record.snapshot = snapshot;
state.record.message = message;
service_render(state);
return true;
}
service_page_count(count, rows) service_page_count(count, rows)
{ {
return int((max(count, 1) + rows - 1) / rows); return int((max(count, 1) + rows - 1) / rows);
@@ -201,6 +227,8 @@ service_request(kind, pages, generation)
request.page = 0; request.page = 0;
request.pages = pages; request.pages = pages;
request.remaining = 12000; request.remaining = 12000;
if (kind == "reminder")
request.remaining = 6000;
return request; return request;
} }
@@ -347,7 +375,10 @@ service_view_create(state, request)
state.record_title = service_text(self, state, level.hud_service_right, state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count); level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++) rows = 3;
if (request.kind == "reminder")
rows = 1;
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right, state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch, level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label); "right", 1.05, "", level.hud_c_label);
@@ -463,6 +494,12 @@ service_render(state)
state.record_title.alpha = 1; state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0) if (!isDefined(snapshot) || snapshot.round == 0)
return; return;
if (request.kind == "reminder")
{
state.rows[0] SetText(request.message);
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++) for (i = 0; i < 3; i++)
{ {
index = page * 3 + i; index = page * 3 + i;
+86 -1
View File
@@ -1,4 +1,4 @@
/* tws_t6_leaderboard.gsc - private map record announcements and .leaders replay. */ /* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
#include maps\mp\_utility; #include maps\mp\_utility;
#include common_scripts\utility; #include common_scripts\utility;
@@ -26,10 +26,12 @@ init()
level.tws_lb_pending = undefined; level.tws_lb_pending = undefined;
level.tws_lb_cache = undefined; level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0; level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again"); scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome); scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener(); level thread lb_chat_listener();
level thread lb_worker(); level thread lb_worker();
level thread lb_round_watcher();
} }
lb_worker() lb_worker()
@@ -66,6 +68,88 @@ lb_worker()
} }
} }
lb_round_watcher()
{
level endon("end_game");
level endon("game_ended");
level endon("intermission");
last_round = 0;
for (;;)
{
// T6 advances its counter before the between-round pause is over.
// Both engines notify the actual start; never synthesize a late start.
level waittill("start_of_round");
if (!isDefined(level.round_number) || level.round_number <= last_round)
continue;
last_round = level.round_number;
lb_round_start(last_round);
}
}
lb_round_start(round)
{
if (isDefined(level.tws_lb_chase_finished))
return;
snapshot = lb_cached_record();
if (!isDefined(snapshot) || snapshot.round < 2)
return;
message = lb_round_message(round, snapshot.round);
if (message == "")
return;
// Celebrate at most once, even if a later refresh raises the fleet record.
// Busy clients skip the milestone too; no delayed mid-round announcements.
if (round > snapshot.round)
level.tws_lb_chase_finished = true;
players = getPlayers();
for (i = 0; i < players.size; i++)
players[i] lb_remind_player(snapshot, message);
}
lb_round_message(round, record)
{
if (round < 1 || record < 2)
return "";
gap = record - round;
if (gap < 0)
return "Recorded high round passed!";
if (gap == 0)
return "Record round reached!";
if (gap > 20)
return "";
// Anchor the cadence to the target, not to round numbers divisible by 5/2.
if (gap > 10 && gap % 5 != 0)
return "";
if (gap > 5 && gap <= 10 && gap % 2 != 0)
return "";
if (gap == 1)
return "1 round away from matching it";
return gap + " rounds away from matching it";
}
lb_player_ready(player)
{
// A welcome attempt (even with no record) or manual request releases this
// gate. Give that full announcement at least 30 seconds before a reminder.
if (!isDefined(player.tws_lb_last_request) ||
getTime() - player.tws_lb_last_request < 30000)
return false;
if (!isDefined(player.sessionstate) ||
(player.sessionstate != "playing" && player.sessionstate != "spectator"))
return false;
if (isDefined(level.passed_introscreen) && !level.passed_introscreen)
return false;
return true;
}
lb_remind_player(snapshot, message)
{
if (!lb_player_ready(self))
return;
self scripts\zm\tws_t6_common::show_record_reminder(snapshot, message);
}
lb_welcome() lb_welcome()
{ {
self endon("disconnect"); self endon("disconnect");
@@ -131,6 +215,7 @@ lb_request(manual)
// This also applies when the cache is unavailable. // This also applies when the cache is unavailable.
if (manual) if (manual)
self notify("tws_lb_requested"); self notify("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record(); snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual) if (!isDefined(snapshot) && !manual)
return; return;
+50 -5
View File
@@ -167,11 +167,56 @@ for(const game of ['t5','t6']) {
assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false); assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false);
assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability(')); assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability('));
}); });
test('last active cannot enter; last active death ends only AFK-only matches, respecting afterlife',()=>{ test('solo and last active players can enter with unavailable or pending teammates',()=>{
const alone=engine(game);alone.other.downed=true;alone.enter();assert(!alone.p.hud_afk_on);assert.equal(alone.sent.length,0); for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){
const e=engine(game);e.enter();e.other.alive=false;e.c.afk_team_watcher().next();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'match_end');assert(e.log.some(x=>x[0]==='notify'&&x[1]==='end_game')); const e=engine(game);
const recovering=engine(game);recovering.enter();recovering.other.afterlife=true;recovering.c.afk_team_watcher().next();assert(recovering.p.hud_afk_on); if(flags===null)e.players.splice(1,1);
const noAfk=engine(game);noAfk.p.alive=false;noAfk.other.alive=false;noAfk.c.afk_team_watcher().next();assert(!noAfk.log.some(x=>x[0]==='notify')); else if(flags.pending)e.other.tws_afk_state.pending=true;
else Object.assign(e.other,flags);
e.enter();e.advance(1000);e.tick();
assert(e.p.hud_afk_on);assert.equal(e.p.afk_remaining(),1799000);
assert.equal(e.stops.length,0);assert.equal(e.p.sessionstate,'playing');
assert(!e.log.some(x=>x[0]==='notify'));
}
});
test('teammate death or disconnect during authorization does not cancel entry',()=>{
for(const at of ['reserve','start'])for(const change of ['death','disconnect']){
const e=engine(game,{during:request=>{
if(request.action!==at)return;
if(change==='death')e.other.alive=false;
else e.players.splice(1,1);
}});
e.enter();e.tick();assert(e.p.hud_afk_on);assert.equal(e.stops.length,0);
}
});
test('all players can be AFK and teammate loss does not force a match end',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
e.advance(1000);e.tick();e.other.afk_tick().next();
assert(e.p.hud_afk_on);assert(e.other.hud_afk_on);assert.equal(e.stops.length,0);
e.other.alive=false;e.other.afk_tick().next();e.tick();
assert(e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'death');
e.players.splice(1,1);e.advance(1000);e.tick();assert(e.p.hud_afk_on);
assert(!e.log.some(x=>x[0]==='notify'));
assert(!e.source.includes('afk_team_watcher'));
assert(!/level\s+notify\("end_game"\)/.test(e.source));
});
test('solo protection expires normally and manual return preserves bounded grace',()=>{
for(const manual of [false,true]){
const e=engine(game,{authorized:3});e.players.splice(1,1);e.enter();
e.advance(1000);assert.equal(e.p.afk_damage(null,null,10),0);
if(manual){e.p.afk_exit();assert.equal(e.stops.at(-1).reason,'manual');}
e.advance(2000);e.tick();
assert(!e.p.hud_afk_on);assert(!e.p.tws_afk_protected);assert(!e.p.ignoreme);
assert(!e.p.tws_afk_controls_locked);assert.equal(e.p.afk_damage(null,null,10),-1);
if(!manual)assert.equal(e.stops.at(-1).reason,'expired');
}
});
test('stock match end still releases and settles every AFK player',()=>{
const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state);
const watcher=e.c.afk_end_watcher();watcher.next();watcher.next();
assert(e.level.afk_ended);assert.equal(e.stops.length,2);
for(const p of e.players){assert(!p.hud_afk_on);assert(!p.tws_afk_protected);assert(p.tws_afk_state.cancelled);}
assert(e.stops.every(s=>s.reason==='match_end'));
}); });
test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{ test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next(); const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
+118 -1
View File
@@ -24,7 +24,7 @@ function translate(source) {
const result = []; const result = [];
for (let match; (match = headers.exec(protectedSource));) { for (let match; (match = headers.exec(protectedSource));) {
const name = match[1]; const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue; if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'show_record_reminder', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue; if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1; let end = headers.lastIndex, depth = 1;
while (depth) { while (depth) {
@@ -133,6 +133,7 @@ function engine(game, count = 8, withHud = false) {
}, },
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; }, addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot), help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
event: name => emit(self, name), end: name => emit(level, name), event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; }, get state() { return self.tws_service; },
get live() { return elements.filter(e => !e.destroyCalls); }, get live() { return elements.filter(e => !e.destroyCalls); },
@@ -322,6 +323,120 @@ for (const game of ['t5', 't6']) {
assert(e.state.record, 'previous expiry cannot hide newer request'); assert(e.state.record, 'previous expiry cannot hide newer request');
assert.equal(e.textCalls, texts); assert.equal(e.textCalls, texts);
}); });
check(`${game} automatic reminders use two elements and expire after six seconds`, () => {
const e = engine(game);
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), true);
const request = e.state.record;
assert.equal(request.kind, 'reminder');
assert.equal(request.pages, 1);
assert.equal(request.remaining, 6000);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.rows.length, 1);
assert.equal(e.state.rows[0].text, '5 rounds to tie');
assert.equal(e.live.length, 2);
assert.equal(e.visible.length, 2);
assertCounter(e, 1, 1);
assertTextOnly(e);
const calls = e.textCalls, allocated = e.elements.length;
e.advance(5900);
assert.equal(e.state.record, request);
assert.equal(request.remaining, 100);
assert.equal(e.reminder({ round: 83 }, 'Newer target'), false);
assert.equal(e.state.record, request, 'rejected hints do not replace or restart a visible hint');
assert.equal(request.remaining, 100);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, allocated);
e.advance(100);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
assert.equal(e.reminder({ round: 83 }, '6 rounds to tie'), true);
assert.equal(e.state.record.remaining, 6000);
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 2);
});
check(`${game} automatic reminders never replace or queue behind help and full records`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(1000);
const record = e.state.record;
const generation = e.state.generation, allocations = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000);
assert.equal(e.state.generation, generation);
assert.equal(e.elements.length, allocations);
e.help();
e.advance(1000);
const help = e.state.help, withHelp = e.elements.length;
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.help, help);
assert.equal(e.state.record, record);
assert.equal(e.elements.length, withHelp);
e.advance(11000);
assert.equal(e.state.record, record);
assert.equal(record.remaining, 11000, 'manual record still resumes with its remaining time');
e.advance(23000);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0, 'skipped hints do not appear later');
e.help();
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state.record, undefined, 'help alone must not queue a hint');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} help discards a reminder and manual replay replaces it at full duration`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
const reminderElements = [...e.live];
e.advance(2000);
e.help();
assert.equal(e.state.record, undefined);
assert(reminderElements.every(elem => elem.destroyCalls === 1));
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.live.length, 0, 'an interrupted round hint does not resume after help');
e.reminder({ round: 82 }, '4 rounds to tie');
const replaced = [...e.live];
e.advance(5900);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
assert.equal(e.state.record.kind, 'record');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.record.pages, 2);
assert.equal(e.live.length, 5);
assert(replaced.every(elem => elem.destroyCalls === 1));
e.advance(100);
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
assert.equal(e.state.record.remaining, 11900);
e.advance(11900);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.rows[0].text, 'Four');
e.advance(12000);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
const e = engine(game);
for (const bad of [undefined, {}, { round: 0 }, { round: -1 }])
assert.equal(e.reminder(bad, '5 rounds to tie'), false);
for (const message of [undefined, ''])
assert.equal(e.reminder({ round: 82 }, message), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect'])
check(`${game} ${event} cleans a reminder and blocks reopening`, () => {
const e = engine(game);
e.reminder({ round: 82 }, '5 rounds to tie');
e.advance(1000);
if (event === 'disconnect') e.event(event); else e.end(event);
assert.equal(e.state.closed, true);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
assert.equal(e.reminder({ round: 82 }, '4 rounds to tie'), false);
e.advance(12000);
assert.equal(e.elements.length, 2);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => { check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game); const e = engine(game);
e.record(undefined); e.record(undefined);
@@ -372,6 +487,8 @@ for (const game of ['t5', 't6']) {
check(`${game} ending before first service blocks lazy creation`, () => { check(`${game} ending before first service blocks lazy creation`, () => {
const e = engine(game); const e = engine(game);
e.end('end_game'); e.help(); e.record(snapshot()); e.end('end_game'); e.help(); e.record(snapshot());
assert.equal(e.reminder({ round: 82 }, '5 rounds to tie'), false);
assert.equal(e.state, undefined);
assert.equal(e.elements.length, 0); assert.equal(e.elements.length, 0);
}); });
check(`${game} rendered records cache text and never allocate on timer ticks`, () => { check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
+233 -7
View File
@@ -1,4 +1,4 @@
// Execute the actual data-only GSC functions with small engine-value shims. // Execute actual leaderboard GSC functions with small data, player and clock shims.
// This is a fixture check, not a GSC VM, linker or gameplay test. // This is a fixture check, not a GSC VM, linker or gameplay test.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -34,9 +34,13 @@ function functions(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1); let body = protectedSource.slice(headers.lastIndex, end - 1);
// Each GSC function has its own implicit locals, including recursion. // Each GSC function has its own implicit locals, including recursion.
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1])); const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length').replace(/\bwait\s+[\d.]+;/g, ';'); const coroutine = match[1] === 'lb_round_watcher';
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
if (coroutine)
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]); body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
found.set(match[1], `function ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); found.set(match[1], `function${coroutine ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
} }
return found; return found;
} }
@@ -44,15 +48,17 @@ function functions(source) {
function engine(game) { function engine(game) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8'); const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source); const all = functions(source);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']); const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener',
'lb_request', 'lb_round_watcher', 'lb_round_start', 'lb_remind_player']);
const table = []; const table = [];
for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i; for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i;
const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' }; const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' };
const logs = [], queries = [], responses = []; const logs = [], queries = [], responses = [];
let now = 1000000;
const context = vm.createContext({ const context = vm.createContext({
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table }, level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
GetDvar: (key) => dvars[key] ?? '', GetDvar: (key) => dvars[key] ?? '',
getTime: () => 1000000, getTime: () => now,
spawnStruct: () => ({}), spawnStruct: () => ({}),
isDefined: (value) => value !== undefined && value !== null, isDefined: (value) => value !== undefined && value !== null,
gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array' gscType: (value) => value === undefined || value === null ? 'undefined' : typeof value === 'object' ? 'array'
@@ -69,7 +75,76 @@ function engine(game) {
lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); }, lb_get: (query) => { queries.push(query); assert(responses.length, 'unexpected request'); return responses.shift(); },
}); });
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context); vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), context);
return { context, all, source, dvars, responses, queries, logs }; return { context, all, source, dvars, responses, queries, logs,
get now() { return now; }, setTime(value) { now = value; } };
}
function roundRuntime(game) {
const e = engine(game), c = e.context;
const players = [], attempts = [], shown = [], records = [], notifications = [], endEvents = [];
const current = (player, run) => {
const prior = c.self;
c.self = player;
try { return run(); } finally { c.self = prior; }
};
c.getPlayers = () => players;
c.endon = (entity, event) => {
assert.equal(entity, c.level);
endEvents.push(event);
};
c.playerReminder = (player, snapshot, message) => current(player, () => c.lb_remind_player(snapshot, message));
c.reminderService = (player, snapshot, message) => {
const notice = { player, snapshot, message, round: c.level.round_number };
attempts.push(notice);
if (player.busy) return false;
shown.push(notice);
return true;
};
c.recordService = (player, snapshot) => records.push({ player, snapshot });
c.playerNotify = (player, event) => notifications.push({ player, event });
for (const name of ['lb_round_start', 'lb_remind_player', 'lb_request', 'lb_round_watcher']) {
const code = e.all.get(name)
.replace(/\b(?:self|level) endon\([^;]*\);/g, declaration =>
name === 'lb_round_watcher' ? declaration.replace('level endon(', 'endon(level, ') : '')
.replace(/players\[i\] lb_remind_player\(/g, 'playerReminder(players[i], ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record_reminder\(/g, 'reminderService(self, ')
.replace(/self scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(self, ')
.replace(/self notify\(/g, 'playerNotify(self, ');
vm.runInContext(code, c);
}
let watcher, stopped = false;
const resumeWatcher = () => {
const step = watcher.next();
assert.equal(step.done, false);
assert.equal(step.value.entity, c.level);
assert.equal(step.value.event, 'start_of_round');
};
const startWatcher = () => {
if (watcher) return;
watcher = c.lb_round_watcher();
resumeWatcher();
};
return { ...e, players, attempts, shown, records, notifications, endEvents,
get now() { return e.now; },
readyPlayer(fields = {}) {
const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
players.push(player);
return player;
},
cache(round, saved_at = e.now) {
return (c.level.tws_lb_cache = { round, saved_at, lines: ['Holder'] });
},
round(round) { c.level.round_number = round; c.lb_round_start(round); },
request(player, manual) { return current(player, () => c.lb_request(manual)); },
startWatcher,
startRoundEvent(round) {
c.level.round_number = round;
if (stopped) return;
startWatcher();
resumeWatcher();
},
end(event) { if (endEvents.includes(event)) { stopped = true; watcher.return(); } },
};
} }
function mapOption(game) { function mapOption(game) {
@@ -147,6 +222,157 @@ for (const game of ['t5', 't6']) {
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]); assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
c.level.tws_lb_cache = undefined; c.level.tws_lb_cache = undefined;
}); });
for (const [record, expected] of [
[50, [30, 35, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51]],
[53, [33, 38, 43, 45, 47, 48, 49, 50, 51, 52, 53, 54]],
]) check(`${game} record ${record} reminders accelerate relative to the target`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 1; round <= record + 10; round++) e.round(round);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
assert.equal(e.shown[0].message, '20 rounds away from matching it');
assert.equal(e.shown.at(-3).message, '1 round away from matching it');
assert.equal(e.shown.at(-2).message, 'Record round reached!');
assert.equal(e.shown.at(-1).message, 'Recorded high round passed!');
assert.equal(e.context.level.tws_lb_chase_finished, true);
assert.equal(e.queries.length, 0);
});
check(`${game} low records avoid round-one history and handle short approaches`, () => {
for (const record of [0, 1, 2, 6]) {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(record);
for (let round = 0; round <= record + 4; round++) e.round(round);
const expected = record < 2 ? [] : Array.from({ length: record + 1 }, (_, i) => i + 1);
assert.deepEqual(e.shown.map(notice => notice.round), expected);
}
});
check(`${game} absent, expired and empty caches skip reminders without fetching`, () => {
const e = roundRuntime(game);
e.readyPlayer();
for (const cached of [undefined, { round: 0, saved_at: e.now }, { round: 1, saved_at: e.now },
{ round: 50, saved_at: e.now - 900001 }]) {
e.context.level.tws_lb_cache = cached;
e.round(30); e.round(51);
assert.equal(e.context.level.tws_lb_chase_finished, undefined);
}
assert.equal(e.attempts.length, 0);
const stillUsable = e.cache(50, e.now - 900000);
e.round(30);
assert.equal(e.shown.length, 1);
assert.equal(e.shown[0].snapshot, stillUsable);
assert.equal(e.queries.length, 0);
});
check(`${game} the next round uses the latest cache without a mid-round announcement`, () => {
const e = roundRuntime(game);
e.readyPlayer();
const first = e.cache(50);
e.round(35);
const raised = e.cache(60);
assert.equal(e.shown.length, 1);
e.round(40);
const corrected = e.cache(55);
e.round(45);
assert.deepEqual(e.shown.map(notice => notice.snapshot), [first, raised, corrected]);
assert.deepEqual(e.shown.map(notice => notice.message), [
'15 rounds away from matching it', '20 rounds away from matching it', '10 rounds away from matching it',
]);
assert.equal(e.queries.length, 0);
});
check(`${game} overtaking finishes this match's chase even after cache changes or a busy client`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.round(50); e.round(51); e.round(52);
e.cache(70);
e.round(55); e.round(70); e.round(71);
assert.deepEqual(e.shown.map(notice => notice.round), [50, 51]);
const busy = roundRuntime(game);
const player = busy.readyPlayer({ busy: true });
busy.cache(50); busy.round(51);
assert.equal(busy.attempts.length, 1);
assert.equal(busy.shown.length, 0);
player.busy = false;
busy.round(52);
assert.equal(busy.attempts.length, 1, 'a skipped milestone cannot become a delayed celebration');
});
check(`${game} each client must be ready and thirty seconds past welcome or manual replay`, () => {
const e = roundRuntime(game);
const playing = e.readyPlayer();
const spectator = e.readyPlayer({ sessionstate: 'spectator' });
e.readyPlayer({ tws_lb_last_request: undefined });
e.readyPlayer({ tws_lb_last_request: e.now - 29999 });
for (const sessionstate of [undefined, 'connecting', 'dead'])
e.readyPlayer({ sessionstate });
e.cache(50); e.round(45);
assert.deepEqual(e.shown.map(notice => notice.player), [playing, spectator]);
assert.equal(e.context.lb_player_ready({ ...playing, tws_lb_last_request: e.now - 30000 }), true);
});
check(`${game} welcome attempts and manual requests reset the reminder cooldown`, () => {
const e = roundRuntime(game);
const player = e.readyPlayer({ tws_lb_last_request: undefined });
e.request(player, false);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 0, 'missing initial cache still releases the welcome readiness gate');
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1);
assert.equal(e.context.lb_player_ready(player), true);
e.cache(50); e.round(45);
e.request(player, true);
assert.equal(player.tws_lb_last_request, e.now);
assert.equal(e.records.length, 1);
assert.equal(e.notifications[0].event, 'tws_lb_requested');
e.round(46);
assert.equal(e.shown.length, 1);
e.setTime(e.now + 29999);
assert.equal(e.context.lb_player_ready(player), false);
e.setTime(e.now + 1); e.round(47);
assert.deepEqual(e.shown.map(notice => notice.round), [45, 47]);
});
check(`${game} reminder readiness follows the engine's spectator/introscreen rules`, () => {
const e = roundRuntime(game), c = e.context;
const player = e.readyPlayer({ sessionstate: 'spectator', hasSpawned: false });
if (game === 't5') {
assert.equal(c.lb_player_ready(player), false);
player.hasSpawned = true;
assert.equal(c.lb_player_ready(player), true);
delete player.hasSpawned;
assert.equal(c.lb_player_ready(player), true);
} else {
assert.equal(c.lb_player_ready(player), true, 'T6 spectator newcomers keep hasSpawned false');
c.level.passed_introscreen = false;
assert.equal(c.lb_player_ready(player), false);
c.level.passed_introscreen = true;
assert.equal(c.lb_player_ready(player), true);
}
});
check(`${game} watcher waits for real starts and suppresses repeats, decreases and skipped starts`, () => {
const e = roundRuntime(game), observed = [];
e.context.lb_round_start = round => observed.push(round);
e.context.level.round_number = 30;
e.startWatcher();
assert.deepEqual(observed, [], 'loading mid-round must not synthesize an announcement');
e.context.level.round_number = 31;
assert.deepEqual(observed, [], 'a counter change before the actual start must not announce');
for (const round of [undefined, -1, 31, 31, 29, 30, 31, 34, 34]) e.startRoundEvent(round);
assert.deepEqual(observed, [31, 34], 'a skipped round emits only the newly observed start');
const fresh = roundRuntime(game), initial = [];
fresh.context.lb_round_start = round => initial.push(round);
fresh.startRoundEvent(0); fresh.startRoundEvent(1); fresh.startRoundEvent(1);
assert.deepEqual(initial, [1]);
assert(source.includes('level thread lb_round_watcher();'));
assert(!all.get('lb_round_watcher').includes('hud_round_changed'));
assert(!all.get('lb_round_watcher').includes('tws_t5_hud'));
assert(!all.get('lb_round_watcher').includes('tws_t6_hud'));
});
for (const event of ['end_game', 'game_ended', 'intermission'])
check(`${game} ${event} stops the independent round watcher`, () => {
const e = roundRuntime(game);
e.readyPlayer(); e.cache(50);
e.context.level.round_number = 44; e.startWatcher(); e.startRoundEvent(45);
assert.deepEqual(e.endEvents, ['end_game', 'game_ended', 'intermission']);
e.end(event); e.startRoundEvent(46);
assert.deepEqual(e.shown.map(notice => notice.round), [45]);
});
check(`${game} unicode and URL encoding`, () => { check(`${game} unicode and URL encoding`, () => {
assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9'); assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9');
assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟'); assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟');
@@ -288,7 +514,7 @@ check('paired data helpers stay identical', () => {
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8')); const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8')); const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
for (const [name, fn] of t5) { for (const [name, fn] of t5) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) { if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::'); const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
assert.equal(normalized, fn, name); assert.equal(normalized, fn, name);
} }
+6 -6
View File
@@ -1,18 +1,18 @@
{ {
"tws_t5_afk.gsc": "2.0.0", "tws_t5_afk.gsc": "2.1.0",
"tws_t5_bank.gsc": "2.0.3", "tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.4.1", "tws_t5_common.gsc": "1.5.0",
"tws_t5_hud.gsc": "1.4.2", "tws_t5_hud.gsc": "1.4.2",
"tws_t5_join.gsc": "1.0.0", "tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.1.0", "tws_t5_leaderboard.gsc": "1.2.0",
"tws_t5_lock.gsc": "1.0.0", "tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "2.0.0", "tws_t6_afk.gsc": "2.1.0",
"tws_t6_bank.gsc": "2.0.3", "tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.4.1", "tws_t6_common.gsc": "1.5.0",
"tws_t6_hud.gsc": "1.4.2", "tws_t6_hud.gsc": "1.4.2",
"tws_t6_join.gsc": "1.0.1", "tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.1.0", "tws_t6_leaderboard.gsc": "1.2.0",
"tws_t6_lock.gsc": "1.0.0", "tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.4.0", "t5-plugin": "1.4.0",