Compare commits
2
Commits
6781a4ed5a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caaaa102a4 | ||
|
|
c19bef6dc6 |
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user