Compare commits
15
Commits
e95ebe3117
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caaaa102a4 | ||
|
|
c19bef6dc6 | ||
|
|
6781a4ed5a | ||
|
|
7b95f604b4 | ||
|
|
d7bf16e9c2 | ||
|
|
2b5b1955bc | ||
|
|
929bf0354b | ||
|
|
d15bc50b7d | ||
|
|
2123b8f4d5 | ||
|
|
463b4bcc51 | ||
|
|
ab989395f7 | ||
|
|
3bb0b5178e | ||
|
|
9cce0be438 | ||
|
|
eda2c7e76d | ||
|
|
664e8da81b |
+745
@@ -4,8 +4,753 @@ Changes are listed newest first, with an independent version for every individua
|
||||
T5/T6 script and each native plugin. Current versions are recorded in
|
||||
[versions.json](versions.json); see the [version policy](README.md#versioning).
|
||||
|
||||
## 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)
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_afk.gsc` | `2.0.0` | `t5/scripts/tws_t5_afk.gsc` |
|
||||
| `tws_t6_afk.gsc` | `2.0.0` | `t6/scripts/tws_t6_afk.gsc` |
|
||||
| `t5-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
|
||||
| `t6-plugin` | `1.4.0` | `src/component/afk.cpp`, `src/utils/afk.*` |
|
||||
|
||||
- Replace unlimited AFK with shared GUID reserves, authenticated authorization,
|
||||
explicit bank purchase confirmation, real contribution rewards and durable
|
||||
request/session journals. Both engines use matching contracts and behavior.
|
||||
- Show an independent central countdown, final-minute urgency and audible warnings.
|
||||
Zero immediately releases AFK protection and owned controls/weapons. Manual
|
||||
`.back` retains its separate two-second grace; it cannot renew an AFK session.
|
||||
Reject downed activation (including T5), stock restrictions and last-active-player
|
||||
entry. End the match when only AFK players remain, respecting stock recovery.
|
||||
- Award each successful revive, including repeated revives of one teammate. Count
|
||||
kill/headshot events, round participation and active engagement without using
|
||||
score changes. EC owns configurable rates, caps, persistent progress and atomic
|
||||
bank conversion; no second points bank is introduced.
|
||||
- Compatibility: AFK `2.0.0` requires matching TWS plugin `1.4.0+` (native AFK API
|
||||
`1.0.0`), common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+ and the EC #151
|
||||
endpoints. EC host agent `1.12.0+` must be deployed before activation. Existing
|
||||
private `tws_bank_token` and `tws_server_id` authenticate requests. There is no
|
||||
new upstream dependency pin or native loader `plugin_version()` requirement.
|
||||
- Deployment: deploy EC and upgraded host agents first; build/install both plugin
|
||||
DLLs and matching scripts, retain persistent `tws-afk` storage, then **fully
|
||||
restart each game-server process**. No deployment, Windows DLL build or live
|
||||
Plutonium validation is claimed. See [AFK operation and validation](docs/afk.md).
|
||||
- Validation: both engines pass native AFK journal/adapter tests with ASan/UBSan,
|
||||
39 AFK behavior checks, 14 core HUD and 56 service HUD checks. All nine T6 scripts
|
||||
compiled/assembled with upstream gsc-tool `8f1d6bd` T6 PC compiler/assembler
|
||||
libraries. Script and integration checks are documented in the AFK guide; final
|
||||
in-game T5/T6 validation remains required. Unchanged common/HUD/bank/telemetry
|
||||
scripts retain their individual versions.
|
||||
|
||||
### Center the complete zombie count above the health bar
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_hud.gsc` | `1.4.2` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.4.2` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
|
||||
- Replace the separately positioned number and label with one center-aligned
|
||||
`N ZOMBIES` readout at the health track's midpoint in both engines. Use a
|
||||
uniform font scale of `1.25` and the existing count color; digit changes and
|
||||
the initial `-- ZOMBIES` placeholder share the same center.
|
||||
- Update text only when the count changes using `SetTextUnlimited`, avoiding
|
||||
a new config string for each distinct count. Keep counting, `.hud` visibility,
|
||||
game-over termination and disconnect cleanup behavior. Core HUD allocation
|
||||
decreases from 11 to 10 archived elements; AFK still adds one.
|
||||
- Compatibility: HUD `1.4.2` requires matching common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334), which provides
|
||||
`SetTextUnlimited` for T5 and T6. No new plugin or backend requirement.
|
||||
Common and AFK need no source change for this fix; both HUD scripts change.
|
||||
- Validation: 73 HUD checks passed, including digit transitions, a non-default
|
||||
track midpoint, unchanged-count polling, hidden updates and service cleanup.
|
||||
Leaderboard (143), bank (42), join (17) and telemetry (23) portable checks
|
||||
passed. These use engine shims; actual EC integration and live Plutonium
|
||||
validation were not run. GSC compilation was unavailable because gsc-tool
|
||||
and PowerShell are not installed. Verify rendering on Windows/Plutonium.
|
||||
- Deployment: copy each updated HUD script and restart the map on r5334+.
|
||||
Older installations must first update Plutonium on clients and servers and
|
||||
fully restart those processes. No full server-process restart is required
|
||||
for the script update alone. No deployment performed.
|
||||
|
||||
### Use ASCII separators in HUD branding and map records
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.4.1` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.4.1` | `t6/scripts/tws_t6_common.gsc` |
|
||||
| `tws_t5_hud.gsc` | `1.4.1` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.4.1` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
|
||||
- Replace the middle dot with an ASCII vertical bar in the top-center branding
|
||||
and map-record heading after the separator displayed as two dots on T5.
|
||||
Apply the same text change to T6 for a consistent appearance. Keep existing
|
||||
spacing, positioning and HUD/service behavior.
|
||||
- Compatibility: no new dependency requirement. No native-plugin, backend or
|
||||
other script changes are required.
|
||||
- Validation: 71 HUD, 143 leaderboard, 42 bank, 17 join and 23 telemetry portable
|
||||
checks passed. These use engine shims; actual EC bank integration, GSC
|
||||
compilation and live Windows/Plutonium validation were not performed.
|
||||
- Deployment: copy the updated common and HUD scripts for each installed game,
|
||||
then restart the map. No full server-process restart is required. No deployment
|
||||
performed; live Windows/Plutonium rendering validation remains outstanding.
|
||||
|
||||
## 2026-10-15 — Player moderation runtime
|
||||
|
||||
- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional
|
||||
paired scripts register current GUIDs and use the existing shared connection
|
||||
dispatcher. Require common 1.2.0, the matching native plugin 1.3.0, and Electric
|
||||
Cherry's instance-authenticated moderation endpoint/private configuration.
|
||||
- **t5-plugin 1.3.0 / t6-plugin 1.3.0**: add the native moderation component and
|
||||
policy/journal utilities, connection-bound synchronous actions, durable cached
|
||||
bans/mutes and expiry/revocation, and an opt-in bounded HTTP response limit.
|
||||
Existing HTTP callers keep their previous limit behavior. Sources are each
|
||||
plugin's component/moderation.cpp, utils/moderation.*, and utils/http.*.
|
||||
Both games change for matching functionality; their chat opcodes remain distinct.
|
||||
- Deploy the receiver first; build and install both matching DLL/script pairs,
|
||||
supply separate instance credentials, and fully restart game-server processes.
|
||||
Persistent tws-moderation directories must survive restart. No deployment or
|
||||
live Windows/Plutonium verification is claimed. See [runtime contract and
|
||||
validation](docs/moderation.md).
|
||||
- Validation: paired native moderation policy/adapter checks passed with ASan and
|
||||
UBSan, including separate-process journal and session-sequence recovery. The
|
||||
GSC identity and retry/disable/resume checks, native HTTP cap/error checks,
|
||||
existing HTTP component, bank and telemetry checks, metadata/link validation,
|
||||
and `git diff --check` passed. Native adapter tests use engine shims; no DLL
|
||||
build, GSC compilation or live game validation was performed.
|
||||
|
||||
## 2026-10-15
|
||||
|
||||
### Replace the dashboard panels with unboxed HUD overlays
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.4.0` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.4.0` | `t6/scripts/tws_t6_common.gsc` |
|
||||
| `tws_t5_hud.gsc` | `1.4.0` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.4.0` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
| `tws_t5_afk.gsc` | `1.2.0` | `t5/scripts/tws_t5_afk.gsc` |
|
||||
| `tws_t6_afk.gsc` | `1.2.0` | `t6/scripts/tws_t6_afk.gsc` |
|
||||
|
||||
- Both games use the supplied minimal layout: unlabeled match/round timers at
|
||||
upper left, combined branding at top center, zombies above a slim health bar
|
||||
at bottom center, and numeric current/maximum HP to its right only while
|
||||
injured. Center the AFK indicator above the zombie count. Remove dashboard
|
||||
backgrounds while retaining outlined text and health colors.
|
||||
- Keep `.hud` as a private visibility toggle for the entire core and AFK
|
||||
indicator. Hidden data updates, frozen game-over values, connection/spawn
|
||||
lifecycle, AFK protection and stock HUD behavior are unchanged.
|
||||
- Move services into an unboxed upper-right area below the Plutonium tag.
|
||||
Records use a compact right-aligned `Map record · Round N` header and up to
|
||||
three existing bounded holder lines, with no footer or single-page counter.
|
||||
Record data, cache behavior, all tied holders and 12-second paging remain
|
||||
unchanged; leaderboard source and versions are unchanged.
|
||||
- Show all nine installed commands, including `.hud`, in one help list with
|
||||
full `.d <amount|all>` / `.w <amount|all>` syntax. Both engines use explicit
|
||||
command/description rows; extra registrations use pages of nine, with a
|
||||
counter only for multi-page lists. Preserve private requests, help priority,
|
||||
record pause/resume, replay replacement and 12-second page timing.
|
||||
- Allocate only the active service view in both engines and destroy it when
|
||||
idle or before switching kinds. Core + AFK use 11 + 1 archived elements;
|
||||
nine-command help uses 19 nonarchived elements (20 with a multi-page counter),
|
||||
and records use four (five with a multi-page counter). Idle holds none.
|
||||
These counts do not establish live Plutonium rendering capacity.
|
||||
- Common adds fullscreen overlay helpers using direct normalized `640 × 480`
|
||||
positions and integer shader dimensions. Retain legacy panel helpers for
|
||||
compatibility. Both HUD and AFK implementations use the new helpers.
|
||||
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 56 service/
|
||||
static checks (71 total). Leaderboard passed 143 checks, bank 42, join 17 and
|
||||
telemetry 23 through their portable check scripts. JSON uniqueness, the
|
||||
16-script manifest, all six version/changelog matches, 66 local documentation
|
||||
links/anchors and `git diff --check` passed. Checks use engine shims; actual
|
||||
EC bank integration was not run. GSC compilation was not run because
|
||||
gsc-tool/PowerShell are unavailable; live Windows/Plutonium validation remains
|
||||
outstanding.
|
||||
- Compatibility: HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`.
|
||||
Both engines receive companion changes; leaderboard `1.1.0` remains
|
||||
compatible. No new Plutonium, native-plugin or backend requirement.
|
||||
- Deployment: copy each game's common, HUD and AFK scripts together where
|
||||
installed, then restart the map. No full server-process restart or plugin
|
||||
update is required. No deployment performed. Windows/Plutonium layout,
|
||||
element capacity, stock HUD overlap and lifecycle validation remain required.
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### Release T5 service elements when records or help close
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.3.1` | `t5/scripts/tws_t5_common.gsc` |
|
||||
|
||||
- Address core HUD elements disappearing when the first record announcement
|
||||
opens and remaining absent after it closes. The previous T5 service allocated
|
||||
all 24 help/record elements at once and retained them while idle. That pattern
|
||||
is consistent with HUD allocation pressure; the live T5 SP capacity has not
|
||||
been established.
|
||||
- Allocate only the active service view, with at most 12 elements for help or
|
||||
11 for records. Release the elements when the service becomes idle and before
|
||||
switching between help and records. Reuse them for pages within the same
|
||||
view, and render the page indicator as one cached text element. The single
|
||||
controller, help priority, paused record timing and request replacement remain
|
||||
intact. Core elements and their visibility setting are unchanged.
|
||||
- T6 needs no source change: the reported failure is on T5, whose explicit help
|
||||
rows expanded its former allocation to 24 elements. T6 retains its smaller
|
||||
18-element multiline renderer; validate its existing behavior separately.
|
||||
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 53 service/
|
||||
static checks (68 total), including active-view allocation, idle release,
|
||||
repeated service cycles and core preservation. Leaderboard passed 143 checks,
|
||||
bank 42, join 17 and telemetry 23 through their portable check scripts. These
|
||||
use engine shims; actual EC bank integration was not run. Live Windows/
|
||||
Plutonium rendering validation was not performed.
|
||||
- Compatibility: HUD `1.3.0`, AFK `1.1.0` and leaderboard `1.1.0` remain
|
||||
compatible. No new dependency requirement. Deployment: copy only
|
||||
`tws_t5_common.gsc` and restart the map. No plugin update or full server-process
|
||||
restart is required. No deployment performed; live T5 validation must confirm
|
||||
core integrity during automatic records, help, view switches and dismissal.
|
||||
|
||||
### Add a per-player HUD visibility command
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.3.0` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.3.0` | `t6/scripts/tws_t6_common.gsc` |
|
||||
| `tws_t5_hud.gsc` | `1.3.0` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.3.0` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
| `tws_t5_afk.gsc` | `1.1.0` | `t5/scripts/tws_t5_afk.gsc` |
|
||||
| `tws_t6_afk.gsc` | `1.1.0` | `t6/scripts/tws_t6_afk.gsc` |
|
||||
|
||||
- Both HUD modules handle the exact, case-insensitive `.hud` chat command to
|
||||
toggle that player's core branding, timers, zombie count, HP/bar and AFK
|
||||
indicator. Visibility defaults on, persists across death/respawn on the
|
||||
current connection/map, and resets on reconnect or map restart.
|
||||
- Common tracks core elements and their intended alpha values so changing
|
||||
visibility preserves each element's state. Data and timers continue updating
|
||||
while hidden, and game-over values remain frozen. AFK routes only its
|
||||
indicator visibility through these helpers; protection behavior is unchanged.
|
||||
Help, records and stock HUD elements are unaffected.
|
||||
- Register `.hud` in help only when the HUD module is installed. The full
|
||||
command order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`,
|
||||
`.d`, `.w`: nine commands across pages of four, four and one. T5 retains
|
||||
explicit help rows; T6 retains multiline columns.
|
||||
- Compatibility: HUD `1.3.0` and AFK `1.1.0` require their matching common
|
||||
`1.3.0+`. Both engines receive companion implementations. No new Plutonium,
|
||||
native-plugin or backend dependency requirement.
|
||||
- Deployment: update each game's common, HUD and AFK scripts together where
|
||||
installed, then restart the map. No plugin update or full server-process
|
||||
restart is required. No deployment performed; GSC compilation and live
|
||||
T5/T6 validation remain required.
|
||||
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 48 service/
|
||||
static checks (63 total), covering private visibility, intended opacity,
|
||||
late-created elements, hidden updates/frozen clocks, AFK transitions, service
|
||||
independence, conditional help and three-page ordering. Leaderboard passed
|
||||
143 checks, bank 42, join 17 and telemetry 23 through their existing portable
|
||||
check scripts. These use engine shims; actual EC bank integration was not run.
|
||||
GSC compiler and PowerShell were unavailable, and live Windows/Plutonium
|
||||
validation was not performed.
|
||||
|
||||
### Render T5 help as separate rows
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.6` | `t5/scripts/tws_t5_common.gsc` |
|
||||
|
||||
- Replace T5's newline-separated help columns with four preallocated
|
||||
command/description pairs, positioned on separate rows. This addresses BO1
|
||||
rendering the newline text as one concatenated line. Reuse the row elements
|
||||
across pages and hide unused rows on short pages.
|
||||
- Preserve panel dimensions, four commands per page, 12-second timing and the
|
||||
removed help footer. T5's service now uses 24 nonarchived HUD elements.
|
||||
T6 needs no source change: its existing multiline renderer remains in place
|
||||
with 18 service elements; the reported rendering issue was observed in T5.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 42 service checks
|
||||
(50 total), covering both engines' pagination and T5's distinct row elements,
|
||||
partial-page hiding, reuse, record transitions and disconnect cleanup.
|
||||
GSC compilation and live T5 display/notification validation were not run.
|
||||
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
|
||||
requirement. Deployment: copy only the updated T5 common script and restart
|
||||
the map. No plugin update or full server-process restart is required.
|
||||
No deployment performed; live T5 display validation remains required.
|
||||
|
||||
### Remove the help footer
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.5` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.5` | `t6/scripts/tws_t6_common.gsc` |
|
||||
|
||||
- Both games remove the `Four commands per page` footer and its HUD element.
|
||||
Help still displays four commands per page with its page indicator and
|
||||
existing timing. The shared service now uses 18 nonarchived elements.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
|
||||
(49 total), including the 18-element allocation and removed help footer.
|
||||
GSC compilation and live Plutonium validation were not run.
|
||||
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
|
||||
requirement. Deployment: copy only each game's updated common script and
|
||||
restart the map. No plugin update or full server-process restart is required.
|
||||
No deployment performed; live T5/T6 display validation remains required.
|
||||
|
||||
### Hide the page indicator for single-page records
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.4` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.4` | `t6/scripts/tws_t6_common.gsc` |
|
||||
|
||||
- Both games hide `1 / 1` when the record service has only one page. Records
|
||||
spanning multiple pages and all help pages retain their page indicators.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
|
||||
(49 total), including empty/unavailable records, multi-page indicators,
|
||||
replacement by a single-page record, resuming after help, and single-page
|
||||
help indicators. GSC compilation and live Plutonium validation were not run.
|
||||
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
|
||||
requirement. Deployment: copy only each game's updated common script and
|
||||
restart the map. No plugin update or full server-process restart is required.
|
||||
No deployment performed; live T5/T6 display validation remains required.
|
||||
|
||||
### Restore panel backgrounds and health bars
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.3` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.3` | `t6/scripts/tws_t6_common.gsc` |
|
||||
|
||||
- Both engines apply the final integer conversion after `max()` in
|
||||
`make_panel_width()`. Native `max()` returns a float, which `SetShader`
|
||||
rejects for its integer width argument; converting before `max()` left
|
||||
backgrounds and health-bar shaders missing. The corrected helper supplies
|
||||
integer widths to both panel creation and health-bar updates.
|
||||
- Retain panel color `(0.07, 0.10, 0.08)`, opacity `0.72`, and the corrected
|
||||
text positions/font sizes. HUD source is unchanged and remains `1.2.1`.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
|
||||
(49 total). The new regression models native float typing and strict integer
|
||||
shader widths, and checks the existing panel color/opacity. GSC compiler and
|
||||
PowerShell were unavailable; GSC compilation and live Windows/Plutonium display
|
||||
validation were not performed.
|
||||
- Compatibility: use matching common `1.2.3+` with HUD `1.2.1`; no new
|
||||
Plutonium, native-plugin or backend dependency requirement. Deployment: if
|
||||
HUD `1.2.1` is already installed, copy only each game's updated common script
|
||||
and restart the map. No plugin update or full server-process restart is
|
||||
required. No deployment performed; verify restored shaders in T5 and T6.
|
||||
|
||||
### Correct footer font size and screen-edge panel placement
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.2` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.2` | `t6/scripts/tws_t6_common.gsc` |
|
||||
| `tws_t5_hud.gsc` | `1.2.1` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.2.1` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
|
||||
- Both engines enforce a minimum text font scale of `1.0`, with both service
|
||||
footers explicitly at `1.0`, addressing the oversized `.leaders` footer.
|
||||
- Use fullscreen horizontal and vertical anchors so panels follow the actual
|
||||
upper-right screen edge instead of safe-area insets. Convert horizontal
|
||||
design offsets and shader widths by `0.75` into the normalized 640-wide
|
||||
viewport; use a top offset of 20. The 1920×1080 reference yields a 495-pixel
|
||||
panel, 22.5-pixel right margin and 45-pixel top margin, leaving space for the
|
||||
Plutonium tag. Other aspect ratios retain normalized horizontal geometry,
|
||||
so physical proportions vary; live font fit and tag clearance need validation.
|
||||
- Both HUD scripts use common's new `make_panel_width(width)` for health-bar
|
||||
updates so the fill uses the same width conversion as its track. T5 and T6
|
||||
receive the same fix with their matching namespaces.
|
||||
- Compatibility: HUD `1.2.1` requires matching common `1.2.2+`; upgrade common
|
||||
and HUD together. No new Plutonium, native-plugin or backend dependency is
|
||||
required.
|
||||
- Deployment: copy each game's updated common and HUD scripts and restart the
|
||||
map. No full server-process restart is required. No deployment performed;
|
||||
GSC compilation and live Windows/Plutonium display validation remain required.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 39 service/static
|
||||
geometry checks (47 total); `node tools/check_leaderboard.mjs` passed 143
|
||||
checks. The geometry shims cover 4:3, 16:9 and ultrawide bounds in a normalized
|
||||
640×480 frame, not live glyph fit or tag clearance. GSC compiler and PowerShell
|
||||
were unavailable; no GSC compilation or live display validation was performed.
|
||||
|
||||
### Shorter map-record footer
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.1` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.1` | `t6/scripts/tws_t6_common.gsc` |
|
||||
|
||||
- Both games remove `Briefly shown` and its separator from the map-record
|
||||
footer, retaining `.leaders to view again`. Record timing and replay behavior
|
||||
are unchanged.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks
|
||||
using engine shims. GSC compilation and live Plutonium display validation
|
||||
were not run.
|
||||
- Compatibility: no new dependency requirement. Deployment: copy each game's
|
||||
updated common script and restart the map. No plugin update or full
|
||||
server-process restart is required. No deployment performed; GSC compilation
|
||||
and live T5/T6 display validation remain required.
|
||||
|
||||
### Fixed upper-right core HUD and shared service window
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_common.gsc` | `1.2.0` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.2.0` | `t6/scripts/tws_t6_common.gsc` |
|
||||
| `tws_t5_hud.gsc` | `1.2.0` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.2.0` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
| `tws_t5_leaderboard.gsc` | `1.1.0` | `t5/scripts/tws_t5_leaderboard.gsc` |
|
||||
| `tws_t6_leaderboard.gsc` | `1.1.0` | `t6/scripts/tws_t6_leaderboard.gsc` |
|
||||
| `tws_t5_afk.gsc` | `1.0.1` | `t5/scripts/tws_t5_afk.gsc` |
|
||||
| `tws_t6_afk.gsc` | `1.0.1` | `t6/scripts/tws_t6_afk.gsc` |
|
||||
|
||||
- Both engines use a fixed upper-right core panel below the top edge, with
|
||||
`TWS ZOMBIES`, `discord.tws.lol`, side-by-side match/round timers, zombie count,
|
||||
current/maximum HP even at full health, and a full-width health bar. Replace
|
||||
the co-op name row with reserved footer space for the AFK indicator. Core
|
||||
creation on connection supports spectator placeholders without seeding the
|
||||
match clock early. Preserve each engine's timer behavior and frozen clocks at
|
||||
game end; health and zombie-count workers now freeze on both engines as well.
|
||||
Missing max-health values use a display fallback without modifying gameplay health.
|
||||
- Common owns one fixed-height service window below the core. Help shows four
|
||||
commands per page for 12 seconds each, in stable order and filtered by loaded
|
||||
modules. `.help` / `.commands` restart at page one. The final page dismisses
|
||||
automatically; opening a service never shifts or resizes the core.
|
||||
- Render help with two fixed multiline text columns and a muted command-column
|
||||
backdrop. The service uses 19 nonarchived HUD elements regardless of command
|
||||
or page count; the core and AFK use 15 + 1 archived elements. Reuse text and
|
||||
numeric displays instead of allocating elements for every command/page.
|
||||
- Records use the same window, with a prominent round, page count, replay hint,
|
||||
and up to three holder lines per page. Abbreviate overlong individual names
|
||||
without splitting UTF-8 characters; retain every tied holder across bounded
|
||||
lines and pages. Help pauses records and resumes their remaining page time;
|
||||
`.leaders` during help replaces the pending record from page one. Unavailable
|
||||
records are distinct from an empty recorded history. Help and records remain
|
||||
private and work without the HUD module. Reused elements and request state
|
||||
protect newer displays from stale timers and clean up on disconnect/game end.
|
||||
- Compatibility: HUD `1.2.0`, leaderboard `1.1.0`, and AFK `1.0.1` require the
|
||||
matching game's common `1.2.0+`. Existing common helper signatures remain
|
||||
available for unchanged consumers. No new native-plugin, backend or external
|
||||
dependency requirement; existing leaderboard HTTP/JSON requirements still apply.
|
||||
Both T5 and T6 receive companion implementations adapted to their engines.
|
||||
- Deployment: copy each game's matching common, HUD, leaderboard and AFK scripts
|
||||
that are installed, then restart the map. No plugin update or full server-process
|
||||
restart is required for this change. No deployment performed.
|
||||
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks;
|
||||
`node tools/check_leaderboard.mjs` passed 143 checks, `node tools/check_bank.mjs`
|
||||
passed 42 fixture groups, `node tools/check_join.mjs` passed 17 checks, and
|
||||
`node tools/check_telemetry.mjs` passed 23 checks. These use engine shims.
|
||||
No local GSC compiler or PowerShell was available; GSC compilation and live
|
||||
Windows/Plutonium validation remain required. Follow the
|
||||
[HUD validation guide](README.md#hud-validation) before rollout.
|
||||
|
||||
## 2026-10-13
|
||||
|
||||
### Shorter Discord URL
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_hud.gsc` | `1.1.2` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.1.2` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
|
||||
- Both games replace the displayed Discord URL with `https://discord.tws.lol`.
|
||||
Keep the smaller font and muted gray introduced in the already-uploaded
|
||||
`1.1.1` release.
|
||||
- Compatibility: no new dependency requirement; matching common `1.1.0+`
|
||||
remains required. Deployment: copy each updated HUD script and restart the
|
||||
map. No plugin update or full server-process restart is required. No
|
||||
deployment of `1.1.2` performed here; GSC compilation and live T5/T6 display
|
||||
verification remain required.
|
||||
|
||||
### More subtle Discord label
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_hud.gsc` | `1.1.1` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.1.1` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
|
||||
- Both games reduce the top-left Discord URL font scale from `1.4` to `1.2`
|
||||
and darken its neutral gray from `(0.85, 0.85, 0.85)` to `(0.65, 0.65, 0.65)`.
|
||||
The label keeps its position and connection lifetime; other HUD text is
|
||||
unaffected.
|
||||
- Compatibility: no new dependency requirement; matching common `1.1.0+`
|
||||
remains required. Deployment: copy each updated HUD script and restart the
|
||||
map. No plugin update or full server-process restart is required. The user
|
||||
reports `1.1.1` has been uploaded; GSC compilation and live T5/T6 appearance
|
||||
verification have not been performed here.
|
||||
|
||||
### Simplified bank balance message
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_bank.gsc` | `2.0.3` | `t5/scripts/tws_t5_bank.gsc` |
|
||||
| `tws_t6_bank.gsc` | `2.0.3` | `t6/scripts/tws_t6_bank.gsc` |
|
||||
|
||||
- Both games now display `.b` as `Bank: {balance} points`, retaining the
|
||||
existing colors and omitting the available amount. Update startup version
|
||||
reporting and the existing balance-message fixtures. API data and point
|
||||
accounting are unchanged.
|
||||
- Validation: all 42 portable bank script fixture groups passed with
|
||||
`node tools/check_bank.mjs`, including exact balance text for both games.
|
||||
- Compatibility: no new dependency requirement; matching TWS plugins `1.2.0+`
|
||||
remain required. Deployment: copy each updated bank script and restart the
|
||||
map. No plugin update or full server-process restart is required for this
|
||||
change. No deployment performed; GSC compilation and live T5/T6 display
|
||||
verification remain required.
|
||||
|
||||
### Bank failure diagnostics — TWS #14
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_bank.gsc` | `2.0.2` | `t5/scripts/tws_t5_bank.gsc` |
|
||||
| `tws_t6_bank.gsc` | `2.0.2` | `t6/scripts/tws_t6_bank.gsc` |
|
||||
| `t5-plugin` | `1.2.0` | `t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
|
||||
| `t6-plugin` | `1.2.0` | `t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
|
||||
|
||||
- Both engines log startup script/loaded TWS plugin versions, effective endpoint,
|
||||
safe server identity, credential format validation and pending journal count.
|
||||
Add diagnostic correlation for every HTTP attempt without changing API IDs;
|
||||
balance requests keep their empty acknowledgment ID contract.
|
||||
- Distinguish creation, transport, HTTP, missing diagnostics, JSON/schema,
|
||||
response-ID and application failures, with safe HTTP/curl/timing fields.
|
||||
Native acknowledgment parsing adds fixed failure reasons without changing
|
||||
existing validity rules. Only known bounded response codes reach logs; never
|
||||
log credentials, headers, bodies or native exception contents.
|
||||
- Log journal load/write/removal failures and settlement decision, attempts,
|
||||
pending duration, next retry delay and resolution. Suppress identical failures
|
||||
for 30 seconds, report suppression counts, and log reason changes and recovery
|
||||
immediately. Recovered pending duration starts at this map's journal load.
|
||||
Preserve points, durable decisions, retry behavior and player-facing messages.
|
||||
- Compatibility: both scripts require matching plugin `1.2.0+` for the additive
|
||||
`bankVersion()` native and parser reason field. No new EC/agent or third-party
|
||||
dependency requirement; existing common/telemetry and migration requirements
|
||||
remain. Upstream pins and native loader `plugin_version()` are unchanged.
|
||||
- Deployment: build/install both DLLs and **restart the full server processes**;
|
||||
install matching scripts, restarting the map if copied after process startup.
|
||||
Verify installed DLL/GSC hashes against the release artifacts and startup
|
||||
version logs, particularly Ascension's reported stale GSC artifact. No deployment
|
||||
performed. Windows DLL builds, GSC compilation and live T5/T6 checks remain
|
||||
required; see [bank diagnostics](README.md#bank-diagnostics--tws-14).
|
||||
- Validation: 44 portable script fixture groups passed, including actual EC
|
||||
bank service/SQLite integration at checkout `d614707`. Fixtures assert safe
|
||||
startup/outcome logs, missing diagnostics, HTTP 401, timeout, malformed/schema
|
||||
acknowledgments, ID mismatch, storage failures, rate limiting and recovery,
|
||||
alongside existing accounting/replay tests. Native parser/storage/process-restart
|
||||
and registration-adapter checks run with address/undefined-behavior sanitizers.
|
||||
|
||||
## 2026-10-12
|
||||
|
||||
### String bank identities — TWS #15
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_bank.gsc` | `2.0.1` | `t5/scripts/tws_t5_bank.gsc` |
|
||||
| `tws_t6_bank.gsc` | `2.0.1` | `t6/scripts/tws_t6_bank.gsc` |
|
||||
|
||||
- Normalize T5's `player getGuid()` and T6's persistent `player.guid` to
|
||||
strings before balance/prepare requests or durable cancel/commit decisions.
|
||||
Preserve large and zero-prefixed string GUIDs without numeric conversion.
|
||||
Reject missing, unsupported or invalid identities privately before HTTP,
|
||||
journaling or score changes, using EC's 1–64 character token contract.
|
||||
- Both engines receive the fix. T6 uses the same field as telemetry, populated
|
||||
by stock connection initialization; fixtures no longer give T6 a getter shim.
|
||||
Other scripts and native plugins are unchanged.
|
||||
- Checks: `node tools/check_bank.mjs` covers numeric/string/missing identities,
|
||||
balance, prepare, both decisions and exact-body replay after restart.
|
||||
Its optional `--ec-root` mode executes the actual EC bank service and bank
|
||||
migrations against isolated SQLite, covering the original numeric rejection,
|
||||
deposits, withdrawals, zero-balance `.w all`, cancellation and idempotent
|
||||
commit recovery. All 34 fixture groups passed with EC checkout `d614707`;
|
||||
`python3 tools/test_plugin_bank.py` also passed both engines' native
|
||||
storage/response, process-restart and component checks with sanitizers.
|
||||
- Compatibility: no new dependency requirement or EC source change. Existing
|
||||
plugins `1.1.0`, common/telemetry `1.0.0+`, and EC agent `1.9.1+` remain
|
||||
sufficient. Deployment: copy each updated bank script to its game's script
|
||||
directory and restart the map; no full server-process restart is required
|
||||
for this fix. Continue following the endpoint migration procedure if also
|
||||
upgrading from a legacy URL.
|
||||
- This fix prevents new invalid journals; it does not rewrite old pending
|
||||
decisions. No production recovery or deployment was performed as part of
|
||||
this change.
|
||||
GSC compilation, deployed-artifact verification and live T5/T6 command checks
|
||||
remain required; see [bank identity validation](README.md#bank-identity-validation).
|
||||
|
||||
### Public game-bank endpoint — TWS #13
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_bank.gsc` | `2.0.0` | `t5/scripts/tws_t5_bank.gsc` |
|
||||
| `tws_t6_bank.gsc` | `2.0.0` | `t6/scripts/tws_t6_bank.gsc` |
|
||||
|
||||
- Use `https://ec.thewrightserver.net/api/bank` for all balance, transfer and
|
||||
settlement requests on both engines. Remove the `tws_bank_url` override;
|
||||
stale settings are ignored. This configuration removal requires a major bump.
|
||||
Keep EC's `tws_server_id` and private per-instance `tws_bank_token` authentication.
|
||||
- Both T5 and T6 receive the same behavior with their existing namespaces and
|
||||
startup guards. Native plugins and other scripts need no source changes.
|
||||
- Compatibility: requires companion EC agent `1.9.1+` to recognize public and
|
||||
legacy controller-derived journals during authenticated teardown settlement.
|
||||
Existing plugin `1.1.0`, common `1.0.0+` and telemetry `1.0.0+` remain sufficient;
|
||||
no new native API or third-party dependency requirement.
|
||||
- Deployment: update agents first. When changing endpoints, **rebuild through
|
||||
EC** so the old process stops and its pending decisions settle before a new
|
||||
process starts. Do not switch scripts with just a map restart in that case;
|
||||
journals are endpoint-scoped. If the old URL already matches exactly, copy the
|
||||
scripts and restart the map; no full server-process restart is required.
|
||||
See [endpoint upgrade and recovery](README.md#upgrading-the-bank-endpoint).
|
||||
No deployment was performed; agent release signing, Windows/GSC compilation
|
||||
and live T5/T6 validation remain required.
|
||||
- Checks: portable script fixtures exercise real initialization with stale
|
||||
overrides, every request type, authentication, empty balances and retries.
|
||||
Native fixtures cover zero-balance and insufficient-balance replies alongside
|
||||
existing durable storage and process-restart checks.
|
||||
|
||||
### Persistent cross-game player bank — TWS #3 / Electric Cherry #146
|
||||
|
||||
| Component | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_bank.gsc` | `1.0.0` | New `t5/scripts/tws_t5_bank.gsc` |
|
||||
| `tws_t6_bank.gsc` | `1.0.0` | New `t6/scripts/tws_t6_bank.gsc` |
|
||||
| `t5-plugin` | `1.1.0` | `t5/plugins/tws-t5-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
|
||||
| `t6-plugin` | `1.1.0` | `t6/plugins/tws-t6-gsc-utils/src/component/bank.cpp`, `src/utils/bank.cpp`, `src/utils/bank.hpp` |
|
||||
|
||||
- Add `.b`, `.d <amount|all>` and `.w <amount|all>` plus help registration on
|
||||
both engines. Store shared GUID balances and ledger history through Electric
|
||||
Cherry's authenticated bank API. EC generates and injects a private token per
|
||||
immutable server instance, preserving it on rebuild/move and revoking it on
|
||||
retirement. No manual token map is required;
|
||||
the shared telemetry token does not authorize bank operations.
|
||||
- Add native `bankLoad`, `bankStore`, `bankRemove` and `bankResponse` helpers.
|
||||
Save cancellation before preparing a transfer, replace it with a commit
|
||||
decision before applying points without yielding, and replay settlement
|
||||
after map/process restarts. Invalid replies, insufficient funds, changed
|
||||
player state, and failed journal writes leave spendable points unchanged.
|
||||
Preserve earned `score_total`; applied withdrawals belong to the current match.
|
||||
- T5 and T6 receive equivalent behavior with their own namespaces, last-stand
|
||||
APIs, entry points and journal directories. Other scripts are unchanged;
|
||||
existing common help registration and telemetry match keys already suffice.
|
||||
- Compatibility: deploy Electric Cherry #146 first, then each game's plugin
|
||||
`1.1.0` and bank script `1.0.0`, with common and telemetry `1.0.0` or newer.
|
||||
EC host agents `1.9.0+` settle pending journals before destructive teardown.
|
||||
No new third-party dependency, upstream pin, or loader version requirement.
|
||||
Keep `tws-bank/<game>/` on persistent writable storage; preserve pending
|
||||
journals when rotating credentials or restarting servers.
|
||||
- Deployment: build/install both Windows x86 DLLs and **restart the full server
|
||||
processes**, then install the new bank scripts and create/rebuild through EC
|
||||
to inject private credentials. Restart maps after script changes. See
|
||||
[bank setup and recovery](README.md#player-bank-t5-and-t6).
|
||||
No deployment was performed. Windows DLL linking, GSC compilation and live
|
||||
Plutonium checks remain required.
|
||||
- Checks: `node tools/check_bank.mjs` exercises the actual transfer functions;
|
||||
`python3 tools/test_plugin_bank.py` checks native journals, response validation,
|
||||
failed writes and process recovery with sanitizers. The T6 compiler check
|
||||
now includes all eight modules.
|
||||
|
||||
### T6 newcomer join eligibility — `tws_t6_join.gsc` `1.0.1`
|
||||
|
||||
- Fix `t6/scripts/tws_t6_join.gsc` treating the initial T6 setup spawn as
|
||||
gameplay, which rejected a new spectator's `.join` on Farm Survival round 3.
|
||||
Wait for the stock delayed spectator transition, then record actual play.
|
||||
Continue observing later spawns so waiting for the next round cannot grant
|
||||
an extra life after death. Reject requests while a spawn is settling; a real
|
||||
death during that window also permanently closes eligibility.
|
||||
- T5 is unchanged: its separate SP spawn flow and `self.initialized` guard
|
||||
do not use T6's delayed game-mode spectator transition.
|
||||
- Compatibility: existing T6 common `1.0.0` or later; no new dependency
|
||||
requirement. Deploy the updated join script to `storage/t6/scripts/zm/`
|
||||
and restart the map. No full server-process restart is required.
|
||||
- Portable regression fixtures: `node tools/check_join.mjs`. Compilation and
|
||||
live Plutonium validation remain required; no deployment was performed.
|
||||
|
||||
### Persistent top-left Discord URL
|
||||
|
||||
| Script | Version | Affected source |
|
||||
| --- | --- | --- |
|
||||
| `tws_t5_hud.gsc` | `1.1.0` | `t5/scripts/tws_t5_hud.gsc` |
|
||||
| `tws_t5_common.gsc` | `1.1.0` | `t5/scripts/tws_t5_common.gsc` |
|
||||
| `tws_t6_hud.gsc` | `1.1.0` | `t6/scripts/tws_t6_hud.gsc` |
|
||||
| `tws_t6_common.gsc` | `1.1.0` | `t6/scripts/tws_t6_common.gsc` |
|
||||
|
||||
- Add `https://discord.thewrightserver.net` as a small, light-gray top-left
|
||||
HUD label, created once per connection, including spectator newcomers.
|
||||
It has no expiry or death/respawn reset and is cleaned up on disconnect.
|
||||
Move the gameplay stack down 20 HUD units to leave room above the timers;
|
||||
help and record positioning follow the shared layout automatically.
|
||||
- Both engines receive the feature using their existing HUD anchors: T5
|
||||
left/top safe-area alignment and T6 fullscreen alignment.
|
||||
- Compatibility: deploy each game's HUD and common `1.1.0` together for the
|
||||
reserved top line. No new native plugin or external dependency requirement.
|
||||
Other scripts and both plugins retain their existing versions.
|
||||
- Deployment: copy the updated HUD and common scripts to `storage/t5/scripts/sp/`
|
||||
or `storage/t6/scripts/zm/`, respectively, and restart the map. No full
|
||||
server-process restart is required. No deployment has been performed here.
|
||||
- Live validation remains required on both Plutonium games: check placement
|
||||
and readability, join as a spectator, die/respawn, reconnect, and open help
|
||||
and records to verify spacing and that only one URL label appears.
|
||||
|
||||
### Initial versioning baseline
|
||||
|
||||
Established independent version `1.0.0` entries for each of the 14 scripts and
|
||||
|
||||
@@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal
|
||||
changes, the minor for compatible features, and the major for breaking changes
|
||||
to commands, configuration, native APIs, or data contracts. Reset lower fields
|
||||
when increasing the minor or major. Bump once per logical change; documentation-only
|
||||
and test-only changes are exempt. All 14 scripts and both plugins advance independently.
|
||||
and test-only changes are exempt. All 18 scripts and both plugins advance independently.
|
||||
|
||||
The initial `1.0.0` versions document the existing source baseline. These TWS
|
||||
versions are separate from upstream revisions in each plugin's `upstream.json`
|
||||
@@ -170,18 +170,341 @@ Modules currently in each tree:
|
||||
| lock | `t5/scripts/tws_t5_lock.gsc` | `t6/scripts/tws_t6_lock.gsc` |
|
||||
| telemetry | `t5/scripts/tws_t5_telemetry.gsc` | `t6/scripts/tws_t6_telemetry.gsc` — queued `game_start` / `heartbeat` / `round_change` / `game_end` matching T5 |
|
||||
| join | `t5/scripts/tws_t5_join.gsc` | `t6/scripts/tws_t6_join.gsc` |
|
||||
| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank |
|
||||
| leaderboard | `t5/scripts/tws_t5_leaderboard.gsc` | `t6/scripts/tws_t6_leaderboard.gsc` |
|
||||
|
||||
## Player bank (T5 and T6)
|
||||
|
||||
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and
|
||||
telemetry scripts (`1.0.0` or later). Players can use `.b` to read their balance,
|
||||
`.d <amount|all>` to deposit spendable points, and `.w <amount|all>` to withdraw.
|
||||
Configured bank modules register all three commands in `.help`. Balances are
|
||||
shared by GUID across games and survive map/server restarts. Transfers do not
|
||||
change `score_total`; bank-assisted matches still count on existing leaderboards.
|
||||
|
||||
Deploy [Electric Cherry #146](https://gitea.thewrightserver.net/josh/electric-cherry/issues/146)
|
||||
first, then build/install both native plugins `1.2.0` and **restart the server
|
||||
processes**. The plugins provide `bankLoad`, `bankStore`, `bankRemove` and
|
||||
`bankResponse`. Removing the bank script is necessary to omit these linking
|
||||
requirements; disabling it by dvar does not avoid linking.
|
||||
|
||||
Electric Cherry generates a unique bank token when it prepares a native server
|
||||
instance and injects it through that instance's private
|
||||
`electric_cherry_bank.cfg`, with `tws_server_id` supplied by the launch configuration.
|
||||
Both bank scripts `2.0.0` always use `https://ec.thewrightserver.net/api/bank`;
|
||||
`tws_bank_url` is no longer read, even if an old configuration still sets it.
|
||||
The host agent's enrolled controller address is independent and continues to
|
||||
serve agent traffic. Agent `1.9.1+` writes only the private `tws_bank_token` in
|
||||
the bank configuration. Rebuilds and host moves keep the instance token;
|
||||
destroying the instance revokes it. Reusing a server name gets a new token.
|
||||
There is no `EC_BANK_TOKENS` map or per-server secret to maintain. Keep secrets
|
||||
outside shared scripts and promoted configs.
|
||||
Set `tws_bank_enabled 0` to disable the module; restart maps after script/config changes.
|
||||
Electric Cherry's `EC_BANK_MAX_BALANCE` defaults to 250000 (range 1–1000000).
|
||||
Each transfer and bank-funded spendable balance is capped at 1000000 points.
|
||||
|
||||
Transfers require a living player recorded in the server's current telemetry
|
||||
match. Newcomers may wait until the next 30-second heartbeat. The game validates
|
||||
points and player state again after a prepare reply; failed or uncertain
|
||||
prepares leave game points unchanged and cancel the reservation. After points
|
||||
move, a durable commit decision retries settlement without applying them again.
|
||||
A transfer in progress blocks new bank requests on that server. A GUID can
|
||||
have only one prepared transfer across the fleet.
|
||||
|
||||
Preserve `tws-bank/t5/` and `tws-bank/t6/`, relative to the game process working
|
||||
directory, across process/container restarts, with one process per server/endpoint
|
||||
directory. EC host agents **1.9.1+** settle
|
||||
pending journals before deleting runtime storage on removal, rebuild, migration
|
||||
or launch cancellation. Update the agents before enabling the bank. An outage
|
||||
or invalid journal blocks cleanup and preserves the files. Decisions are scoped
|
||||
to server name and URL and contain no token. Recoveries run before new bank requests;
|
||||
failed cleanup/replies retry with the original identity. Corrupt storage
|
||||
blocks the bank; failed writes prevent points changes. Restore storage or
|
||||
credentials to finish pending work, rather than deleting the journal. Holds
|
||||
have no automatic expiry. Already-applied withdrawals are match points and
|
||||
are not refunded when a match ends or its process crashes. As with any live
|
||||
score, the journal cannot make game memory and the remote database atomic
|
||||
across a machine/storage failure.
|
||||
|
||||
### Bank diagnostics — TWS #14
|
||||
|
||||
Bank scripts `2.0.2` require their matching TWS plugins `1.2.0+`. Startup logs
|
||||
report the script version, the loaded plugin's TWS version via `bankVersion()`,
|
||||
the effective endpoint, a validated server ID, credential format validation,
|
||||
and the loaded journal count. Credential validation checks the local format;
|
||||
HTTP 401/403 still identifies a credential rejected by EC. A failed journal load
|
||||
logs `pending=unknown` and disables the bank; invalid configuration reports that
|
||||
journals were not loaded. Server IDs outside the bounded log-safe token alphabet
|
||||
are shown as `invalid`/`unavailable` without changing the configured API identity.
|
||||
|
||||
Each HTTP attempt receives a local `correlation=bank-<map milliseconds>-<counter>`
|
||||
for console diagnostics, including `.b`. These IDs are scoped to a map/process
|
||||
log, never sent to EC, and never replace durable transfer IDs. Balance requests
|
||||
still omit the API request ID and require an empty response ID. Transfer logs
|
||||
also include their original request ID, linking prepare, journal and settlement.
|
||||
|
||||
Outcomes distinguish request creation, transport failure, HTTP rejection,
|
||||
missing diagnostics/response, invalid acknowledgment (with a fixed parser reason),
|
||||
request-ID mismatch, unexpected state, and valid application rejection. Available
|
||||
HTTP status, curl code and cumulative DNS/connect/TLS/total milliseconds are
|
||||
included; absent or malformed fields say `unavailable`. Only allowlisted response
|
||||
codes reach logs; unfamiliar codes say `unknown`. No token, authorization header,
|
||||
raw response/request body, transport exception or private configuration contents
|
||||
are logged. Existing point accounting, journal format, API payloads, retry delays
|
||||
and player-facing messages are unchanged.
|
||||
|
||||
Settlement lines add the durable decision, attempt count, pending seconds, next
|
||||
retry delay, cleanup failures and eventual resolution. Pending time begins when
|
||||
a new cancel decision is attempted; recovered journals measure from this map's
|
||||
load, so downtime is not included. Counters reset on map/process restart. Repeated
|
||||
identical failures per action are summarized at most every 30 seconds, with a
|
||||
`suppressed` count. Reason/status/code changes and recovery log immediately.
|
||||
Native storage logs similarly identify load/write/remove failures and recovery.
|
||||
Rate-limited attempts retain their correlation internally; only emitted lines
|
||||
appear in the console. No log history or retry metadata is added to the journal.
|
||||
|
||||
Build/install both updated DLLs and **restart each full server process**, then
|
||||
install the corresponding scripts and restart the map if they were copied after
|
||||
startup. `bankVersion()` is a new linking requirement even when the bank is
|
||||
disabled by dvar. No EC/agent change or new third-party dependency is required;
|
||||
existing bank API, common/telemetry and endpoint-migration requirements still apply.
|
||||
Verify deployed DLL and GSC SHA-256 hashes against the built release artifacts,
|
||||
and confirm startup reports `script=2.0.3 plugin=1.2.0` before interpreting results.
|
||||
An old bank script checksum on Ascension was reported in issue #14; deploying
|
||||
only the DLL does not update GSC. No deployment was performed here.
|
||||
|
||||
Run `node tools/check_bank.mjs --ec-root /path/to/electric-cherry` and
|
||||
`python3 tools/test_plugin_bank.py`. On **both T5 and T6**, compile/link and test
|
||||
successful `.b`/deposit/withdrawal, HTTP 401/403, timeout, malformed JSON/schema,
|
||||
wrong response ID, unwritable/corrupt journals and settlement recovery after a
|
||||
map/process restart. Check first/change/recovery logs, 30-second suppression,
|
||||
HTTP timings, unchanged player messages/points and one remote ledger entry per
|
||||
transfer. Keep journal files intact during failure tests. Windows DLL/GSC builds,
|
||||
installed-artifact verification and these live game checks remain required.
|
||||
|
||||
### Bank identity validation
|
||||
|
||||
Bank scripts `2.0.1` use T5's `player getGuid()` and T6's persistent
|
||||
`player.guid`, matching each game's telemetry identity source. Both normalize
|
||||
integer identities to strings and preserve existing strings exactly, including
|
||||
large or zero-prefixed GUIDs. Missing or invalid identities show a private
|
||||
"Bank player identity unavailable" message before any request, journal write
|
||||
or score change. GUIDs must meet EC's existing 1–64 character token contract
|
||||
(`A–Z`, `a–z`, `0–9`, `_`, `.`, `:`, `-`). Persisted decisions and their retries
|
||||
retain that original string identity, even after the player disconnects.
|
||||
|
||||
No new plugin or EC update is required for this fix. Install the updated bank
|
||||
script in each game's script directory and restart the map. This does not
|
||||
repair an older numeric-GUID journal: preserve any such pending decisions and
|
||||
their request IDs for scoped recovery; never delete them to unblock a rebuild.
|
||||
The endpoint upgrade procedure below still applies to legacy URLs.
|
||||
|
||||
Run `node tools/check_bank.mjs` for both scripts' portable fixtures. To also
|
||||
exercise the actual EC bank service and bank migrations with isolated SQLite:
|
||||
|
||||
```sh
|
||||
node tools/check_bank.mjs --ec-root /path/to/electric-cherry
|
||||
python3 tools/test_plugin_bank.py
|
||||
```
|
||||
|
||||
The optional EC check requires that checkout's installed `typescript` and
|
||||
`better-sqlite3` dependencies. It uses no EC configuration, credentials, network
|
||||
or production database. It covers numeric rejection, normalized balances and
|
||||
transfers, zero-balance `.w all`, cancellation, and idempotent commit replay.
|
||||
Native checks require the pinned JSON headers as described above. These tests
|
||||
do not validate game VM linking, HTTP route authentication or deployed artifacts.
|
||||
|
||||
After deployment, compare the SHA-256 hashes of both installed bank scripts
|
||||
with the source files from this release, and confirm the map restarted. On T5
|
||||
and T6, check `.b`, a deposit, a withdrawal, and `.w all` with zero bank balance
|
||||
(an insufficient-balance message with no point change). Confirm EC records
|
||||
string GUIDs matching telemetry, one ledger entry per completed transfer, and
|
||||
unchanged `score_total`. On a test server, interrupt settlement and restart the
|
||||
map/process: the original request ID and decision must settle once without
|
||||
reapplying game points. GSC compilation and these live checks remain required.
|
||||
|
||||
### Upgrading the bank endpoint
|
||||
|
||||
Deploy the Electric Cherry agent `1.9.1+` companion to
|
||||
[TWS #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13)
|
||||
before installing bank scripts `2.0.0`. It accepts journals for the public game
|
||||
endpoint and the legacy bank URL derived from that host's enrolled controller.
|
||||
It settles through the existing authenticated, instance-scoped agent API at the
|
||||
controller; it never follows journal URLs and rejects other endpoint identities.
|
||||
The instance token and server identity must stay the same through the upgrade.
|
||||
|
||||
**If the old `tws_bank_url` differs from the public endpoint, rebuild the server
|
||||
through EC with agent `1.9.1+` and the new scripts.** The agent stops the old
|
||||
process and settles its original commit/cancel decisions before starting the
|
||||
replacement and deleting old storage. A lost reply replays the same request ID;
|
||||
EC deduplicates it. An outage, corrupt journal or failed acknowledgment retains
|
||||
storage and blocks the replacement. Resolve the failure and retry the rebuild.
|
||||
Keep the enrolled controller address unchanged until legacy decisions drain.
|
||||
Do not merely replace scripts and restart the map: native journals are keyed by
|
||||
server and endpoint, so the new scripts cannot see a legacy endpoint's pending
|
||||
decisions. Do not rename, rewrite or delete those journals to force migration.
|
||||
|
||||
If the previous endpoint already was the exact public URL, install the scripts
|
||||
and restart the map; saved decisions replay under the same journal identity.
|
||||
No new plugin build or full process restart is required for that case. Other
|
||||
legacy endpoints are refused by the agent and require recovery with their
|
||||
original configuration before switching. No deployment has been performed here.
|
||||
|
||||
Run `node tools/check_bank.mjs` and `python3 tools/test_plugin_bank.py`.
|
||||
Before rollout, build/link Windows DLLs, compile GSC, and verify both games:
|
||||
help, balance and `all` commands, score HUD/persistence, max/overdraw errors,
|
||||
death/respawn/disconnect during HTTP, late telemetry, lost prepare/commit
|
||||
responses, disk failures and map/process restart with pending decisions.
|
||||
For this upgrade also verify stale URL settings are ignored, an empty `.b`
|
||||
shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal
|
||||
settles legacy and public journals without duplicate ledger entries or lost points.
|
||||
|
||||
## Core HUD and service window (T5 and T6)
|
||||
|
||||
The core uses a light, unboxed layout: match time above round time at the upper
|
||||
left, `TWS ZOMBIES | discord.tws.lol` centered along the top, and the zombie
|
||||
count centered near the bottom above a slim health bar. The complete
|
||||
`N ZOMBIES` text uses one centered element at the health track's midpoint,
|
||||
keeping the group centered as the count gains or loses digits. It uses a
|
||||
uniform font scale of `1.25` and shows `-- ZOMBIES` before data is available.
|
||||
Current/maximum HP appears to the right of the bar only while injured, then
|
||||
disappears at full health. The AFK reserve countdown is independently centered on screen. Text
|
||||
uses an outline for contrast; there are no panel backgrounds or timer labels.
|
||||
|
||||
Fullscreen X/Y anchors bypass safe-area insets. Overlay positions and shader
|
||||
widths use the normalized `640 × 480` viewport directly. Text uses a minimum
|
||||
font scale of `1.0`. Check the upper-left timers
|
||||
against stock map inventory/objectives and performance overlays, and keep the
|
||||
upper-right service area below the Plutonium tag. Physical proportions vary
|
||||
with aspect ratio, so verify text fit and screen-edge clearance in-game.
|
||||
|
||||
The core is created once per connection and stays in place when services open,
|
||||
across death and respawn, and while newcomers spectate. Unavailable values use
|
||||
placeholders until the first-spawn callback binds the data displays. Creation
|
||||
does not start the match clock. Each engine retains its timer behavior; clocks,
|
||||
health and zombie-count displays freeze at game over. Health display fallbacks
|
||||
never modify gameplay max health.
|
||||
|
||||
Type `.hud` in chat to hide or show your core, including its branding, timers,
|
||||
zombie count, health bar and damage-only HP. The AFK countdown stays visible. The exact command
|
||||
is case-insensitive. Visibility defaults on and persists across death/respawn
|
||||
within the current connection/map; reconnecting or restarting the map restores
|
||||
the visible default. Data and timers continue updating while hidden, so showing
|
||||
the HUD restores current values or the frozen game-over values. Help, records
|
||||
and the stock HUD remain unaffected.
|
||||
|
||||
Common owns one unboxed service area at the upper right. `.help` and its
|
||||
`.commands` alias show installed commands in pages of up to nine rows with command
|
||||
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
|
||||
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
|
||||
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
|
||||
restriction. Additional registered commands use pages of nine rows; a page
|
||||
count appears only when more than one page is needed. Each page lasts
|
||||
12 seconds and the list closes after the final page. Repeating either help
|
||||
command restarts page one. Both engines position rows explicitly, avoiding
|
||||
T5's newline-rendering problem.
|
||||
|
||||
Records use the same service area, right aligned: `Map record | Round N`
|
||||
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
|
||||
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
|
||||
with a fresh replay from page one. Round-start reminders use just the record
|
||||
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
|
||||
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
|
||||
counter; records use four, or five with a multi-page counter. Hidden elements
|
||||
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.
|
||||
|
||||
HUD `1.4.2` requires matching common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) on both games.
|
||||
The centered counter uses `SetTextUnlimited` only when the count changes,
|
||||
avoiding dynamic counter strings in the text config-string pool. Numeric HP
|
||||
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
|
||||
the map; no full server-process restart is required. On older Plutonium
|
||||
revisions, update clients and server and fully restart them before deploying.
|
||||
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
|
||||
leaderboard plugin requirements still apply. Common retains its older panel
|
||||
helpers for unchanged consumers; the new layout uses fullscreen overlay helpers
|
||||
with the same integer shader-width handling.
|
||||
|
||||
### HUD validation
|
||||
|
||||
Run `node tools/check_hud.mjs` for portable pagination, service arbitration,
|
||||
reminder rendering, request replacement and cleanup checks, and
|
||||
`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
|
||||
command/lifecycle regressions. These
|
||||
checks use engine shims and do not prove GSC VM behavior. Follow the
|
||||
[T6 compiler instructions](t6/scripts/README.md#validation); T5 requires a live
|
||||
Plutonium server because gsc-tool has no T5 backend.
|
||||
|
||||
Before rollout, validate both engines in Plutonium:
|
||||
|
||||
1. Check 16:9 and narrower/wider resolutions, safe-area settings, long match
|
||||
timers, stock map inventory/objectives, performance overlays, player scores,
|
||||
ammo and interaction prompts. Check upper-left timers, top-center branding,
|
||||
bottom-center zombies/health and the upper-right service area. Text must stay
|
||||
legible without panel backgrounds; services must clear the Plutonium tag.
|
||||
Verify the health-bar fill stays within its track and damage-only HP clears
|
||||
stock scores/ammo. Check text fit at other aspect ratios because horizontal
|
||||
geometry scales with viewport width. Check `0`, `6`, `9`, `10`, `99` and
|
||||
`100 ZOMBIES`: the entire text must stay centered over the health track,
|
||||
including after `.hud` hides/shows it and after game over freezes the count.
|
||||
2. Join before spawning, join mid-match, die/respawn and reconnect. Verify one
|
||||
core per player, spectator placeholders, no early match-clock start, correct
|
||||
health/Juggernog changes, and frozen timers at game over. HP numbers must
|
||||
appear only while injured and disappear on full healing. Toggle AFK and back
|
||||
without overlapping zombies/health or changing existing protection behavior.
|
||||
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
|
||||
the installed commands including AFK balance/buy, full bank syntax and a page counter and
|
||||
automatic dismissal after 12 seconds. Add custom registered commands to
|
||||
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
|
||||
Remove optional modules and restart the map to confirm filtering, including
|
||||
`.hud` disappearing without HUD, then restore them.
|
||||
4. Interrupt records with help and check that the interrupted page resumes for
|
||||
its remaining time. Repeat `.leaders` while help is open; only the latest
|
||||
replay should start, at page one. Check long holder names, ties spanning
|
||||
pages, no records, and unavailable records. Repeat without the HUD installed.
|
||||
5. Disconnect or end the game while help/records are active, reconnect and
|
||||
request them again. Verify no duplicate elements, stale timeout closures or
|
||||
surviving service windows. Confirm `.join`, bank commands and lobby lock
|
||||
retain their existing behavior with their required modules installed.
|
||||
6. Toggle `.hud` and `.HUD` before spawning, during play, while AFK and after
|
||||
game over. Check private visibility, all core elements and the independently visible AFK countdown,
|
||||
continued updates while hidden, and unchanged help/records/stock HUD. Verify
|
||||
death/respawn preserves the choice and reconnect/map restart restores visibility.
|
||||
7. On T5, watch the first automatic record announcement and its dismissal, then
|
||||
repeat `.leaders`, help and interrupted/resumed records. Verify the core
|
||||
health bar, damage-only HP, zombie count and timers remain intact while a
|
||||
service is visible and after it closes. Include stock round/connection
|
||||
notifications and other installed HUD mods; portable allocation checks do
|
||||
not establish live T5 SP rendering capacity.
|
||||
|
||||
## Map record announcements
|
||||
|
||||
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
|
||||
**along with the updated common module** for that game.
|
||||
The new module privately announces the current map's fleet-wide highest recorded
|
||||
**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
|
||||
round and every tied holder once per connection, including spectator newcomers.
|
||||
Type `.leaders` to replay the record privately from the latest usable cache.
|
||||
Repeating the command restarts the announcement from its first page. It appears
|
||||
in `.help` when the leaderboard module is enabled. If no usable record is
|
||||
available, the command asks you to try again shortly.
|
||||
available, the service identifies the record as unavailable; this differs from
|
||||
a valid empty record history. Neither state adds a replay footer.
|
||||
It reads Electric Cherry's public `/api/leaderboard` endpoint without a token;
|
||||
only completed matches contribute. Ties may come from the same run or separate
|
||||
runs. Names are the API's latest player names, with players identified by GUID.
|
||||
@@ -203,13 +526,44 @@ exist in the API's map list and match the current game and engine map. Clear it
|
||||
when changing variants; restart the map after changing these settings.
|
||||
|
||||
The announcement starts five seconds after player readiness and waits for the
|
||||
initial record fetch if necessary. It appears in the same HUD area as `.help`,
|
||||
below the health bar, with a heading such as `Record: Round 23` and holder names
|
||||
underneath. Each page shows up to three holder lines for 12 seconds. Opening
|
||||
`.help` hides the record and pauses its display time until help closes.
|
||||
All holders are included; colour/control codes are removed, and unusually long
|
||||
names are shortened to keep messages readable. HUD elements are removed when
|
||||
the announcement finishes, the player disconnects, or the game ends.
|
||||
initial record fetch if necessary. It appears as right-aligned text in the
|
||||
common-owned upper-right service area: `Map record | Round N`, followed by up
|
||||
to three holder lines. There is no background or replay footer, and a page
|
||||
count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining
|
||||
page time until help closes. A `.leaders` request during help replaces any
|
||||
pending replay and starts from page one after help closes.
|
||||
All holders are included; colour/control codes are removed, and overlong
|
||||
individual names are abbreviated without splitting UTF-8 characters. Bounded
|
||||
lines and additional pages preserve every tied holder. The window closes when
|
||||
the announcement finishes. Both games release their view elements when idle or
|
||||
changing service kinds. Disconnect or game end cleans up any remaining service
|
||||
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
|
||||
the highest-round tie ends. Successful snapshots refresh every five minutes.
|
||||
@@ -243,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
|
||||
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)
|
||||
|
||||
Both reporters default to `https://ec.thewrightserver.net/api/telemetry` and send
|
||||
@@ -281,3 +648,23 @@ powershell -File t6/scripts/tools/check.ps1 -CompilerPath C:\path\to\gsc-tool.ex
|
||||
```
|
||||
|
||||
Requires PowerShell and `gsc-tool` (T6 PC).
|
||||
|
||||
## Player moderation (T5 and T6)
|
||||
|
||||
See [Moderation runtime](docs/moderation.md) for the instance credential contract,
|
||||
policy persistence, command-result limits and deployment validation. New
|
||||
`tws_t5_moderation.gsc` / `tws_t6_moderation.gsc` 1.0.0 require the corresponding
|
||||
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
|
||||
configuration it stays disabled. Existing bank and telemetry credentials are
|
||||
never reused. Native plugin updates require a full game-server process restart.
|
||||
|
||||
## Persistent AFK reserves (T5 and T6)
|
||||
|
||||
See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior,
|
||||
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+`,
|
||||
Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart
|
||||
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.
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
# Persistent AFK reserve
|
||||
|
||||
AFK `2.0.0` replaces unlimited protected breaks on both T5 and T6. A GUID owns
|
||||
one free allowance and saved reserve across servers, games, maps and reconnects.
|
||||
Electric Cherry owns balances, policy, purchase transactions and the time ledger.
|
||||
The game owns verified contribution events, player commands, countdown rendering,
|
||||
protection and accepted stop decisions. This uses the existing bank account and
|
||||
private instance credential, not a second points bank.
|
||||
|
||||
## Commands and behavior
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `.afk` | Reserve available time, acknowledge its start, then enter protected AFK |
|
||||
| `.back` | Stop time accounting and return, preserving the existing two-second manual grace |
|
||||
| `.afk balance` | Show free, saved and currently reserved seconds as a clock |
|
||||
| `.afk buy 10` | Quote ten minutes and its bank-point price |
|
||||
| `.afk buy 10 confirm` | Accept that quote within 30 seconds |
|
||||
|
||||
Return with `.back` before purchasing; an existing reservation never extends
|
||||
automatically. Purchases require whole policy packs; the initial pack is 10 minutes for 10,000
|
||||
points. There is no automatic purchase. EC atomically rejects insufficient funds,
|
||||
pending bank transfers, changed quotes and over-capacity purchases without a
|
||||
point debit. A submitted purchase retains its ID until EC acknowledges it.
|
||||
|
||||
The central countdown and “Protection ends when this timer reaches zero.” remain
|
||||
visible even with `.hud` hidden. The final minute is larger and red; warnings
|
||||
sound at 60, 30, and each of the final ten seconds. At zero, AFK protection ends
|
||||
immediately, including in the damage callback; the script restores its own controls,
|
||||
weapons and zombie-targeting state. Expiration has no spectator transition, purchase
|
||||
or extra grace. Repeating `.afk` does not restart the timer or reserve twice.
|
||||
|
||||
AFK `2.1.0` allows solo players and the last active player to enter; all players
|
||||
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
|
||||
callback passes through when it does not own protection, preserving stock game
|
||||
invulnerability. Other mods that restrict controls should set
|
||||
`player.tws_afk_external_controls` while their restriction applies.
|
||||
|
||||
AFK leaves player session state unchanged, so stock player-count zombie scaling
|
||||
continues to include AFK players. Losing the last non-AFK teammate does not end
|
||||
protected breaks or force a match end. Authorized time continues to count down;
|
||||
normal expiration and stock match-end cleanup still apply. Verify these rules
|
||||
on each deployed map/mode.
|
||||
|
||||
## Rewards
|
||||
|
||||
Initial EC policy:
|
||||
|
||||
| Source | Time |
|
||||
| --- | --- |
|
||||
| Free allowance | 30 minutes per rolling 24 hours |
|
||||
| Five minutes of active play | 1 minute |
|
||||
| Completed round with participation | 15 seconds |
|
||||
| 100 kills | 30 seconds |
|
||||
| 25 headshot kills | 15 seconds |
|
||||
| Each successful teammate revive | 30 seconds |
|
||||
|
||||
Saved earned and purchased time has a combined six-hour capacity and no expiry.
|
||||
Free time is separate, non-accumulating and consumed first. EC retains fractional
|
||||
reward progress across reconnects. Round/kill/headshot rewards share eight minutes
|
||||
per rolling active hour; headshots stack with kills. Active-play rewards and
|
||||
**every successful revive** are outside that cap. There is no per-teammate,
|
||||
per-match or hourly revive limit. Retrying a report cannot award it again.
|
||||
|
||||
Both engines consume stock `zom_kill`, `player_revived` and `end_of_round`
|
||||
notifications. T5 also attaches to stock hellhound spawn/death callbacks because
|
||||
those enemies omit `zom_kill`; T6 hellhounds already emit the stock zombie death
|
||||
event. Completed round numbers are deduplicated locally. Headshots require a lethal head/helmet hit with a qualifying damage
|
||||
type. No reward uses spendable score or `score_total`. A round requires a kill,
|
||||
revive, or at least 30 seconds of active engagement and eligibility at completion.
|
||||
|
||||
The one-second activity sampler uses movement over four units, a view change over
|
||||
two degrees or a changed input button to refresh a 15-second engagement interval.
|
||||
The stock `is_reviving` state counts support; holding a zombie that targets
|
||||
the player within 512 units counts while deliberate input occurred in the last
|
||||
60 seconds. Kill/revive events also refresh engagement. AFK, dead and spectator
|
||||
time do not earn rewards. Contributions after manual `.back` count immediately,
|
||||
including during its separate two-second grace. This permits support and zombie
|
||||
holding without a minimum kill rate, while passive connection alone earns nothing.
|
||||
|
||||
Stock integration was checked against
|
||||
[T5 scripts 70689b8](https://github.com/plutoniummod/t5-scripts/tree/70689b8288e60079d48926657e670c41aab4abdf)
|
||||
and [T6 scripts 2d84403](https://github.com/plutoniummod/t6-scripts/tree/2d8440321602f22f63f5335817110d60ddf223d5).
|
||||
Recheck callback contracts if the stock runtime changes.
|
||||
|
||||
## Configuration and coordinated rollout
|
||||
|
||||
1. Back up EC and the game runtime journals. Deploy EC #151 and restart its service.
|
||||
2. Update all EC host agents to **1.12.0+** before enabling these scripts. This
|
||||
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
|
||||
**2.1.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and
|
||||
Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes.
|
||||
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.
|
||||
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.
|
||||
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `tws_afk_enabled` | enabled | `0` disables new AFK service startup |
|
||||
| `tws_afk_cooldown_seconds` | `15` | Reentry cooldown, clamped to 2–300 seconds |
|
||||
| `tws_afk_warning_sound` | `zmb_perks_packa_ready` | Local warning sound alias; verify on deployed maps |
|
||||
|
||||
Economic configuration lives in EC's `EC_AFK_*` environment settings. See the
|
||||
[EC AFK guide](https://gitea.thewrightserver.net/josh/electric-cherry/src/branch/main/docs/guides/afk.md)
|
||||
for exact rates, rolling-window accounting and JSON schemas. The fixed game
|
||||
endpoint is `https://ec.thewrightserver.net/api/afk`; requests reuse the private
|
||||
`ec_bank_` credential. Shared telemetry tokens cannot spend or reserve time.
|
||||
|
||||
## API and recovery ownership
|
||||
|
||||
The matching native API `1.0.0` exposes `afkVersion`, `afkResponse`, `afkStore`,
|
||||
`afkLoad`, `afkRemove`, `afkPrepare`, `afkAuthorize`, `afkBegin`, `afkRemaining`,
|
||||
`afkCheckpoint`, `afkStop`, and `afkRecover`. Responses must validate their complete
|
||||
schema and request/session identity before authorization takes effect. Requests
|
||||
are immutable; journal writes precede network transmission and native activation.
|
||||
|
||||
EC actions are `balance`, `event`, `purchase`, `reserve`, `start` and `settle`.
|
||||
Events carry cumulative counters scoped to GUID, instance and match, with a
|
||||
monotonic sequence. The native journal coalesces newer cumulative event snapshots
|
||||
to bound outage queues; EC prevents retries and reordered reports awarding twice.
|
||||
Revive counts still increase for every distinct successful revive. Kill/active
|
||||
progress is flushed at most every 30 seconds and on normal disconnect/end; a hard
|
||||
process crash can lose progress that had not yet reached the local journal.
|
||||
Already accepted rewards and journaled snapshots persist.
|
||||
|
||||
Protection starts only after reservation and start acknowledgments, a final
|
||||
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.
|
||||
Manual return, disconnect, death and match end stop accounting immediately. A
|
||||
failed stop-journal write freezes the original stop time for retry rather than
|
||||
charging during the delay. Never replay a reserve/start from a background sender.
|
||||
|
||||
Before activation, an interrupted session recovers as `never_started` with zero
|
||||
consumption. Its tombstone also blocks delayed reserve/start delivery. After
|
||||
activation, a crash with no durable stop becomes `unresolved`, preserving its last
|
||||
confirmed checkpoint and full reservation. Absence of a heartbeat never releases
|
||||
spendable time. An administrator must reconcile such a stopped session using EC's
|
||||
audited correction endpoint; corrected charges remain distinct from exact durations.
|
||||
Do not delete journals or change IDs to unblock a hold.
|
||||
|
||||
The EC agent independently proves the old process is stopped before recovering
|
||||
journals during remove/rebuild/cancel-create. It sends requests only to its enrolled
|
||||
controller using the assigned teardown operation and host credential. Lost replies,
|
||||
unrecognized endpoints or malformed journals preserve runtime storage for retry.
|
||||
|
||||
## Validation
|
||||
|
||||
Portable checks (engine shims are not live Plutonium):
|
||||
|
||||
```sh
|
||||
node tools/check_afk.mjs
|
||||
python3 tools/test_plugin_afk.py --json-header /path/to/pinned/json.hpp
|
||||
node tools/check_bank.mjs
|
||||
node tools/check_hud.mjs
|
||||
node tools/check_telemetry.mjs
|
||||
```
|
||||
|
||||
Use each plugin's pinned JSON header; the native harness also accepts a path with
|
||||
`{game}` for different T5/T6 dependency revisions. It compiles native utilities
|
||||
and actual registration adapters with ASan/UBSan, then exercises separate-process
|
||||
journal recovery. T6 source compilation follows its [script instructions](../t6/scripts/README.md#validation).
|
||||
T5 linking and behavior require a live server.
|
||||
|
||||
Before production activation, validate **both engines**:
|
||||
|
||||
- Enter/return/expire, reconnect, die, change maps and restart the process; compare
|
||||
the displayed countdown and EC balances/ledger. At expiry attempt moving, firing,
|
||||
throwing equipment and receiving zombie damage immediately, including `.hud` off.
|
||||
- Try T5 entry while downed, then after a successful revive. Check T6 laststand,
|
||||
afterlife, intermission and stock control restrictions; no stock protection should
|
||||
be removed. Repeat rapid `.afk`/`.back` and concurrent commands during slow replies.
|
||||
- Earn each reward; revive the same teammate repeatedly beyond the performance cap.
|
||||
Verify passive, spectating, AFK and bank point changes earn nothing. Test support
|
||||
and zombie holding. Carry partial progress across maps/servers/T5/T6.
|
||||
- Confirm the displayed purchase price; try expired/changed quotes, insufficient
|
||||
bank funds, pending bank transfers and capacity overflow. Drop purchase replies
|
||||
and retry without a second debit. Attempt simultaneous reservations on two servers.
|
||||
- Disconnect EC after authorization; the countdown must continue and expire locally.
|
||||
Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and
|
||||
restart. Verify never-started refunds, durable exact stops and unconfirmed crash
|
||||
holds. Correct only a proven stopped session and confirm the ledger audit.
|
||||
- Enter as the only connected player and as the last active player with downed,
|
||||
dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the
|
||||
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,
|
||||
interrupt the agent after a lost reply, and retry. Runtime deletion must wait for
|
||||
acknowledged recovery and cannot authorize a new purchase.
|
||||
|
||||
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
|
||||
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
|
||||
DLL build, production deployment or live T5/T6 validation is implied by these checks.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Moderation runtime
|
||||
|
||||
T5 and T6 use the same protocol and native policy core, with separate native
|
||||
chat opcodes and existing engine symbols. Install the game's new moderation
|
||||
script 1.0.0, common 1.2.0, and native plugin 1.3.0. The controller must implement
|
||||
`POST /api/moderation/runtime` before enabling the scripts.
|
||||
|
||||
## Private configuration
|
||||
|
||||
Each instance receives its own non-shared credential. Keep this configuration
|
||||
out of shared scripts, release artifacts, ordinary config exports, and logs:
|
||||
|
||||
```cfg
|
||||
set tws_server_id "registered-server"
|
||||
set tws_moderation_url "https://controller.example/api/moderation/runtime"
|
||||
set tws_moderation_token "ec_moderation_REPLACE_WITH_INSTANCE_SECRET"
|
||||
set tws_moderation_generation "1"
|
||||
```
|
||||
|
||||
Set `tws_moderation_enabled "0"` to disable the runtime. Missing or invalid
|
||||
configuration leaves it waiting; it retries every three seconds so private configuration can be installed without a map restart. Disabling stops synchronization and enforcement; it does not revoke controller sanctions or promise to restore existing engine mutes. HTTPS is mandatory; redirects are not followed.
|
||||
The generation must match the controller's live instance. Only that instance's
|
||||
credential authorizes its roster, receipts and policy; the bank and telemetry
|
||||
credentials cannot authorize moderation.
|
||||
|
||||
## Synchronization
|
||||
|
||||
Native HTTP runs outside the game thread with 3-second connection and 5-second
|
||||
transfer deadlines. Polls start at most once per 3 seconds; only one poll is in
|
||||
flight. Responses are limited to 1 MiB at the transport (oversize fails without
|
||||
truncation), 1000 sanctions and 100 actions. The native cache never crosses a GSC
|
||||
string boundary. Requests include protocol 1, server, generation, runtime session, sessionSequence,
|
||||
players `{guid,name,connection}`, capabilities, appliedRevision and up to 256
|
||||
receipts. Sessions change on map/process startup. A positive safe-integer sessionSequence is incremented and atomically persisted before each new runtime starts; the controller rejects older sequences and conflicting sessions at the same sequence. Repeated unchanged configuration preserves the current sequence. Never share an instance credential or its cache directory between simultaneous game-server processes. Connections change on every
|
||||
native connect/disconnect event, even when the same GUID reuses the same slot.
|
||||
GUIDs remain strings; pure decimal IDs have leading zeroes removed without a numeric conversion; the T5 script reads getGuid and the T6 script reads the
|
||||
stock persistent guid field. GUIDs are limited to 128 ASCII identifier bytes. Roster names are bounded to 255 UTF-8 bytes; malformed name bytes become question marks and ASCII controls become spaces. Blank names, including Unicode whitespace-only names, become Unknown player. Names never select an action target.
|
||||
|
||||
Responses contain `{ok,revision,serverTime,sanctions,actions,pollAfterMs}`.
|
||||
Sanctions contain `{id,revision,guid,kind,reason,expiresAt}`, with `kind` ban/mute
|
||||
and nullable expiry. Actions contain `{id,guid,connection,kind,reason,expiresAt}`;
|
||||
kind is warn/message/kick/announce. Only announce uses null GUID and connection.
|
||||
Deadlines and policy expiry are UTC ISO timestamps. Clock skew over two minutes
|
||||
rejects an update. Received server time is anchored to monotonic elapsed time without moving backward between polls, conservatively including the full transfer age and a one-second timestamp rounding margin, so a slow or backward-jumping host clock cannot extend one-time deadlines. This may expire an action or timed policy slightly early. Deadlines are checked again after writing the action journal and before dispatch. Revision rollback, inconsistent policy at the same revision,
|
||||
malformed data, duplicate IDs and changed action content reject an update.
|
||||
Removing sanctions already expired according to the validated serverTime is permitted without a revision increment.
|
||||
The current client uses its fixed 3-second poll interval after validating the
|
||||
server's pollAfterMs bounds (1000–60000).
|
||||
|
||||
## Enforcement and recovery
|
||||
|
||||
Keep `tws-moderation/t5/` or `tws-moderation/t6/`, relative to the game process
|
||||
working directory, on persistent writable storage. Files are scoped to server,
|
||||
endpoint, instance generation and SHA-256 credential fingerprint and do not contain the credential. Complete
|
||||
snapshots replace files atomically. Every write verifies ownership of the current session sequence so a superseded runtime cannot roll the counter or policy backward. Corrupt cache disables startup and preserves
|
||||
the file; storage failures are logged and block new effectful actions. Cache loss must be recovered by rebuilding the controller instance generation and issuing a new private credential; resetting or deleting a cache under an existing credential cannot reset the controller's highest session sequence. Retired
|
||||
instance generations cannot inherit a newer generation's policy cache.
|
||||
|
||||
Cached bans and mutes continue during controller outages and expire using the
|
||||
host wall clock until synchronized, then the later of host time and the monotonic server-time estimate. After a restart without connectivity, host clock drift remains an operator responsibility. Ban enforcement is a kick after connection, checked through the
|
||||
shared connected dispatcher and a 500 ms loop; it is not a pre-authentication
|
||||
admission hook. Still-connected banned players are checked again every five
|
||||
seconds. Removing the last applicable mute restores clients muted by this
|
||||
runtime; unrelated manually applied mutes are not deliberately cleared. Managed
|
||||
mute ownership survives process/map restart so revoked cached mutes can be
|
||||
removed when the affected player returns. No native ban.txt is written.
|
||||
|
||||
One-time actions require the exact current connection and unexpired deadline.
|
||||
The native game-thread adapter validates the target and executes immediately;
|
||||
GSC's buffered executeCommand is never used. Only fixed kick/mute/unmute command
|
||||
templates are available; untrusted text cannot add delimiters, quotes, line
|
||||
breaks or backslash escapes. Visible text is bounded to 240 UTF-8 bytes without splitting codepoints. Messages use direct per-game server message calls.
|
||||
|
||||
Before one-time dispatch, the action and an unknown receipt are saved durably.
|
||||
Duplicate IDs never execute again, including after restart. A crash between
|
||||
journal write and dispatch may therefore lose a one-time action; its result
|
||||
stays unknown instead of risking a duplicate kick. Journal entries expire from
|
||||
storage only after receipt acknowledgment and at least a day past their action
|
||||
deadline. The journal is bounded at 10000 entries; saturation stops new actions
|
||||
and requires controller delivery/storage investigation.
|
||||
|
||||
A confirmed private-message receipt means the game server dispatched it, not
|
||||
that the player read it. A kick is confirmed only if its synchronous dispatch
|
||||
observes the native disconnect callback; otherwise the result is unknown.
|
||||
Native mute commands have no readback, so appliedRevision means the policy is
|
||||
cached and being enforced, not independently verified voice/text suppression.
|
||||
Do not describe a cached policy revision as proven enforcement.
|
||||
|
||||
## Validation and rollout
|
||||
|
||||
Portable checks:
|
||||
|
||||
```sh
|
||||
node tools/check_moderation.mjs
|
||||
python3 tools/test_plugin_moderation.py
|
||||
python3 tools/test_plugin_http.py
|
||||
python3 tools/test_plugin_component.py
|
||||
```
|
||||
|
||||
The native moderation runner accepts `--json-header '/path/{game}-json.hpp'`
|
||||
when dependencies have not been restored. Use each plugin's pinned JSON revision.
|
||||
It compiles the actual core and registration/HTTP bridge against minimal engine
|
||||
shims with ASan/UBSan, including separate process persistence tests. These checks
|
||||
do not validate Windows linking or the Plutonium ABI.
|
||||
|
||||
Build both x86 DLLs using the existing Windows build workflow. Deploy the new
|
||||
plugins and scripts together, then fully restart each game-server process; a map
|
||||
restart alone cannot load the native methods. Test in isolated T5 Zombies and T6
|
||||
Zombies instances before fleet rollout: duplicate names, same-account reconnect,
|
||||
slot reuse, kick command/reason behavior, text and voice mute/unmute, reconnect
|
||||
and map/process restart, expiry, revocation, controller outage, credential
|
||||
revocation, truncated/oversized responses and disk failure. Verify clientkick and
|
||||
muteclient command support in the deployed Plutonium revisions. If unsupported,
|
||||
keep moderation disabled; this change has no automatic production deployment.
|
||||
@@ -101,3 +101,39 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
|
||||
The localhost tests compile the actual HTTP utility against the host libcurl.
|
||||
They do not validate Windows linking, its Schannel backend or game VM notification
|
||||
handling. Those checks require the Windows build and a running Plutonium server.
|
||||
|
||||
## Bank support (TWS 1.1.0)
|
||||
|
||||
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
|
||||
decisions and strict response validation for the new bank script. Build and
|
||||
install the DLL, then restart the full process. No upstream pins or external
|
||||
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py`.
|
||||
|
||||
## Bank diagnostics (TWS 1.2.0)
|
||||
|
||||
`bankVersion()` returns the loaded DLL's TWS component version, independently of
|
||||
upstream pins and the native loader's unchanged `plugin_version()` value.
|
||||
`bankResponse()` now also returns `reason`: `ok` on success, otherwise one of
|
||||
`empty_body`, `body_limit`, `invalid_json`, `schema`, `field_limit`, `state`,
|
||||
`amount_range`, `ok_state`, `missing_id`, or `zero_transfer`. Invalid responses
|
||||
clear all parsed fields. Existing acknowledgment acceptance rules are unchanged.
|
||||
The returned `code` on valid responses remains API data; bank scripts allowlist
|
||||
it before logging. Callers must never log arbitrary response fields.
|
||||
|
||||
Native journal logs name load/write/remove, limit identical failures to one
|
||||
per 30 seconds, and log recovery with suppressed counts. Exception text, journal
|
||||
contents and credentials are excluded. The new scripts require this DLL even
|
||||
when disabled by dvar because `bankVersion()` must link. No new third-party
|
||||
dependencies or upstream pin changes are required. Rebuild/install the DLL and
|
||||
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
|
||||
storage and native registration adapter with sanitizers and engine shims.
|
||||
|
||||
## AFK journal API (`1.4.0+`)
|
||||
|
||||
The AFK component exports API `1.0.0` for immutable requests, reservations,
|
||||
monotonic countdowns, durable stopped-session decisions and crash recovery.
|
||||
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
|
||||
credential, uses a separate `tws-afk` journal, and never stores credentials in
|
||||
that journal. Installing this native change requires a full process restart.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/afk.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
|
||||
{ return {"tws-afk/t5",server,endpoint}; }
|
||||
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
|
||||
static std::array<status,9> states;
|
||||
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
|
||||
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
|
||||
state.failed = failed; state.logged = now; state.suppressed = 0;
|
||||
}
|
||||
template <typename F> bool mutate(size_t operation, F action)
|
||||
{
|
||||
try { action(); storage_result(operation,false); return true; }
|
||||
catch (...) { storage_result(operation,true); return false; }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
|
||||
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server,endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0,false); return entries;
|
||||
}
|
||||
catch (...) { storage_result(0,true); return {}; }
|
||||
});
|
||||
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
|
||||
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
|
||||
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
|
||||
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
|
||||
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
|
||||
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
|
||||
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return spool(server,endpoint).remaining(id); });
|
||||
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
|
||||
catch (...) { storage_result(6,true); return -1; }
|
||||
});
|
||||
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
|
||||
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
|
||||
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
|
||||
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
|
||||
gsc::function::add("afkresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::afk::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid; result["reason"] = ack.reason;
|
||||
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
|
||||
result["session_id"] = ack.session_id;
|
||||
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
|
||||
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
|
||||
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
|
||||
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
|
||||
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
|
||||
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(afk::component)
|
||||
@@ -0,0 +1,98 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/bank.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
|
||||
{
|
||||
return {"tws-bank/t5", server, endpoint};
|
||||
}
|
||||
|
||||
// Only fixed operation names are printed, never exception text or paths
|
||||
// from private configuration. Per-operation state has bounded memory.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status
|
||||
{
|
||||
bool failed = false;
|
||||
std::chrono::steady_clock::time_point logged{};
|
||||
unsigned suppressed = 0;
|
||||
};
|
||||
static std::array<status, 3> states;
|
||||
constexpr const char* names[] = {"load", "write", "remove"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30))
|
||||
{
|
||||
++state.suppressed;
|
||||
return;
|
||||
}
|
||||
if (failed || state.failed)
|
||||
printf("[tws_bank] native_storage operation=%s outcome=%s suppressed=%u\n",
|
||||
names[operation], failed ? "storage_failure" : "recovered", state.suppressed);
|
||||
state.failed = failed;
|
||||
state.logged = now;
|
||||
state.suppressed = 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("bankversion", []() { return std::string("1.2.0"); });
|
||||
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server, endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id;
|
||||
entry["decision"] = value.decision;
|
||||
entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0, false);
|
||||
return entries;
|
||||
}
|
||||
catch (...) { storage_result(0, true); return {}; }
|
||||
});
|
||||
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{
|
||||
try { spool(server, endpoint).store(body); storage_result(1, false); return true; }
|
||||
catch (...) { storage_result(1, true); return false; }
|
||||
});
|
||||
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { spool(server, endpoint).erase(id); storage_result(2, false); return true; }
|
||||
catch (...) { storage_result(2, true); return false; }
|
||||
});
|
||||
gsc::function::add("bankresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::bank::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid;
|
||||
result["reason"] = ack.reason;
|
||||
result["state"] = ack.state;
|
||||
result["code"] = ack.code;
|
||||
result["request_id"] = ack.id;
|
||||
result["amount"] = ack.amount;
|
||||
result["balance"] = ack.balance;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
REGISTER_COMPONENT(bank::component)
|
||||
@@ -0,0 +1,161 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "scripting.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "command.hpp"
|
||||
#include "game/game.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/moderation.hpp>
|
||||
#include <utils/http.hpp>
|
||||
#include <utils/cryptography.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
namespace moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using runtime = utils::moderation::runtime;
|
||||
struct transfer
|
||||
{
|
||||
std::string session, response;
|
||||
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
|
||||
std::atomic<bool> done{false};
|
||||
bool success = false;
|
||||
};
|
||||
std::shared_ptr<runtime> current;
|
||||
std::shared_ptr<transfer> pending;
|
||||
std::string endpoint, credential, configured_server;
|
||||
int configured_generation = 0;
|
||||
std::chrono::steady_clock::time_point next_poll{}, next_log{};
|
||||
bool fault = false;
|
||||
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
|
||||
void failure()
|
||||
{
|
||||
if (std::chrono::steady_clock::now() >= next_log)
|
||||
{
|
||||
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
|
||||
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
}
|
||||
}
|
||||
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
|
||||
std::string execute(const std::string& kind, int slot, const std::string& reason)
|
||||
{
|
||||
auto active = current;
|
||||
if (!active) return "failed";
|
||||
// This function is called only by the server-thread GSC pump. No queue/yield
|
||||
// separates runtime target validation from synchronous engine dispatch.
|
||||
if (kind == "announce" || kind == "warn" || kind == "message")
|
||||
{
|
||||
const auto text = std::string("h \"") + reason + "\"";
|
||||
game::SV_GameSendServerCommand(slot, 0, text.c_str());
|
||||
return "confirmed";
|
||||
}
|
||||
if (slot < 0 || slot >= 64) return "failed";
|
||||
if (kind == "mute" || kind == "unmute")
|
||||
{
|
||||
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
|
||||
return "unknown"; // Engine has no mute-state readback in this adapter.
|
||||
}
|
||||
if (kind != "kick") return "failed";
|
||||
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
|
||||
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
|
||||
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
|
||||
}
|
||||
void pump()
|
||||
{
|
||||
if (!current || fault) return;
|
||||
try
|
||||
{
|
||||
if (pending && pending->done.load())
|
||||
{
|
||||
auto completed = pending; pending.reset();
|
||||
if (completed->session == current->session)
|
||||
{
|
||||
if (completed->success)
|
||||
{
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
|
||||
current->accept(completed->response, now(), age);
|
||||
}
|
||||
else failure();
|
||||
}
|
||||
}
|
||||
auto active = current;
|
||||
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
|
||||
{
|
||||
return current == active ? execute(kind, slot, reason) : std::string("failed");
|
||||
});
|
||||
if (current != active) return;
|
||||
if (!pending && std::chrono::steady_clock::now() >= next_poll)
|
||||
{
|
||||
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
|
||||
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
|
||||
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
scheduler::thread_pool.push([work, request, target, token]
|
||||
{
|
||||
try
|
||||
{
|
||||
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
|
||||
limits.max_response_bytes = 1024 * 1024;
|
||||
const auto response = utils::http::get_data(target, request,
|
||||
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
|
||||
if (response && response->code == CURLE_OK && response->status == 200)
|
||||
{ work->response = response->buffer; work->success = true; }
|
||||
}
|
||||
catch (...) { work->success = false; }
|
||||
work->done.store(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (...) { failure(); }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup(plugin::plugin* plugin) override
|
||||
{
|
||||
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
|
||||
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
|
||||
scripting::on_shutdown([]
|
||||
{
|
||||
// In-flight requests own only native data. Results from the old VM are abandoned.
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
|
||||
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
|
||||
{
|
||||
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
|
||||
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
|
||||
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
|
||||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
|
||||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
|
||||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
|
||||
try
|
||||
{
|
||||
auto configured = std::make_shared<runtime>("tws-moderation/t5", server, url, generation,
|
||||
utils::cryptography::sha256::compute(token, true));
|
||||
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
|
||||
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
|
||||
}
|
||||
catch (...) { fault = true; failure(); return false; }
|
||||
});
|
||||
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
|
||||
{
|
||||
if (!current || fault) return false;
|
||||
const auto ref = entity.get_entity_reference();
|
||||
if (ref.classnum != 0 || ref.entnum >= 64) return false;
|
||||
try { current->observe(ref.entnum, guid, name); return true; }
|
||||
catch (...) { failure(); return false; }
|
||||
});
|
||||
gsc::function::add("moderationstop", []
|
||||
{
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationpump", pump);
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(moderation::component)
|
||||
@@ -0,0 +1,381 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "afk.hpp"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
|
||||
constexpr int max_seconds = 604800;
|
||||
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
|
||||
std::map<std::string, active_clock> active;
|
||||
struct stopped_clock { journal::clock::time_point time; std::string reason; };
|
||||
std::map<std::string, stopped_clock> stopped;
|
||||
std::string hash(const std::string& value)
|
||||
{
|
||||
uint64_t result = 14695981039346656037ull;
|
||||
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0') << std::setw(16) << result;
|
||||
return out.str();
|
||||
}
|
||||
bool identifier(const nlohmann::json& value)
|
||||
{
|
||||
if (!value.is_string()) return false;
|
||||
const auto& text = value.get_ref<const std::string&>();
|
||||
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
|
||||
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
|
||||
}
|
||||
bool integer(const nlohmann::json& value, int maximum = 1000000000)
|
||||
{
|
||||
return value.is_number_integer() && value >= 0 && value <= maximum;
|
||||
}
|
||||
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
|
||||
void append(std::vector<request>& requests, const request& incoming)
|
||||
{
|
||||
for (const auto& value : requests)
|
||||
if (value.id == incoming.id)
|
||||
{
|
||||
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
|
||||
throw std::runtime_error("AFK immutable request conflict");
|
||||
return;
|
||||
}
|
||||
requests.push_back(incoming);
|
||||
}
|
||||
void erase_session_requests(std::vector<request>& requests, const std::string& id)
|
||||
{
|
||||
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
|
||||
{
|
||||
const auto data = nlohmann::json::parse(value.body);
|
||||
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
|
||||
}), requests.end());
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement value;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
value.state = data.at("state").get<std::string>();
|
||||
value.code = data.at("code").get<std::string>();
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
|
||||
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
|
||||
if (!states.count(value.state)) return invalid("state");
|
||||
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
|
||||
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
|
||||
const std::pair<const char*, int acknowledgement::*> fields[] = {
|
||||
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
|
||||
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
|
||||
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
|
||||
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
|
||||
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
|
||||
for (const auto& [name, member] : fields)
|
||||
{
|
||||
if (!integer(data.at(name))) return invalid("amount_range");
|
||||
value.*member = data.at(name).get<int>();
|
||||
}
|
||||
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
|
||||
if (data.contains("awarded_seconds"))
|
||||
{
|
||||
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
|
||||
value.awarded_seconds = data.at("awarded_seconds").get<int>();
|
||||
}
|
||||
if (data.contains("session_id") && !data.at("session_id").is_null())
|
||||
{
|
||||
value.session_id = data.at("session_id").get<std::string>();
|
||||
if (value.session_id.size() > 64) return invalid("field_limit");
|
||||
}
|
||||
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
|
||||
{
|
||||
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
|
||||
value.consumed_seconds = data.at("consumed_seconds").get<int>();
|
||||
}
|
||||
if ((value.state == "reserved" || value.state == "started") &&
|
||||
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
|
||||
value.valid = true;
|
||||
value.reason = "ok";
|
||||
return value;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
|
||||
{ file_ = root / (hash(scope_) + ".json"); }
|
||||
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
|
||||
|
||||
request journal::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
|
||||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
|
||||
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
|
||||
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
|
||||
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
|
||||
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
|
||||
throw std::runtime_error("invalid AFK session identity");
|
||||
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
|
||||
throw std::runtime_error("invalid AFK reservation size");
|
||||
if (value.action == "event")
|
||||
{
|
||||
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
|
||||
throw std::runtime_error("invalid AFK activity sequence");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
|
||||
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
journal::snapshot journal::read() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_)) return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error("cannot read AFK journal");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
|
||||
data.at("requests").size() + data.at("sessions").size() > max_entries)
|
||||
throw std::runtime_error("invalid AFK journal; preserve for recovery");
|
||||
snapshot result;
|
||||
std::set<std::string> requests, sessions;
|
||||
for (const auto& body : data.at("requests"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
|
||||
result.requests.push_back(std::move(value));
|
||||
}
|
||||
for (const auto& entry : data.at("sessions"))
|
||||
{
|
||||
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
|
||||
const auto request = decode(value.body);
|
||||
const auto body = nlohmann::json::parse(value.body);
|
||||
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
|
||||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
|
||||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
|
||||
throw std::runtime_error("invalid AFK session");
|
||||
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
|
||||
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
|
||||
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
|
||||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
|
||||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
|
||||
(value.phase != "active" && value.consumed_seconds != 0))
|
||||
throw std::runtime_error("invalid AFK session accounting");
|
||||
result.sessions.push_back(std::move(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void journal::save(const snapshot& value) const
|
||||
{
|
||||
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
|
||||
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
|
||||
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
|
||||
for (const auto& request : value.requests) data["requests"].push_back(request.body);
|
||||
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
|
||||
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_; temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace AFK journal; old file retained");
|
||||
#else
|
||||
std::filesystem::rename(temporary,file_);
|
||||
#endif
|
||||
}
|
||||
std::vector<request> journal::load() const { return read().requests; }
|
||||
void journal::store(const std::string& body) const
|
||||
{
|
||||
auto value = read();
|
||||
const auto incoming = decode(body);
|
||||
// Snapshot replacement is atomic with the new snapshot. Cumulative
|
||||
// counters retain every reward during an outage without an unbounded
|
||||
// request per kill. An in-flight older HTTP request remains idempotent;
|
||||
// removing its old ID cannot remove this newer request.
|
||||
if (incoming.action == "event")
|
||||
{
|
||||
const auto next = nlohmann::json::parse(body);
|
||||
for (const auto& entry : value.requests)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) continue;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
|
||||
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
|
||||
}
|
||||
// Check reused identities before removing any superseded request.
|
||||
append(value.requests,incoming);
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) return false;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
|
||||
}),value.requests.end());
|
||||
}
|
||||
else append(value.requests,incoming);
|
||||
save(value);
|
||||
}
|
||||
void journal::erase(const std::string& id) const
|
||||
{
|
||||
auto value = read();
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
|
||||
save(value);
|
||||
}
|
||||
void journal::prepare(const std::string& body) const
|
||||
{
|
||||
const auto request = decode(body);
|
||||
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
|
||||
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
|
||||
auto value = read();
|
||||
for (const auto& existing : value.sessions)
|
||||
if (session_id(existing) == id)
|
||||
{
|
||||
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
|
||||
return;
|
||||
}
|
||||
append(value.requests,request);
|
||||
value.sessions.push_back({body,"prepared",0,0});
|
||||
save(value);
|
||||
}
|
||||
void journal::authorize(const std::string& id, int seconds) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
|
||||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
|
||||
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
void journal::begin(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
|
||||
if (session.phase == "active" && active.count(key(id))) return;
|
||||
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
|
||||
session.phase = "active";
|
||||
save(value); // Failure prevents protection; never reset an existing clock.
|
||||
// Production clocks start after the durable write; slow storage
|
||||
// must not charge time before the script can apply protection.
|
||||
if (now == clock::time_point{}) now = clock::now();
|
||||
active.emplace(key(id),active_clock{now,session.authorized_seconds});
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
|
||||
{
|
||||
const auto found = active.find(key(session_id(value)));
|
||||
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
|
||||
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
|
||||
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
|
||||
}
|
||||
int journal::remaining(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
const auto found = active.find(key(id));
|
||||
if (found == active.end()) return -1;
|
||||
if (stopped.count(key(id))) return 0;
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
|
||||
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
|
||||
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
|
||||
}
|
||||
int journal::checkpoint(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
|
||||
const auto seconds = elapsed(session,now);
|
||||
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
|
||||
return seconds;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
request journal::settlement(const session& value, const std::string& reason, int consumed) const
|
||||
{
|
||||
auto data = nlohmann::json::parse(value.body);
|
||||
const auto id = session_id(value);
|
||||
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
|
||||
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
|
||||
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
|
||||
else body["consumed_seconds"] = consumed;
|
||||
return decode(body.dump());
|
||||
}
|
||||
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
|
||||
{
|
||||
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
|
||||
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
|
||||
// The caller has already removed protection. Freeze the first stop even
|
||||
// if disk read/write fails, so retries cannot charge time after return.
|
||||
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
|
||||
auto value = read();
|
||||
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
|
||||
if (session_id(*it) == id)
|
||||
{
|
||||
const auto end = stopped.find(key(id));
|
||||
const auto stop_time = end == stopped.end() ? now : end->second.time;
|
||||
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
|
||||
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
|
||||
if ((stop_reason == "never_started" && it->phase == "active") ||
|
||||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
|
||||
throw std::runtime_error("AFK stop reason conflicts with usage");
|
||||
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
|
||||
erase_session_requests(value.requests,id);
|
||||
value.sessions.erase(it);
|
||||
save(value);
|
||||
active.erase(key(id));
|
||||
stopped.erase(key(id));
|
||||
return;
|
||||
}
|
||||
// Duplicate stop is safe after a successful durable transition.
|
||||
}
|
||||
void journal::recover(const std::string& current_match) const
|
||||
{
|
||||
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
|
||||
auto value = read();
|
||||
for (const auto& session : value.sessions)
|
||||
{
|
||||
const auto id = session_id(session);
|
||||
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
|
||||
throw std::runtime_error("cannot recover live AFK protection");
|
||||
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
|
||||
erase_session_requests(value.requests,id);
|
||||
}
|
||||
const auto sessions = value.sessions;
|
||||
value.sessions.clear();
|
||||
save(value);
|
||||
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
struct request { std::string id, action, body; };
|
||||
struct session
|
||||
{
|
||||
std::string body, phase;
|
||||
int authorized_seconds = 0, consumed_seconds = 0;
|
||||
};
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string reason = "schema", state, code, id, session_id;
|
||||
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
|
||||
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
|
||||
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
|
||||
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// Single game-thread writer. Active clocks are process-local, so recovery
|
||||
// can never accidentally replay protection after a process restart.
|
||||
class journal
|
||||
{
|
||||
public:
|
||||
using clock = std::chrono::steady_clock;
|
||||
journal(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<request> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
void prepare(const std::string& reserve_body) const;
|
||||
void authorize(const std::string& id, int seconds) const;
|
||||
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
|
||||
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
|
||||
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
|
||||
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
|
||||
// Call only at map initialization, with its new persistent match ID.
|
||||
// Active current-map sessions are rejected. Unknown crash intervals
|
||||
// remain unresolved at EC, rather than refunding spendable authority.
|
||||
void recover(const std::string& current_match) const;
|
||||
private:
|
||||
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, scope_;
|
||||
request decode(const std::string& body) const;
|
||||
snapshot read() const;
|
||||
void save(const snapshot& value) const;
|
||||
std::string key(const std::string& id) const;
|
||||
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
|
||||
request settlement(const session& value, const std::string& reason, int consumed) const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "bank.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <set>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000;
|
||||
constexpr size_t max_file_size = 16 * 1024 * 1024;
|
||||
constexpr size_t max_reports = 4096;
|
||||
|
||||
// Stable, compact filename for arbitrary server names and endpoints.
|
||||
// The envelope also checks both strings, so a collision fails closed.
|
||||
std::string scope_name(const std::string& scope)
|
||||
{
|
||||
uint64_t hash = 14695981039346656037ull;
|
||||
for (const unsigned char byte : scope)
|
||||
hash = (hash ^ byte) * 1099511628211ull;
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0') << std::setw(16) << hash;
|
||||
return result.str();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
auto invalid = [](const char* reason)
|
||||
{
|
||||
acknowledgement value;
|
||||
value.reason = reason;
|
||||
return value;
|
||||
};
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement result;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
result.state = data.at("state").get<std::string>();
|
||||
result.code = data.at("code").get<std::string>();
|
||||
result.id = data.at("request_id").get<std::string>();
|
||||
if (result.id.size() > 64 || result.code.size() > 64)
|
||||
return invalid("field_limit");
|
||||
if (result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
|
||||
result.state != "cancelled" && result.state != "rejected") return invalid("state");
|
||||
for (const auto* key : {"amount", "balance"})
|
||||
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000)
|
||||
return invalid("amount_range");
|
||||
result.amount = data.at("amount").get<int>();
|
||||
result.balance = data.at("balance").get<int>();
|
||||
if (data.at("ok").get<bool>() != (result.state != "rejected")) return invalid("ok_state");
|
||||
if (result.state != "balance" && result.id.empty()) return invalid("missing_id");
|
||||
if ((result.state == "prepared" || result.state == "committed") && result.amount == 0)
|
||||
return invalid("zero_transfer");
|
||||
result.valid = true;
|
||||
result.reason = "ok";
|
||||
return result;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint))
|
||||
{
|
||||
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
|
||||
}
|
||||
|
||||
report outbox::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size)
|
||||
throw std::runtime_error("invalid bank request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_)
|
||||
throw std::runtime_error("invalid bank scope");
|
||||
report value;
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
value.decision = data.at("decision").get<std::string>();
|
||||
if (value.id.empty() || value.id.size() > 64 ||
|
||||
(value.decision != "cancel" && value.decision != "commit"))
|
||||
throw std::runtime_error("invalid bank decision");
|
||||
value.body = body;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::vector<report> outbox::load() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_))
|
||||
return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size)
|
||||
throw std::runtime_error("bank outbox exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot open bank outbox");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
|
||||
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
|
||||
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
|
||||
std::vector<report> reports;
|
||||
std::set<std::string> identities;
|
||||
for (const auto& body : data.at("reports"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!identities.emplace(value.id).second)
|
||||
throw std::runtime_error("duplicate identity in bank outbox");
|
||||
reports.push_back(std::move(value));
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
void outbox::save(const std::vector<report>& reports) const
|
||||
{
|
||||
if (reports.size() > max_reports)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
|
||||
{"reports", nlohmann::json::array()}};
|
||||
for (const auto& value : reports)
|
||||
data["reports"].push_back(value.body);
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_;
|
||||
temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot write bank outbox; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail())
|
||||
throw std::runtime_error("cannot close bank outbox; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace bank outbox; old file retained");
|
||||
#else
|
||||
// Used by the portable tests; the production plugin targets Windows.
|
||||
std::filesystem::rename(temporary, file_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void outbox::store(const std::string& body) const
|
||||
{
|
||||
const auto incoming = decode(body);
|
||||
auto reports = load();
|
||||
for (auto& value : reports)
|
||||
{
|
||||
if (value.id != incoming.id)
|
||||
continue;
|
||||
auto old_data = nlohmann::json::parse(value.body);
|
||||
auto new_data = nlohmann::json::parse(body);
|
||||
old_data.erase("decision");
|
||||
new_data.erase("decision");
|
||||
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
|
||||
throw std::runtime_error("bank identity/decision conflict");
|
||||
if (value.body != body)
|
||||
{
|
||||
value = incoming;
|
||||
save(reports);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reports.push_back(incoming);
|
||||
save(reports);
|
||||
}
|
||||
|
||||
void outbox::erase(const std::string& id) const
|
||||
{
|
||||
auto reports = load();
|
||||
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
|
||||
{
|
||||
return value.id == id;
|
||||
});
|
||||
if (end == reports.end())
|
||||
return;
|
||||
reports.erase(end, reports.end());
|
||||
save(reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
struct report { std::string id, decision, body; };
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string state, code, id;
|
||||
// Fixed diagnostic vocabulary; never includes response text.
|
||||
std::string reason = "schema";
|
||||
int amount = 0, balance = 0;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// One game-thread writer; cancel intent is replaced by commit before points move.
|
||||
class outbox
|
||||
{
|
||||
public:
|
||||
outbox(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<report> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_;
|
||||
report decode(const std::string& body) const;
|
||||
void save(const std::vector<report>& reports) const;
|
||||
};
|
||||
}
|
||||
@@ -14,13 +14,17 @@ namespace utils::http
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct response_buffer { std::string bytes; size_t limit; };
|
||||
|
||||
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
|
||||
{
|
||||
const auto buffer = static_cast<std::string*>(userp);
|
||||
const auto buffer = static_cast<response_buffer*>(userp);
|
||||
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
|
||||
const auto total_size = size * nmemb;
|
||||
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
|
||||
try
|
||||
{
|
||||
buffer->append(static_cast<char*>(contents), total_size);
|
||||
buffer->bytes.append(static_cast<char*>(contents), total_size);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -68,7 +72,7 @@ namespace utils::http
|
||||
header_list.reset(appended);
|
||||
}
|
||||
|
||||
std::string buffer{};
|
||||
response_buffer buffer{{}, limits.max_response_bytes};
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.data());
|
||||
@@ -106,7 +110,7 @@ namespace utils::http
|
||||
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
|
||||
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
|
||||
if (response.code == CURLE_OK)
|
||||
response.buffer = std::move(buffer);
|
||||
response.buffer = std::move(buffer.bytes);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -25,6 +26,8 @@ namespace utils::http
|
||||
{
|
||||
long connect_timeout_ms = 5000;
|
||||
long timeout_ms = 15000;
|
||||
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
|
||||
size_t max_response_bytes = 0;
|
||||
};
|
||||
|
||||
using headers = std::unordered_map<std::string, std::string>;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "moderation.hpp"
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_file = 16 * 1024 * 1024;
|
||||
constexpr size_t max_response = 1024 * 1024;
|
||||
bool identifier(const std::string& value, size_t limit = 128)
|
||||
{
|
||||
return !value.empty() && value.size() <= limit &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
|
||||
});
|
||||
}
|
||||
std::string bounded_text(const std::string& value, size_t limit)
|
||||
{
|
||||
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
|
||||
// split a codepoint or let one bad name poison the complete JSON roster.
|
||||
std::string result;
|
||||
for (size_t i = 0; i < value.size() && result.size() < limit;)
|
||||
{
|
||||
const auto ch = static_cast<unsigned char>(value[i]);
|
||||
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
|
||||
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
|
||||
bool valid = width && i + width <= value.size();
|
||||
for (size_t j = 1; valid && j < width; ++j)
|
||||
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
|
||||
if (valid && width >= 3)
|
||||
{
|
||||
const auto second = static_cast<unsigned char>(value[i + 1]);
|
||||
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
|
||||
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
|
||||
}
|
||||
if (!valid) { result += '?'; ++i; continue; }
|
||||
if (result.size() + width > limit) break;
|
||||
result.append(value, i, width); i += width;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string player_name(const std::string& value)
|
||||
{
|
||||
auto name = bounded_text(value, 255);
|
||||
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
|
||||
auto visible = name;
|
||||
// Match ECMAScript trim whitespace so the controller cannot reject an
|
||||
// otherwise complete roster because one display name is blank.
|
||||
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
|
||||
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
|
||||
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
|
||||
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
|
||||
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
|
||||
return visible.empty() ? "Unknown player" : name;
|
||||
}
|
||||
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
|
||||
std::string hash_scope(const std::string& text)
|
||||
{
|
||||
unsigned long long hash = 14695981039346656037ull;
|
||||
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
|
||||
std::ostringstream out; out << std::hex << hash; return out.str();
|
||||
}
|
||||
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
|
||||
std::string field(const json& value, const char* key, size_t max, bool empty = false)
|
||||
{
|
||||
require(value.contains(key) && value.at(key).is_string());
|
||||
auto result = value.at(key).get<std::string>();
|
||||
require((empty || !result.empty()) && result.size() <= max);
|
||||
return result;
|
||||
}
|
||||
void reason(const json& value) { field(value, "reason", 500, true); }
|
||||
void sanction(const json& value, long long revision)
|
||||
{
|
||||
require(value.is_object());
|
||||
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
|
||||
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
|
||||
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
void action(const json& value)
|
||||
{
|
||||
require(value.is_object() && identifier(field(value, "id", 128)));
|
||||
auto kind = field(value, "kind", 16);
|
||||
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
|
||||
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
|
||||
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
|
||||
reason(value); timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
}
|
||||
long long timestamp(const std::string& value)
|
||||
{
|
||||
require(value.size() == 20 || value.size() == 24);
|
||||
require(value.back() == 'Z' && value[10] == 'T');
|
||||
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
|
||||
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
|
||||
const auto original = tm;
|
||||
#ifdef _WIN32
|
||||
auto result = _mkgmtime64(&tm);
|
||||
#else
|
||||
auto result = timegm(&tm);
|
||||
#endif
|
||||
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
|
||||
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
|
||||
return result;
|
||||
}
|
||||
std::string command_text(const std::string& value)
|
||||
{
|
||||
// Untrusted text is never a console delimiter or a quoted command escape.
|
||||
std::string result;
|
||||
for (unsigned char ch : bounded_text(value, 240))
|
||||
{
|
||||
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string canonical_guid(const std::string& value)
|
||||
{
|
||||
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
|
||||
{
|
||||
const auto first = value.find_first_not_of('0');
|
||||
return first == std::string::npos ? "0" : value.substr(first);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
std::string new_session()
|
||||
{
|
||||
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
|
||||
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
|
||||
return out.str();
|
||||
}
|
||||
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
|
||||
{
|
||||
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
|
||||
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
|
||||
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
|
||||
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
|
||||
if (std::filesystem::exists(file_))
|
||||
{
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
|
||||
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
|
||||
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
|
||||
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
|
||||
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
|
||||
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
|
||||
{
|
||||
require(identifier(it.key())); action(it.value().at("action"));
|
||||
auto state = field(it.value(), "state", 16);
|
||||
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
|
||||
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
|
||||
}
|
||||
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
|
||||
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
|
||||
saved_ = std::move(value);
|
||||
}
|
||||
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
|
||||
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
|
||||
save(next); // Allocate this boot/map sequence durably before exposing its session.
|
||||
}
|
||||
void runtime::save(const json& next)
|
||||
{
|
||||
const auto bytes = next.dump(); require(bytes.size() < max_file);
|
||||
const auto exists = std::filesystem::exists(file_);
|
||||
require(exists || !allocated_);
|
||||
if (exists)
|
||||
{
|
||||
// A synchronous engine command may replace the map/runtime before the
|
||||
// older call frame returns. It must never overwrite the new sequence.
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
|
||||
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
|
||||
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
|
||||
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
|
||||
}
|
||||
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
|
||||
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
|
||||
require(stream.good()); stream.close(); require(!stream.fail());
|
||||
#ifdef _WIN32
|
||||
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
|
||||
#else
|
||||
std::filesystem::rename(temp, file_);
|
||||
#endif
|
||||
saved_ = next; allocated_ = true;
|
||||
}
|
||||
void runtime::observe(int slot, const std::string& guid, const std::string& name)
|
||||
{
|
||||
require(slot >= 0 && slot < 64 && guid_identifier(guid));
|
||||
auto found = players.find(slot);
|
||||
if (found == players.end() || found->second.guid != canonical_guid(guid))
|
||||
{
|
||||
disconnect(slot);
|
||||
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
|
||||
}
|
||||
else found->second.name = player_name(name);
|
||||
}
|
||||
void runtime::disconnect(int slot)
|
||||
{
|
||||
auto found = players.find(slot);
|
||||
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
|
||||
}
|
||||
json runtime::request()
|
||||
{
|
||||
json roster = json::array(), receipts = json::array(); sent_.clear();
|
||||
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
|
||||
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
|
||||
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
|
||||
{
|
||||
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
|
||||
sent_.push_back(it.key());
|
||||
}
|
||||
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
|
||||
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
|
||||
}
|
||||
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
|
||||
{
|
||||
const auto received = std::chrono::steady_clock::now();
|
||||
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
|
||||
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
|
||||
auto revision = value.at("revision").get<long long>();
|
||||
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
|
||||
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
|
||||
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
|
||||
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
|
||||
std::set<std::string> ids;
|
||||
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
|
||||
ids.clear();
|
||||
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
|
||||
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
|
||||
if (revision == this->revision() && revision != 0)
|
||||
{
|
||||
auto previous = saved_.at("sanctions");
|
||||
for (auto it = previous.begin(); it != previous.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
|
||||
auto incoming = value.at("sanctions");
|
||||
for (auto it = incoming.begin(); it != incoming.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
|
||||
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
|
||||
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
|
||||
require(incoming == previous);
|
||||
}
|
||||
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
|
||||
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
|
||||
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
|
||||
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
|
||||
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
|
||||
save(next); pending_ = value.at("actions");
|
||||
const auto accepted = std::chrono::steady_clock::now();
|
||||
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
|
||||
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
|
||||
std::chrono::milliseconds(transfer_age_ms) + processing;
|
||||
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
|
||||
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
|
||||
clock_anchor_ = accepted;
|
||||
}
|
||||
long long runtime::effective_time(long long local) const
|
||||
{
|
||||
if (!server_anchor_.time_since_epoch().count()) return local;
|
||||
// Never extend deadlines when the host clock lags or jumps backward. The
|
||||
// full request age is conservative because serverTime is generated during it.
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
|
||||
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
|
||||
return std::max(local, static_cast<long long>(estimate));
|
||||
}
|
||||
void runtime::enforce(long long now, const executor& execute)
|
||||
{
|
||||
now = effective_time(now);
|
||||
auto pending = pending_; pending_ = json::array();
|
||||
for (const auto& item : pending)
|
||||
{
|
||||
auto id = item.at("id").get<std::string>();
|
||||
if (saved_["journal"].contains(id))
|
||||
{
|
||||
// A duplicate immutable action is acknowledged, never executed twice.
|
||||
require(saved_["journal"][id].at("action") == item);
|
||||
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
|
||||
}
|
||||
require(saved_["journal"].size() < 10000);
|
||||
auto kind = item.at("kind").get<std::string>(); int slot = -1;
|
||||
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
|
||||
else if (kind != "announce")
|
||||
{
|
||||
for (const auto& [number, target] : players)
|
||||
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
|
||||
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
|
||||
}
|
||||
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
|
||||
if (state == "unknown")
|
||||
{
|
||||
// All target validation and dispatch occur on the calling game thread, without yielding.
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
|
||||
else
|
||||
{
|
||||
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
|
||||
catch (...) { state = "unknown"; }
|
||||
if (state != "confirmed" && state != "failed") state = "unknown";
|
||||
}
|
||||
next = saved_; next["journal"][id]["state"] = state;
|
||||
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
|
||||
}
|
||||
}
|
||||
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
|
||||
const auto roster = players;
|
||||
for (const auto& [slot, target] : roster)
|
||||
{
|
||||
now = effective_time(now);
|
||||
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
|
||||
bool ban = false, mute = false; std::string why;
|
||||
for (const auto& policy : saved_.at("sanctions"))
|
||||
{
|
||||
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
|
||||
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
|
||||
if (policy.at("kind") == "mute") mute = true;
|
||||
}
|
||||
if (ban)
|
||||
{
|
||||
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
|
||||
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
|
||||
continue;
|
||||
}
|
||||
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
|
||||
auto owned = saved_.at("ownedMutes");
|
||||
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
|
||||
if (mute != was_muted || (!mute && managed))
|
||||
{
|
||||
if (mute && !managed)
|
||||
{
|
||||
require(owned.size() < 10000); owned.push_back(target.guid);
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
|
||||
if (!mute && managed)
|
||||
{
|
||||
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <json.hpp>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
using json = nlohmann::json;
|
||||
struct player { int slot; std::string guid, name, connection; };
|
||||
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
|
||||
long long timestamp(const std::string& value);
|
||||
std::string command_text(const std::string& value);
|
||||
std::string new_session();
|
||||
std::string canonical_guid(const std::string& value);
|
||||
class runtime
|
||||
{
|
||||
public:
|
||||
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
|
||||
std::string session = new_session();
|
||||
std::map<int, player> players;
|
||||
void observe(int slot, const std::string& guid, const std::string& name);
|
||||
void disconnect(int slot);
|
||||
json request();
|
||||
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
|
||||
void enforce(long long now, const executor& execute);
|
||||
long long revision() const { return saved_.at("revision").get<long long>(); }
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, credential_scope_;
|
||||
int generation_;
|
||||
unsigned long long serial_ = 0;
|
||||
bool allocated_ = false;
|
||||
std::chrono::system_clock::time_point server_anchor_{};
|
||||
std::chrono::steady_clock::time_point clock_anchor_{};
|
||||
json saved_, pending_ = json::array();
|
||||
std::vector<std::string> sent_;
|
||||
std::map<std::string, bool> muted_;
|
||||
std::map<std::string, long long> kicked_;
|
||||
void save(const json& next);
|
||||
long long effective_time(long long local) const;
|
||||
};
|
||||
}
|
||||
+71
-5
@@ -32,9 +32,15 @@ before copying the renamed scripts.
|
||||
- **`SetTimerUp(N)` only ticks when `N == 0`** on T5 SP. Non-zero seeds
|
||||
render "0:00" but don't advance. Late joiners see 0, not real elapsed;
|
||||
authoritative elapsed lives in `level.match_start_time`.
|
||||
- **Dynamic text: use `SetValue`, not `SetText`.** `SetText` allocates a
|
||||
config string per call; per-tick rebuilds overflow and take the server
|
||||
down. Composite strings like `"87/160"` are three elements.
|
||||
- **Dynamic counter text uses `SetTextUnlimited`; HP uses `SetValue`.**
|
||||
Repeatedly generating unique strings through `SetText` risks exhausting the
|
||||
text config-string pool; identical strings can be reused by the engine's
|
||||
[string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46).
|
||||
The centered `N ZOMBIES` element uses Plutonium r5334+'s `SetTextUnlimited`
|
||||
only when the count changes. Composite HP retains numeric elements around a
|
||||
static slash and `HP` suffix; the group appears only while injured. Help
|
||||
reuses up to nine explicitly positioned command/description pairs, and
|
||||
record rows skip unchanged text.
|
||||
- **`NewClientHudElem(player)`, not `NewHudElem()`.** `NewHudElem` is
|
||||
globally owned; in co-op every player's HUD draws on top of every other
|
||||
player's. Fall back to `NewHudElem` only if elements don't render at all
|
||||
@@ -56,18 +62,78 @@ before copying the renamed scripts.
|
||||
Many BO1 maps pre-set `round_number = 1` at init, so the round watcher
|
||||
fires on transitions only and the timer row seeds on first spawn as a
|
||||
fallback.
|
||||
- **Connection-time HUD creation must not seed clocks.** The unboxed core is
|
||||
available to waiting spectators with placeholders: timers upper left,
|
||||
branding top center and zombies/health bottom center. Timer updates retain
|
||||
their existing spawn/round lifecycle, independently of element creation.
|
||||
- **Visibility is separate from updates.** HUD owns the exact, case-insensitive
|
||||
`.hud` chat command and its help entry. Common's core wrappers register each
|
||||
element and preserve its intended alpha behind a per-player visibility mask;
|
||||
health and AFK changes use `set_core_alpha()`. Hidden data/timers keep updating
|
||||
until game over freezes them. Help, records and stock elements remain separate.
|
||||
Visibility defaults on, survives death/respawn during the connection/map, and
|
||||
resets on reconnect or map restart. AFK protection is unaffected.
|
||||
- **One service view per player.** Common owns the unboxed upper-right service
|
||||
area, ordered help rows, record display state and cleanup. Help shows all
|
||||
nine installed commands; extra registrations use nine rows per page. Help
|
||||
pauses record page time; `.leaders` during help replaces the pending replay
|
||||
from page one. Keep both services independent of the HUD module. Services
|
||||
never reposition the core, and AFK remains centered above the zombie count.
|
||||
- **Bound HUD element counts, including hidden elements.** Core + AFK use
|
||||
10 + 1 archived elements. Common allocates only the active service view:
|
||||
19 nonarchived elements for nine-command help (20 with a multi-page counter),
|
||||
or four for records (five with a multi-page counter), and none while idle.
|
||||
Switching help/record views destroys the previous view first; pages reuse
|
||||
its elements. Keep the controller and pending requests separate from element
|
||||
lifetime so paused records resume normally. Both engines use explicit help
|
||||
rows; BO1 can concatenate newline-separated HUD text. Hiding an element does
|
||||
not free its slot. The previous 24-element persistent service allocation was
|
||||
consistent with the reported core corruption after records first appeared.
|
||||
The exact live T5 SP capacity remains unverified. The
|
||||
[BO1 HUD update](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game/g_hudelem.cpp#L2596)
|
||||
comes from an MP-based reconstruction and is not proof of T5 SP limits or
|
||||
current Plutonium stock usage. Validate automatic records, help, view switches
|
||||
and dismissal alongside stock notifications and other installed HUD mods;
|
||||
the core must remain intact throughout and after every service closes.
|
||||
- **Game-over trigger is level-scope, raced across three candidate
|
||||
notifies** (`end_game` / `game_ended` / `intermission`) since BO1 maps
|
||||
are inconsistent. Per-player triggers do not work — `self waittill("death")`
|
||||
is intercepted by the zombiemode scripts before it reaches our scope, and
|
||||
polling `self.health` misses the zero-crossing (health is rewritten during
|
||||
the game-over sequence).
|
||||
|
||||
HUD `1.4.2` requires matching common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
|
||||
`SetTextUnlimited`. On r5334+, update the HUD script and restart the map;
|
||||
older Plutonium clients and servers need updates and full restarts first.
|
||||
AFK `1.2.0` and leaderboard `1.1.0` remain compatible; AFK still requires
|
||||
common `1.4.0+`.
|
||||
The full help order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
|
||||
`.b`, `.d`, `.w`: one page of nine commands, with full `<amount|all>` bank
|
||||
syntax. `.hud` is absent when HUD is omitted. Records use right-aligned
|
||||
`Map record | Round N` and up to three bounded holder lines, with no footer
|
||||
and no single-page counter. Both views retain 12-second page timing.
|
||||
Fullscreen X/Y overlay anchors use direct normalized `640 × 480` coordinates.
|
||||
Dynamic health fills use `make_overlay_width()` like their tracks. Apply
|
||||
`int()` after `max()` in that helper: native `max()` returns a float, but
|
||||
`SetShader` requires an integer width. Legacy panel helpers and their `0.75`
|
||||
horizontal conversion remain available for existing consumers. Font scale is
|
||||
at least `1.0`. Numeric HP appears only while injured; the core and services
|
||||
have no panel backgrounds. The counter is one `N ZOMBIES` element centered
|
||||
at the health track's midpoint with uniform scale `1.25`, starting as
|
||||
`-- ZOMBIES`. There is no new native-plugin/backend dependency or full
|
||||
server-process restart requirement when already using Plutonium r5334+.
|
||||
Run the [shared HUD checks](../../README.md#hud-validation); portable fixtures
|
||||
do not replace T5 live loading, layout and lifecycle validation. Check the
|
||||
counter's centering at `0`, `6`, `9`, `10`, `99` and `100`, after `.hud`
|
||||
hides/shows it, and after game over freezes the count.
|
||||
|
||||
## Module map
|
||||
|
||||
| File | Role | Fires notifies | Reads notifies |
|
||||
|---|---|---|---|
|
||||
| `tws_t5_common` | Shared config, HUD helpers, `.help` registry, spawn-handler registry, single connect listener + dispatcher | — | `say` |
|
||||
| `tws_t5_hud` | Timer row, zombie count, health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) |
|
||||
| `tws_t5_common` | Shared config, HUD helpers, paginated `.help`, service window, spawn-handler registry, single connect listener + dispatcher | — | `say`, game-end notifications |
|
||||
| `tws_t5_hud` | Unboxed core overlays and private `.hud` visibility toggle | `hud_round_changed`, `hud_match_started`, `hud_game_over` | `say`, internal notifications |
|
||||
| `tws_t5_afk` | `.afk` / `.back` chat toggle, owns its [AFK] indicator | — | — |
|
||||
| `tws_t5_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` |
|
||||
| `tws_t5_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` |
|
||||
|
||||
+195
-60
@@ -1,7 +1,7 @@
|
||||
# TWS T5 Zombies scripts
|
||||
|
||||
Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
|
||||
(Black Ops 1) zombies dedicated servers. Split across seven `.gsc` files,
|
||||
(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files,
|
||||
with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module.
|
||||
|
||||
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
||||
@@ -10,17 +10,21 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, `.help`, connect/spawn registries, single `connected` listener + dispatcher |
|
||||
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Bottom-left HUD stack: timers, zombie count, health bar |
|
||||
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator |
|
||||
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, paginated `.help`, service window, connect/spawn registries, single `connected` listener + dispatcher |
|
||||
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
|
||||
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | Persistent AFK reserve, contribution rewards, purchases and central countdown |
|
||||
| [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_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_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+ |
|
||||
|
||||
## Install / upgrade
|
||||
|
||||
Copy `tws_t5_*.gsc` to `storage/t5/scripts/sp/`, then restart the map.
|
||||
Copy the desired `tws_t5_*.gsc` modules to `storage/t5/scripts/sp/`, then restart the map.
|
||||
Before adding the optional moderation module, install native plugin 1.3.0+ and
|
||||
restart the full game-server process; see [moderation setup](../../docs/moderation.md).
|
||||
When upgrading an existing installation, first remove these old scripts from
|
||||
that directory:
|
||||
|
||||
@@ -31,10 +35,80 @@ that directory:
|
||||
Do not leave both common scripts installed: duplicate connection listeners
|
||||
break T5's connect handshake. Existing dvar names are unchanged.
|
||||
|
||||
## Player bank
|
||||
|
||||
Bank `2.0.2` requires plugin `1.2.0+` for startup version reporting and bounded
|
||||
parser diagnostics. Install the DLL and restart the full server process before
|
||||
loading these scripts. Follow the [diagnostic rollout and live checks](../../README.md#bank-diagnostics--tws-14).
|
||||
|
||||
The optional `tws_t5_bank.gsc` adds `.b`, `.d <amount|all>` and
|
||||
`.w <amount|all>` and registers them in help. It requires common and telemetry
|
||||
`1.0.0` or newer, the TWS t5 plugin `1.2.0`, Electric Cherry's bank API and
|
||||
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
|
||||
including a full process restart for the plugin. Bank `2.0.0+` uses only
|
||||
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
|
||||
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
|
||||
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
|
||||
an EC rebuild must settle legacy journals before the new scripts start.
|
||||
When the endpoint already matches, only a map restart is required.
|
||||
|
||||
Bank `2.0.1` converts T5's `player getGuid()` result to a string before
|
||||
requests or journal writes, preserving existing strings exactly. Missing or
|
||||
invalid identities fail privately without changing points. Follow the shared
|
||||
[identity and deployment checks](../../README.md#bank-identity-validation).
|
||||
|
||||
## Core HUD and service window
|
||||
|
||||
The unboxed core puts match time above round time at the upper left,
|
||||
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
|
||||
slim health bar at the bottom center. `N ZOMBIES` is one centered element at
|
||||
the health track's midpoint, using uniform font scale `1.25` so the whole group
|
||||
stays centered as the digit count changes. Its initial placeholder is
|
||||
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
|
||||
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
|
||||
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
|
||||
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
|
||||
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
|
||||
|
||||
The core is created once per connection, including for spectator newcomers
|
||||
with placeholders until values are available. Creation does not seed the match
|
||||
clock. It persists across death and respawn. Clocks, health and zombie-count
|
||||
displays freeze at game over. Health fallbacks only affect the display and
|
||||
never modify gameplay max health.
|
||||
|
||||
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
|
||||
It defaults visible, keeps your choice across death/respawn on the current
|
||||
connection/map, and resets visible on reconnect or map restart. Timers and data
|
||||
continue updating while hidden; game-over values remain frozen. Help, records
|
||||
and the stock HUD are unaffected. `.hud` requires the HUD module.
|
||||
|
||||
Common provides one unboxed upper-right service area below the Plutonium tag.
|
||||
Records are right aligned, with `Map record | Round N` followed by up to three
|
||||
holder lines; there is no replay footer or single-page counter. Help displays
|
||||
the installed command list in pages of nine rows in aligned rows. Services remain private, leave 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).
|
||||
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), four for records (five with a multi-page
|
||||
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
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
|
||||
|
||||
HUD `1.4.2` requires common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
|
||||
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
|
||||
updated HUD script and restart the map on r5334+; no full server-process
|
||||
restart is required. Older Plutonium revisions need client/server updates and
|
||||
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).
|
||||
|
||||
## Map record announcements
|
||||
|
||||
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
|
||||
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
|
||||
requires a full server-process restart. To omit leaderboard, remove its script;
|
||||
@@ -46,27 +120,50 @@ 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)
|
||||
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
|
||||
|
||||
Type `.help` or `.commands` in chat to see available player commands and a short description.
|
||||
The response is visible only to the requesting player and works while spectating,
|
||||
before the first spawn. Commands are case-insensitive.
|
||||
Help appears below the health bar for 12 seconds, clear of the timers, zombie
|
||||
counter and health display. Typing either command again restarts that display time.
|
||||
Help uses the unboxed upper-right service area. All nine installed commands
|
||||
fit in one list with command and description columns. Additional registered
|
||||
commands use pages of nine rows, with a counter only for multi-page lists.
|
||||
Pages advance every 12 seconds and close after the last page. Typing either
|
||||
command again returns to page one with a fresh timer. Both engines use
|
||||
explicit row positions so T5 does not concatenate newline-separated text.
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
|
||||
| `.afk` | Enter protected AFK using your time reserve |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
| `.b` | Check bank balance (bank module required) |
|
||||
| `.d <amount\|all>` | Deposit points (bank module required) |
|
||||
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
||||
|
||||
Each module registers its own help entries; removing AFK or join also removes
|
||||
those entries from `.help` after a map restart.
|
||||
Each module registers its own help entries. Commands follow the order above,
|
||||
independent of initialization order; removing a module also removes its entries
|
||||
from `.help` after a map restart.
|
||||
With all command modules installed, help shows one page of nine commands,
|
||||
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
|
||||
removes `.hud` from the list.
|
||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||
cached map record privately. Repeating it restarts the record from the first page;
|
||||
help temporarily hides the record until its own display ends.
|
||||
help pauses the record's remaining page time and resumes it after the final
|
||||
help page. `.leaders` during help replaces the pending record replay from page one.
|
||||
|
||||
## Dependency graph
|
||||
|
||||
@@ -116,16 +213,20 @@ graph BT
|
||||
|
||||
## tws_t5_common.gsc
|
||||
|
||||
Hub file. Shared config, HUD helpers, spawn-handler registry, and the
|
||||
Hub file. Shared config, HUD helpers, service window, spawn-handler registry, and the
|
||||
single `level waittill("connected")` in the fleet. Adding a second
|
||||
connect listener anywhere else crashes the T5 SP handshake.
|
||||
|
||||
**Features**
|
||||
- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`,
|
||||
`hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold`
|
||||
- Shared HUD constants for overlay positions, rows/columns, fonts, health bar,
|
||||
colors and update rate
|
||||
- Spawn-handler registry
|
||||
- Connection-handler registry for optional modules that serve spectator newcomers
|
||||
- `.help` listener and help registry populated by installed command modules
|
||||
- `.help` / `.commands` listener and ordered help registry populated by installed modules
|
||||
- One service controller per player, with help/record pagination,
|
||||
interruption/resume, request replacement and disconnect/game-end cleanup;
|
||||
display elements exist only for the active view and are released when idle
|
||||
- Core element registry with per-element alpha state and a per-player visibility mask
|
||||
- Connect listener + per-player dispatch: iterates
|
||||
`level.tws_spawn_handlers` after `spawned_player` and threads each on
|
||||
the player
|
||||
@@ -140,6 +241,19 @@ connect listener anywhere else crashes the T5 SP handshake.
|
||||
| `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` |
|
||||
| `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline |
|
||||
| `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element |
|
||||
| `make_overlay_text(p, x, y, ax, s, c)` | Text at absolute fullscreen coordinates in the normalized `640 × 480` frame |
|
||||
| `make_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Shader at absolute fullscreen coordinates |
|
||||
| `make_overlay_width(width)` | Clamp a normalized width and return the integer required by `SetShader` |
|
||||
| `make_core_overlay_text(p, x, y, ax, s, c)` | Create overlay text registered for `.hud` visibility |
|
||||
| `make_core_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Create an overlay shader registered for `.hud` visibility |
|
||||
| `make_panel_text(p, x, y, ax, s, c)` | Legacy text positioned relative to the former upper-right panel origin |
|
||||
| `make_panel_shader(p, x, y, w, h, c, alpha, sort)` | Legacy shader positioned relative to that panel origin |
|
||||
| `make_panel_width(width)` | Retained legacy design-width conversion for existing consumers |
|
||||
| `make_core_text(p, x, y, ax, s, c)` | Retained legacy panel text with `.hud` visibility |
|
||||
| `make_core_shader(p, x, y, w, h, c, alpha, sort)` | Retained legacy panel shader with `.hud` visibility |
|
||||
| `set_core_alpha(p, elem, alpha)` | Preserve the element's intended alpha while applying the player's visibility choice |
|
||||
| `toggle_core_hud()` | Toggle the player's registered core elements without stopping updates |
|
||||
| `show_record(snapshot)` | Replace the player's record service request; an undefined snapshot shows unavailable |
|
||||
| `destroy_on_disconnect(elems)` | Waits on `disconnect`, destroys the array |
|
||||
| `int_dvar_or(name, fallback)` | Parse an integer dvar with default |
|
||||
| `format_mmss(elapsed_ms)` | Format milliseconds as `M:SS` |
|
||||
@@ -148,27 +262,44 @@ connect listener anywhere else crashes the T5 SP handshake.
|
||||
|
||||
**Live validation (requires a T5 server)**
|
||||
|
||||
1. Type `.help` and `.HELP` while playing and while spectating before the first
|
||||
spawn. Verify all four descriptions appear below the health bar, only for the
|
||||
requesting player. Take damage so health numbers are visible and check that
|
||||
they do not cover the help text.
|
||||
1. Type `.help`, `.HELP` and `.commands` while playing and spectating before the
|
||||
first spawn. Verify all nine commands appear in one unboxed list, private to
|
||||
the requesting player. Check explicit rows, full bank syntax and the absence
|
||||
of a single-page counter. HP should appear beside the bottom health bar only
|
||||
while injured.
|
||||
2. Remove AFK or join, restart the map, and verify its entries disappear while
|
||||
`.help` and the remaining commands still work. Restore the scripts afterward.
|
||||
3. Repeat `.help` before 12 seconds elapse; verify one list stays visible for
|
||||
12 seconds after the latest request, then hides. Reopen it after expiry,
|
||||
die/respawn and reconnect; verify no duplicate text or leftover help elements.
|
||||
3. Verify help closes after 12 seconds. Add custom registered commands to test
|
||||
the nine-row page limit, counter and repeat-to-restart behavior. Interrupt a record
|
||||
with help and verify remaining page time resumes; repeat `.leaders` during
|
||||
help and verify only the latest replay starts from page one.
|
||||
4. Reopen after expiry, die/respawn and reconnect; verify no duplicate text or
|
||||
leftover elements. Repeat without the HUD module. Follow the complete
|
||||
[HUD validation guide](../../README.md#hud-validation) before rollout.
|
||||
5. Watch the first automatic record announcement and its dismissal. Repeat
|
||||
records, help and interrupted/resumed records alongside stock notifications.
|
||||
Check that branding, the health bar, damage-only HP, zombie count and timers
|
||||
remain correct during each view and after it closes.
|
||||
Include other installed HUD mods; the live T5 SP element limit is unverified.
|
||||
6. Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` at different resolutions:
|
||||
the complete text must stay centered above the health track. Repeat after
|
||||
toggling `.hud` and after game over freezes the count.
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_hud.gsc
|
||||
|
||||
Bottom-left cluster. Per-player elements spawn via `::hud_start`.
|
||||
Unboxed core overlays. Per-player elements are created on connection;
|
||||
gameplay updates start through the existing spawn lifecycle.
|
||||
|
||||
**Features**
|
||||
- Match timer + round timer (round resets each round, both freeze on game over)
|
||||
- Player-name row (only visible in co-op)
|
||||
- Zombies-remaining counter
|
||||
- Health bar with green / amber / red ramp; numbers fade in below full HP
|
||||
- Upper-left match timer above round timer (round resets each round, both freeze on game over)
|
||||
- `.hud` toggles this player's core; the AFK countdown stays visible and data updates continue
|
||||
- Top-center `TWS ZOMBIES | discord.tws.lol` branding
|
||||
- One `N ZOMBIES` element centered above the health track, separate from the central AFK countdown
|
||||
- Slim health bar with green / amber / red ramp; current/maximum HP appears
|
||||
beside it only while injured
|
||||
- Spectator placeholders without starting the match clock on connection
|
||||
- Round-transition and game-over notify machinery
|
||||
|
||||
**Provides**
|
||||
@@ -182,47 +313,38 @@ Bottom-left cluster. Per-player elements spawn via `::hud_start`.
|
||||
|
||||
Spawn handler registered: `::hud_start` — threads `hud_timer_row`,
|
||||
`hud_zombie_counter`, `hud_health_display`.
|
||||
Connection handler registered: `::hud_connect` — creates the core with placeholders
|
||||
without seeding clocks; `hud_start` calls it as a fallback if needed.
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`,
|
||||
`make_shader_elem`, `destroy_on_disconnect`, `format_mmss`,
|
||||
`register_spawn_handler`
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
|
||||
`make_core_overlay_shader`, `make_overlay_width`, `set_core_alpha`, `toggle_core_hud`,
|
||||
`destroy_on_disconnect`, `format_mmss`, `register_help_command`,
|
||||
`register_spawn_handler`, `register_connect_handler`
|
||||
- Every `level.hud_*` config value from `tws_t5_common`
|
||||
- Plutonium r5334+ for the count's `SetTextUnlimited` updates
|
||||
- Stock BO1 state: `level.round_number`, `level.zombie_total`,
|
||||
`level.players`, `self.health`, `self.maxhealth`, `self.name` /
|
||||
`self.playername`, AI entities' `.animname` and `.ignore_enemy_count`
|
||||
`level.players`, `self.health`, `self.maxhealth`, AI entities' `.animname`
|
||||
and `.ignore_enemy_count`
|
||||
|
||||
---
|
||||
|
||||
## tws_t5_afk.gsc
|
||||
|
||||
Self-invoked pause mode. Owns the `[AFK]` indicator element entirely — HUD
|
||||
module has zero knowledge of AFK.
|
||||
Persistent protected breaks using the shared GUID reserve. See the
|
||||
[AFK guide](../../docs/afk.md) for commands, policy, ownership, native APIs,
|
||||
crash recovery and live validation. Requires common `1.4.0+`, telemetry `1.0.0+`,
|
||||
matching plugin `1.4.0+`, Plutonium r5334+ and EC #151.
|
||||
|
||||
**Features**
|
||||
- Chat commands `.afk` and `.back` (via `level waittill("say")` — not
|
||||
`callbackPlayerSay`, which never fires on T5 SP dedicated)
|
||||
- On enter: `enableInvulnerability`, `freezeControls(true)`,
|
||||
`self.ignoreme = true` (drops out of zombie targeting)
|
||||
- On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through
|
||||
the target-reacquisition window
|
||||
- `[AFK]` HUD element created per-player via spawn handler; alpha-toggled
|
||||
by enter / exit
|
||||
|
||||
**Provides**
|
||||
- Per-player: `self.hud_afk_on` (bool), `self.hud_afk_elem` (HUD element),
|
||||
writes `self.ignoreme` (stock BO1 targeting flag)
|
||||
- Level var: `level.afk_grace_seconds` (default 2)
|
||||
|
||||
**Depends on**
|
||||
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`,
|
||||
`destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
|
||||
- Shared config: `level.hud_x`, `level.hud_row_match`,
|
||||
`level.hud_font_small`, `level.hud_c_afk`
|
||||
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
|
||||
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
|
||||
|
||||
Spawn handler registered: `::afk_spawn`.
|
||||
Chat uses `level waittill("say")`; the independent central countdown stays visible
|
||||
with `.hud` hidden. Activation rejects laststand before changing state. A stock
|
||||
damage callback owns only AFK protection; expiration immediately relinquishes it
|
||||
and AFK-owned controls/targeting, while manual `.back` keeps its separate two-second
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -486,3 +608,16 @@ and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Com
|
||||
and leaderboard HTTP, JSON, type and string helpers. Install a build exposing
|
||||
the natives listed in each module's documentation, then restart the server
|
||||
process. No database is required.
|
||||
|
||||
## Moderation
|
||||
|
||||
Optional `tws_t5_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
|
||||
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
|
||||
configuration, persistence, action results and deployment checks.
|
||||
|
||||
## Persistent AFK reserve
|
||||
|
||||
AFK `2.0.0` uses EC authorization and a local central countdown. See the
|
||||
[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.
|
||||
The countdown remains visible with `.hud` hidden; expiration has no grace period.
|
||||
|
||||
+945
-58
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
/* tws_t5_bank.gsc - shared GUID bank, with durable transfer decisions. */
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0")
|
||||
return;
|
||||
// Game containers use the public API, independent of the host controller URL.
|
||||
level.bank_url = "https://ec.thewrightserver.net/api/bank";
|
||||
level.bank_server = GetDvar("tws_server_id");
|
||||
level.bank_token = GetDvar("tws_bank_token");
|
||||
level.bank_logs = [];
|
||||
level.bank_diagnostic_serial = 0;
|
||||
credentials = "valid";
|
||||
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
||||
credentials = "invalid";
|
||||
print("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
|
||||
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
|
||||
" credentials=" + credentials);
|
||||
// A private per-server cfg supplies this; never embed bank credentials.
|
||||
if (level.bank_server == "" || level.bank_token.size < 40 ||
|
||||
getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
||||
{
|
||||
print("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token; pending=not_loaded");
|
||||
return;
|
||||
}
|
||||
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
|
||||
if (!isDefined(level.bank_queue))
|
||||
{
|
||||
print("[tws_bank] startup journal_load=failure pending=unknown bank=disabled");
|
||||
return;
|
||||
}
|
||||
print("[tws_bank] startup journal_load=success pending=" + level.bank_queue.size);
|
||||
for (i = 0; i < level.bank_queue.size; i++)
|
||||
{
|
||||
level.bank_queue[i]["since"] = getTime();
|
||||
level.bank_queue[i]["attempts"] = 0;
|
||||
}
|
||||
level.bank_read = 0;
|
||||
level.bank_write = level.bank_queue.size;
|
||||
level.bank_busy = false;
|
||||
level.bank_ended = false;
|
||||
scripts\sp\tws_t5_common::register_help_command(".b", "Show bank balance");
|
||||
scripts\sp\tws_t5_common::register_help_command(".d <amount|all>", "Deposit points");
|
||||
scripts\sp\tws_t5_common::register_help_command(".w <amount|all>", "Withdraw points");
|
||||
level thread bank_end_watcher();
|
||||
level thread bank_sender();
|
||||
level thread bank_chat_listener();
|
||||
}
|
||||
|
||||
bank_end_watcher()
|
||||
{
|
||||
level waittill_any("end_game", "game_ended", "intermission");
|
||||
level.bank_ended = true;
|
||||
}
|
||||
|
||||
bank_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
args = strTok(toLower(message), " ");
|
||||
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
|
||||
continue;
|
||||
if (level.bank_busy || level.bank_read < level.bank_write)
|
||||
{
|
||||
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
|
||||
continue;
|
||||
}
|
||||
level.bank_busy = true;
|
||||
level thread bank_request(player, args);
|
||||
}
|
||||
}
|
||||
|
||||
bank_amount(text)
|
||||
{
|
||||
if (text == "all")
|
||||
return -1;
|
||||
if (text.size == 0 || text.size > 7)
|
||||
return 0;
|
||||
digits = "0123456789";
|
||||
for (i = 0; i < text.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < digits.size; j++)
|
||||
if (text[i] == digits[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return 0;
|
||||
}
|
||||
amount = int(text);
|
||||
if (amount < 1 || amount > 1000000)
|
||||
return 0;
|
||||
return amount;
|
||||
}
|
||||
|
||||
bank_playing(player)
|
||||
{
|
||||
return !level.bank_ended && !is_true(level.intermission) &&
|
||||
isDefined(player) && isDefined(player.sessionstate) &&
|
||||
player.sessionstate == "playing" && isAlive(player) &&
|
||||
!(player maps\_laststand::player_is_in_laststand()) &&
|
||||
isDefined(player.score) && player.score >= 0;
|
||||
}
|
||||
|
||||
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
|
||||
bank_guard(entry)
|
||||
{
|
||||
self endon("tws_bank_finished");
|
||||
self waittill_any("disconnect", "killed_player", "spawned_player");
|
||||
entry.active = false;
|
||||
}
|
||||
|
||||
bank_say(entry, text)
|
||||
{
|
||||
if (entry.active && isDefined(entry.player))
|
||||
entry.player iPrintLnBold(text);
|
||||
}
|
||||
|
||||
bank_request(player, args)
|
||||
{
|
||||
entry = spawnStruct();
|
||||
entry.player = player;
|
||||
entry.active = true;
|
||||
player thread bank_guard(entry);
|
||||
bank_transfer(entry, args);
|
||||
if (entry.active && isDefined(player))
|
||||
player notify("tws_bank_finished");
|
||||
level.bank_busy = false;
|
||||
}
|
||||
|
||||
bank_guid(player)
|
||||
{
|
||||
if (!isDefined(player))
|
||||
return undefined;
|
||||
guid = player getGuid();
|
||||
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
||||
return undefined;
|
||||
// T5 can return a number. Preserve string identities without numeric casts.
|
||||
guid = "" + guid;
|
||||
if (guid.size == 0 || guid.size > 64)
|
||||
return undefined;
|
||||
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
||||
for (i = 0; i < guid.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < allowed.size; j++)
|
||||
if (guid[i] == allowed[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return undefined;
|
||||
}
|
||||
return guid;
|
||||
}
|
||||
|
||||
bank_transfer(entry, args)
|
||||
{
|
||||
player = entry.player;
|
||||
guid = bank_guid(player);
|
||||
if (!isDefined(guid))
|
||||
{
|
||||
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
|
||||
return;
|
||||
}
|
||||
data = [];
|
||||
data["server"] = level.bank_server;
|
||||
data["guid"] = guid;
|
||||
if (args[0] == ".b")
|
||||
{
|
||||
if (args.size != 1)
|
||||
{
|
||||
bank_say(entry, "^3Usage: .b");
|
||||
return;
|
||||
}
|
||||
reply = bank_http("balance", jsonSerialize(data), "");
|
||||
if (!isDefined(reply) || reply["state"] != "balance")
|
||||
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
|
||||
else
|
||||
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
|
||||
return;
|
||||
}
|
||||
if (args.size != 2)
|
||||
{
|
||||
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
|
||||
return;
|
||||
}
|
||||
amount = bank_amount(args[1]);
|
||||
if (amount == 0)
|
||||
{
|
||||
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
|
||||
return;
|
||||
}
|
||||
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
|
||||
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
|
||||
{
|
||||
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
|
||||
return;
|
||||
}
|
||||
operation = "withdraw";
|
||||
if (args[0] == ".d")
|
||||
{
|
||||
operation = "deposit";
|
||||
if (amount == -1)
|
||||
amount = int(player.score);
|
||||
if (amount <= 0 || amount > 1000000 || player.score < amount)
|
||||
{
|
||||
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
capacity = 1000000 - int(player.score);
|
||||
if (capacity < 0)
|
||||
capacity = 0;
|
||||
if (operation == "withdraw" && amount != -1 && amount > capacity)
|
||||
{
|
||||
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
|
||||
return;
|
||||
}
|
||||
id = telemetryNewMatch();
|
||||
if (!isDefined(id))
|
||||
{
|
||||
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
|
||||
return;
|
||||
}
|
||||
data["request_id"] = id;
|
||||
data["match"] = level.telemetry_match;
|
||||
data["operation"] = operation;
|
||||
data["amount"] = amount;
|
||||
if (amount == -1)
|
||||
data["amount"] = "all";
|
||||
data["capacity"] = capacity;
|
||||
data["decision"] = "cancel";
|
||||
body = jsonSerialize(data);
|
||||
pending_since = getTime();
|
||||
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
|
||||
if (!bank_store(body, id, "cancel"))
|
||||
{
|
||||
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||
return;
|
||||
}
|
||||
reply = bank_http(operation, body, id);
|
||||
if (isDefined(reply) && reply["state"] == "prepared")
|
||||
{
|
||||
transfer = reply["amount"];
|
||||
if (entry.active && bank_playing(player) &&
|
||||
(amount == -1 || amount == transfer) &&
|
||||
((operation == "deposit" && player.score >= transfer) ||
|
||||
(operation == "withdraw" && player.score <= 1000000 - transfer)))
|
||||
{
|
||||
data["decision"] = "commit";
|
||||
commit_body = jsonSerialize(data);
|
||||
// No yield between recording the decision and updating the balance.
|
||||
// Do not change score_total: bank funds are not earned match points.
|
||||
if (bank_store(commit_body, id, "commit"))
|
||||
{
|
||||
if (operation == "deposit")
|
||||
player.score -= transfer;
|
||||
else
|
||||
player.score += transfer;
|
||||
player.pers["score"] = player.score;
|
||||
body = commit_body;
|
||||
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
|
||||
}
|
||||
else
|
||||
{
|
||||
data["decision"] = "cancel";
|
||||
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||
}
|
||||
}
|
||||
else
|
||||
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
|
||||
}
|
||||
else if (isDefined(reply) && reply["state"] == "rejected")
|
||||
bank_say(entry, bank_error(reply["code"]));
|
||||
else
|
||||
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
|
||||
pending = [];
|
||||
pending["id"] = id;
|
||||
pending["body"] = body;
|
||||
pending["decision"] = data["decision"];
|
||||
pending["since"] = pending_since;
|
||||
pending["attempts"] = 0;
|
||||
level.bank_queue[level.bank_write] = pending;
|
||||
level.bank_write++;
|
||||
}
|
||||
|
||||
bank_error(code)
|
||||
{
|
||||
if (code == "insufficient_balance" || code == "empty")
|
||||
return "^3Not enough bank balance. Your points are unchanged.";
|
||||
if (code == "bank_limit")
|
||||
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
|
||||
if (code == "points_limit")
|
||||
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
|
||||
if (code == "not_in_match")
|
||||
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
|
||||
if (code == "pending")
|
||||
return "^3A bank transfer is pending on another server. Try again shortly.";
|
||||
return "^3Bank rejected the transfer. Your points are unchanged.";
|
||||
}
|
||||
|
||||
// Only locally generated identities/configured server names pass through here.
|
||||
// Never use this to print response fields: those use fixed vocabularies below.
|
||||
bank_log_token(value)
|
||||
{
|
||||
if (!isDefined(value) || typeof(value) != "string" || value.size == 0 || value.size > 128)
|
||||
return "unavailable";
|
||||
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < allowed.size; j++)
|
||||
if (value[i] == allowed[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return "invalid";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bank_response_code(code)
|
||||
{
|
||||
// Unknown values may contain secrets or console controls, even if short.
|
||||
codes = strTok("ok insufficient_balance empty bank_limit points_limit not_in_match pending invalid_request unauthorized forbidden not_found conflict disabled", " ");
|
||||
for (i = 0; i < codes.size; i++)
|
||||
if (isDefined(code) && code == codes[i])
|
||||
return codes[i];
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bank_response_reason(reason)
|
||||
{
|
||||
reasons = strTok("empty_body body_limit invalid_json schema field_limit state amount_range ok_state missing_id zero_transfer", " ");
|
||||
for (i = 0; i < reasons.size; i++)
|
||||
if (isDefined(reason) && reason == reasons[i])
|
||||
return reasons[i];
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bank_diagnostic_number(diagnostics, key)
|
||||
{
|
||||
if (!isDefined(diagnostics) || typeof(diagnostics) != "array" || !isDefined(diagnostics[key]))
|
||||
return "unavailable";
|
||||
value = diagnostics[key];
|
||||
if ((typeof(value) != "int" && typeof(value) != "float") || value < 0 || value > 2147483647)
|
||||
return "unavailable";
|
||||
return "" + int(value);
|
||||
}
|
||||
|
||||
bank_http_details(diagnostics)
|
||||
{
|
||||
detail = "";
|
||||
keys = strTok("http_status curl_code dns_ms connect_ms tls_ms total_ms", " ");
|
||||
for (i = 0; i < keys.size; i++)
|
||||
detail += " " + keys[i] + "=" + bank_diagnostic_number(diagnostics, keys[i]);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// Fixed keys bound memory. Correlation IDs/timings never form the signature.
|
||||
// Report first failure, reason/status/code changes, 30s reminders and recovery.
|
||||
bank_log(key, signature, detail, failed)
|
||||
{
|
||||
now = getTime();
|
||||
previous = level.bank_logs[key];
|
||||
suppressed = 0;
|
||||
recovery = "";
|
||||
if (isDefined(previous))
|
||||
{
|
||||
suppressed = previous["suppressed"];
|
||||
if (failed && previous["signature"] == signature && now >= previous["time"] && now - previous["time"] < 30000)
|
||||
{
|
||||
previous["suppressed"]++;
|
||||
level.bank_logs[key] = previous;
|
||||
return;
|
||||
}
|
||||
if (previous["failed"] && !failed)
|
||||
recovery = " recovery=true";
|
||||
}
|
||||
print("[tws_bank] " + detail + recovery + " suppressed=" + suppressed);
|
||||
current = [];
|
||||
current["signature"] = signature;
|
||||
current["time"] = now;
|
||||
current["failed"] = failed;
|
||||
current["suppressed"] = 0;
|
||||
level.bank_logs[key] = current;
|
||||
}
|
||||
|
||||
bank_store(body, id, decision)
|
||||
{
|
||||
saved = bankStore(level.bank_server, level.bank_url, body);
|
||||
outcome = "success";
|
||||
if (!saved)
|
||||
outcome = "storage_failure";
|
||||
bank_log("write_" + decision, outcome, "journal_write decision=" + decision +
|
||||
" request_id=" + bank_log_token(id) + " outcome=" + outcome, !saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
bank_http_outcome(action, id, correlation, reason, code, diagnostics, failed)
|
||||
{
|
||||
level.bank_http_signature = reason + ":" + code + ":" +
|
||||
bank_diagnostic_number(diagnostics, "http_status") + ":" + bank_diagnostic_number(diagnostics, "curl_code");
|
||||
level.bank_http_detail = "correlation=" + correlation + " action=" + action +
|
||||
" request_id=" + bank_log_token(id) + " outcome=" + reason + " code=" + code + bank_http_details(diagnostics);
|
||||
// Settlement adds progress and cleanup outcome before logging this attempt.
|
||||
if (action != "commit" && action != "cancel")
|
||||
bank_log(action, level.bank_http_signature, level.bank_http_detail, failed);
|
||||
}
|
||||
|
||||
bank_http(action, body, id)
|
||||
{
|
||||
level.bank_diagnostic_serial++;
|
||||
correlation = "bank-" + getTime() + "-" + level.bank_diagnostic_serial;
|
||||
headers = [];
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Authorization"] = "Bearer " + level.bank_token;
|
||||
options = [];
|
||||
options["method"] = "POST";
|
||||
options["headers"] = headers;
|
||||
options["body"] = body;
|
||||
options["connect_timeout_ms"] = 5000;
|
||||
options["timeout_ms"] = 15000;
|
||||
request = http::request(level.bank_url + "/" + action, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, "request_creation", "none", undefined, true);
|
||||
return undefined;
|
||||
}
|
||||
request waittill("done", response, success, error, diagnostics);
|
||||
reason = "ok";
|
||||
if (!isDefined(success) || !success)
|
||||
reason = "transport_failure";
|
||||
else if (bank_diagnostic_number(diagnostics, "http_status") == "unavailable")
|
||||
reason = "missing_diagnostics";
|
||||
else if (diagnostics["http_status"] != 200)
|
||||
reason = "http_rejection";
|
||||
else if (!isDefined(response))
|
||||
reason = "missing_response";
|
||||
if (reason != "ok")
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, reason, "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
reply = bankResponse(response);
|
||||
if (!isDefined(reply) || !isDefined(reply["valid"]) || !reply["valid"])
|
||||
{
|
||||
reason = "unknown";
|
||||
if (isDefined(reply))
|
||||
reason = bank_response_reason(reply["reason"]);
|
||||
bank_http_outcome(action, id, correlation, "invalid_ack_" + reason, "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
if (reply["request_id"] != id)
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, "request_id_mismatch", "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
if (reply["state"] == "rejected")
|
||||
reason = "application_rejection";
|
||||
else if ((action == "balance" && reply["state"] != "balance") ||
|
||||
((action == "deposit" || action == "withdraw") && reply["state"] != "prepared") ||
|
||||
((action == "commit" || action == "cancel") && !bank_resolved(action, reply["state"])))
|
||||
reason = "unexpected_state";
|
||||
bank_http_outcome(action, id, correlation, reason, bank_response_code(reply["code"]), diagnostics, reason != "ok");
|
||||
return reply;
|
||||
}
|
||||
|
||||
bank_resolved(decision, state)
|
||||
{
|
||||
if (decision == "commit")
|
||||
return state == "committed";
|
||||
return state == "cancelled" || state == "rejected";
|
||||
}
|
||||
|
||||
bank_sender()
|
||||
{
|
||||
delay = 1;
|
||||
for (;;)
|
||||
{
|
||||
if (level.bank_busy || level.bank_read >= level.bank_write)
|
||||
{
|
||||
wait 0.1;
|
||||
continue;
|
||||
}
|
||||
entry = level.bank_queue[level.bank_read];
|
||||
entry["attempts"]++;
|
||||
level.bank_queue[level.bank_read] = entry;
|
||||
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
|
||||
duration = int((getTime() - entry["since"]) / 1000);
|
||||
progress = " decision=" + entry["decision"] + " attempt=" + entry["attempts"] +
|
||||
" pending_seconds=" + duration;
|
||||
outcome = "pending";
|
||||
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
|
||||
{
|
||||
// Cleanup failures never reapply points; settlement is idempotent.
|
||||
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
|
||||
{
|
||||
bank_log("settlement", "resolved", level.bank_http_detail + progress +
|
||||
" journal_remove=success settlement=resolved next_retry_seconds=0", false);
|
||||
level.bank_queue[level.bank_read] = undefined;
|
||||
level.bank_read++;
|
||||
if (level.bank_read == level.bank_write)
|
||||
{
|
||||
level.bank_queue = [];
|
||||
level.bank_read = 0;
|
||||
level.bank_write = 0;
|
||||
}
|
||||
delay = 1;
|
||||
continue;
|
||||
}
|
||||
outcome = "journal_remove_failure";
|
||||
}
|
||||
bank_log("settlement", level.bank_http_signature + ":" + outcome, level.bank_http_detail + progress +
|
||||
" settlement=" + outcome + " next_retry_seconds=" + delay, true);
|
||||
wait delay;
|
||||
delay *= 2;
|
||||
if (delay > 30)
|
||||
delay = 30;
|
||||
}
|
||||
}
|
||||
+613
-70
@@ -3,6 +3,11 @@
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
main()
|
||||
{
|
||||
precacheShader("white");
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
@@ -11,6 +16,9 @@ init()
|
||||
common_config();
|
||||
|
||||
register_help_command(".help", "List available commands");
|
||||
level thread service_game_over_via("end_game");
|
||||
level thread service_game_over_via("game_ended");
|
||||
level thread service_game_over_via("intermission");
|
||||
level thread help_chat_listener();
|
||||
level thread on_player_connect_dispatch();
|
||||
}
|
||||
@@ -20,41 +28,84 @@ common_config()
|
||||
if (isDefined(level.hud_x))
|
||||
return;
|
||||
|
||||
// anchor: move the whole cluster with these two
|
||||
// Retain legacy panel coordinates for existing helper consumers.
|
||||
level.hud_x = 10;
|
||||
level.hud_y = 10;
|
||||
|
||||
level.hud_row_match = level.hud_y;
|
||||
level.hud_row_round = level.hud_y + 15;
|
||||
level.hud_row_name = level.hud_y + 32;
|
||||
level.hud_row_count = level.hud_y + 54;
|
||||
level.hud_row_bar = level.hud_y + 84;
|
||||
|
||||
level.hud_font_small = 1.4;
|
||||
level.hud_font_large = 2.0;
|
||||
level.hud_font_mid = 1.6;
|
||||
|
||||
level.hud_bar_width = 120;
|
||||
level.hud_bar_height = 8;
|
||||
|
||||
level.hud_col_count = level.hud_x + 90;
|
||||
level.hud_col_name = level.hud_x + 60;
|
||||
level.hud_col_health = level.hud_x + level.hud_bar_width + 12;
|
||||
|
||||
level.hud_rate = 0.1;
|
||||
|
||||
// green/amber/red ramp is health-only
|
||||
level.hud_c_label = (0.85, 0.85, 0.85);
|
||||
level.hud_c_dim = (0.55, 0.55, 0.55);
|
||||
level.hud_c_count = (1, 1, 1);
|
||||
level.hud_c_name = (0.4, 0.9, 0.5);
|
||||
level.hud_c_high = (0.2, 1, 0.4);
|
||||
level.hud_c_mid = (1, 0.8, 0);
|
||||
level.hud_c_low = (1, 0.2, 0.2);
|
||||
level.hud_c_afk = (1, 0.8, 0.2);
|
||||
|
||||
// numbers fade in below this fraction of max hp
|
||||
level.hud_y = 30;
|
||||
// Retain the legacy fields for consumers of the original common config.
|
||||
// The panel renderers below use dedicated row/column offsets instead.
|
||||
level.hud_row_match = level.hud_y;
|
||||
level.hud_row_round = level.hud_y + 15;
|
||||
level.hud_row_name = level.hud_y + 32;
|
||||
level.hud_row_count = level.hud_y + 54;
|
||||
level.hud_col_count = level.hud_x + 90;
|
||||
level.hud_col_name = level.hud_x + 60;
|
||||
level.hud_col_health = level.hud_x + 120 + 12;
|
||||
level.hud_health_threshold = 1.0;
|
||||
level.hud_panel_right = 10;
|
||||
level.hud_panel_top = 20;
|
||||
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
|
||||
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
|
||||
level.hud_panel_x_scale = 0.75;
|
||||
level.hud_panel_width = 220;
|
||||
level.hud_core_height = 100;
|
||||
level.hud_service_y = 108;
|
||||
level.hud_service_height = 124;
|
||||
level.hud_pad = 10;
|
||||
level.hud_row_header = 10;
|
||||
level.hud_row_timers = 34;
|
||||
level.hud_row_stats = 59;
|
||||
level.hud_row_bar = 80;
|
||||
level.hud_row_afk = 86;
|
||||
|
||||
level.hud_font_small = 1.1;
|
||||
level.hud_font_large = 1.6;
|
||||
level.hud_font_mid = 1.25;
|
||||
level.hud_font_afk = 1.0;
|
||||
level.hud_bar_width = 200;
|
||||
level.hud_bar_height = 4;
|
||||
level.hud_rate = 0.1;
|
||||
level.hud_c_panel = (0.07, 0.10, 0.08);
|
||||
level.hud_panel_alpha = 0.72;
|
||||
level.hud_c_label = (0.72, 0.77, 0.74);
|
||||
level.hud_c_dim = (0.65, 0.70, 0.67);
|
||||
level.hud_c_count = (0.93, 0.96, 0.94);
|
||||
level.hud_c_name = (0.65, 0.85, 0.70);
|
||||
level.hud_c_high = (0.65, 0.85, 0.70);
|
||||
level.hud_c_mid = (1, 0.8, 0);
|
||||
level.hud_c_low = (1, 0.2, 0.2);
|
||||
level.hud_c_afk = (1, 0.8, 0.2);
|
||||
|
||||
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
|
||||
level.hud_overlay_timer_x = 6;
|
||||
level.hud_overlay_match_y = 8;
|
||||
level.hud_overlay_round_y = 30;
|
||||
level.hud_overlay_brand_x = 320;
|
||||
level.hud_overlay_brand_y = 8;
|
||||
level.hud_overlay_count_x = 300;
|
||||
level.hud_overlay_count_y = 440;
|
||||
level.hud_overlay_count_label_x = 306;
|
||||
level.hud_overlay_count_label_y = 446;
|
||||
level.hud_overlay_bar_x = 242;
|
||||
level.hud_overlay_bar_y = 466;
|
||||
level.hud_overlay_bar_width = 156;
|
||||
level.hud_overlay_bar_height = 4;
|
||||
level.hud_overlay_health_y = 460;
|
||||
level.hud_overlay_health_x = 414;
|
||||
level.hud_overlay_slash_x = 418;
|
||||
level.hud_overlay_maxhealth_x = 445;
|
||||
level.hud_overlay_hp_x = 449;
|
||||
level.hud_overlay_afk_x = 320;
|
||||
level.hud_overlay_afk_y = 421;
|
||||
level.hud_service_left = 432;
|
||||
level.hud_service_right = 633;
|
||||
level.hud_service_description_x = 538;
|
||||
level.hud_service_title_y = 54;
|
||||
level.hud_service_help_y = 80;
|
||||
level.hud_service_help_pitch = 24;
|
||||
level.hud_service_help_rows = 9;
|
||||
level.hud_service_record_y = 36;
|
||||
level.hud_service_holders_y = 60;
|
||||
level.hud_service_holders_pitch = 18;
|
||||
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
level.tws_spawn_handlers = [];
|
||||
@@ -111,58 +162,408 @@ help_chat_listener()
|
||||
}
|
||||
}
|
||||
|
||||
// A single per-player controller owns service priority, pages and expiry.
|
||||
show_help()
|
||||
{
|
||||
// Cache the text per connection; repeated SetText calls consume config strings.
|
||||
if (!isDefined(self.tws_help_elems))
|
||||
{
|
||||
elems = [];
|
||||
// Use the same anchors as the HUD, with room below the health numbers.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
title = make_text_elem(self, level.hud_x, y,
|
||||
"left", level.hud_font_mid, level.hud_c_afk);
|
||||
title SetText("Commands");
|
||||
elems[elems.size] = title;
|
||||
if (isDefined(level.tws_service_ended))
|
||||
return;
|
||||
self service_ensure();
|
||||
state = self.tws_service;
|
||||
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.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
||||
service_render(state);
|
||||
}
|
||||
|
||||
// An undefined snapshot is the explicit unavailable view for manual requests.
|
||||
show_record(snapshot)
|
||||
{
|
||||
if (isDefined(level.tws_service_ended))
|
||||
return;
|
||||
self service_ensure();
|
||||
state = self.tws_service;
|
||||
service_advance(state, getTime());
|
||||
count = 0;
|
||||
if (isDefined(snapshot))
|
||||
count = snapshot.lines.size;
|
||||
state.generation++;
|
||||
state.record = service_request("record", service_page_count(count, 3), state.generation);
|
||||
state.record.snapshot = snapshot;
|
||||
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)
|
||||
{
|
||||
return int((max(count, 1) + rows - 1) / rows);
|
||||
}
|
||||
|
||||
service_request(kind, pages, generation)
|
||||
{
|
||||
request = spawnStruct();
|
||||
request.kind = kind;
|
||||
request.generation = generation;
|
||||
request.page = 0;
|
||||
request.pages = pages;
|
||||
request.remaining = 12000;
|
||||
if (kind == "reminder")
|
||||
request.remaining = 6000;
|
||||
return request;
|
||||
}
|
||||
|
||||
service_current(state)
|
||||
{
|
||||
if (isDefined(state.help))
|
||||
return state.help;
|
||||
return state.record;
|
||||
}
|
||||
|
||||
service_advance(state, now)
|
||||
{
|
||||
elapsed = max(0, now - state.updated_at);
|
||||
state.updated_at = now;
|
||||
request = service_current(state);
|
||||
if (!isDefined(request) || state.closed)
|
||||
return;
|
||||
|
||||
// Only the visible request consumes time. Help leaves the record untouched.
|
||||
request.remaining -= elapsed;
|
||||
if (request.remaining > 0)
|
||||
return;
|
||||
request.page++;
|
||||
request.remaining = 12000;
|
||||
if (request.page >= request.pages)
|
||||
{
|
||||
if (request.kind == "help")
|
||||
state.help = undefined;
|
||||
else
|
||||
state.record = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Stable order is independent of module initialization order; absent commands
|
||||
// stay absent. Unknown future commands follow the built-in ones in registry order.
|
||||
help_rank(command)
|
||||
{
|
||||
if (command == ".help") return 0;
|
||||
if (command == ".hud") return 1;
|
||||
if (command == ".afk") return 2;
|
||||
if (command == ".back") return 3;
|
||||
if (command == ".join") return 4;
|
||||
if (command == ".leaders") return 5;
|
||||
if (command == ".b") return 6;
|
||||
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
|
||||
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
|
||||
return 9;
|
||||
}
|
||||
|
||||
help_entries()
|
||||
{
|
||||
entries = [];
|
||||
if (!isDefined(level.tws_help_commands))
|
||||
return entries;
|
||||
for (rank = 0; rank <= 9; rank++)
|
||||
{
|
||||
for (i = 0; i < level.tws_help_commands.size; i++)
|
||||
{
|
||||
entry = level.tws_help_commands[i];
|
||||
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
elem SetText("^3" + entry.command + "^7 - " + entry.description);
|
||||
elems[elems.size] = elem;
|
||||
if (help_rank(entry.command) == rank)
|
||||
entries[entries.size] = entry;
|
||||
}
|
||||
|
||||
self.tws_help_elems = elems;
|
||||
self thread destroy_on_disconnect(elems);
|
||||
}
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 1;
|
||||
}
|
||||
|
||||
// Only the expiry timer is replaced; the say listener and cleanup stay alive.
|
||||
self notify("tws_help_refresh");
|
||||
self thread hide_help_after_delay();
|
||||
return entries;
|
||||
}
|
||||
|
||||
hide_help_after_delay()
|
||||
help_command_label(command)
|
||||
{
|
||||
return command;
|
||||
}
|
||||
|
||||
help_description(entry)
|
||||
{
|
||||
rank = help_rank(entry.command);
|
||||
if (rank == 0) return "Show commands";
|
||||
if (rank == 1) return "Toggle core HUD";
|
||||
if (rank == 2) return "Protect & freeze";
|
||||
if (rank == 3) return "Return to play";
|
||||
if (rank == 4) return "Join as newcomer";
|
||||
if (rank == 5) return "View records";
|
||||
if (rank == 6) return "Bank balance";
|
||||
if (rank == 7) return "Deposit";
|
||||
if (rank == 8) return "Withdraw";
|
||||
return entry.description;
|
||||
}
|
||||
|
||||
service_ensure()
|
||||
{
|
||||
if (isDefined(self.tws_service))
|
||||
return;
|
||||
ensure_config();
|
||||
state = spawnStruct();
|
||||
state.elems = [];
|
||||
state.commands = help_entries();
|
||||
state.help = undefined;
|
||||
state.record = undefined;
|
||||
state.closed = false;
|
||||
state.updated_at = getTime();
|
||||
state.rendered = false;
|
||||
state.generation = 0;
|
||||
state.last_generation = 0;
|
||||
state.last_page = -1;
|
||||
state.record_text = [];
|
||||
state.help_page = -1;
|
||||
state.rows = [];
|
||||
state.command_rows = [];
|
||||
state.description_rows = [];
|
||||
state.view_kind = undefined;
|
||||
self.tws_service = state;
|
||||
|
||||
self thread service_loop(state);
|
||||
self thread service_disconnect(state);
|
||||
self thread service_end_game(state);
|
||||
}
|
||||
|
||||
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
|
||||
service_view_create(state, request)
|
||||
{
|
||||
service_view_destroy(state);
|
||||
state.view_kind = request.kind;
|
||||
state.view_paged = request.pages > 1;
|
||||
if (state.view_paged)
|
||||
state.page_counter = service_text(self, state, level.hud_service_right,
|
||||
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
|
||||
|
||||
if (request.kind == "help")
|
||||
{
|
||||
state.help_title = service_text(self, state, level.hud_service_left,
|
||||
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
|
||||
rows = state.commands.size;
|
||||
if (rows > level.hud_service_help_rows)
|
||||
rows = level.hud_service_help_rows;
|
||||
// Explicit lines also work on T5, which can collapse multiline strings.
|
||||
for (i = 0; i < rows; i++)
|
||||
{
|
||||
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
|
||||
state.command_rows[i] = service_text(self, state, level.hud_service_left,
|
||||
y, "left", 1.0, "", level.hud_c_count);
|
||||
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
|
||||
y, "left", 1.0, "", level.hud_c_label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.record_title = service_text(self, state, level.hud_service_right,
|
||||
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
||||
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,
|
||||
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
||||
"right", 1.05, "", level.hud_c_label);
|
||||
if (isDefined(state.page_counter))
|
||||
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
|
||||
}
|
||||
|
||||
service_view_destroy(state)
|
||||
{
|
||||
for (i = 0; i < state.elems.size; i++)
|
||||
{
|
||||
if (isDefined(state.elems[i]))
|
||||
state.elems[i] Destroy();
|
||||
}
|
||||
state.elems = [];
|
||||
state.view_kind = undefined;
|
||||
state.view_paged = undefined;
|
||||
state.page_counter = undefined;
|
||||
state.help_title = undefined;
|
||||
state.command_rows = [];
|
||||
state.description_rows = [];
|
||||
state.record_title = undefined;
|
||||
state.rows = [];
|
||||
state.page_text = undefined;
|
||||
state.record_header_text = undefined;
|
||||
state.record_text = [];
|
||||
state.help_page = -1;
|
||||
}
|
||||
|
||||
service_text(player, state, x, y, align, scale, text, colour)
|
||||
{
|
||||
elem = make_overlay_text(player, x, y, align, scale, colour);
|
||||
elem.alpha = 0;
|
||||
elem.archived = false;
|
||||
if (text != "")
|
||||
elem SetText(text);
|
||||
state.elems[state.elems.size] = elem;
|
||||
return elem;
|
||||
}
|
||||
|
||||
service_render(state)
|
||||
{
|
||||
if (state.closed)
|
||||
return;
|
||||
request = service_current(state);
|
||||
page = -1;
|
||||
generation = 0;
|
||||
if (isDefined(request))
|
||||
{
|
||||
page = request.page;
|
||||
generation = request.generation;
|
||||
}
|
||||
if (state.rendered && state.last_generation == generation && state.last_page == page)
|
||||
return;
|
||||
state.rendered = true;
|
||||
state.last_generation = generation;
|
||||
state.last_page = page;
|
||||
if (!isDefined(request))
|
||||
{
|
||||
service_view_destroy(state);
|
||||
return;
|
||||
}
|
||||
paged = request.pages > 1;
|
||||
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
|
||||
service_view_create(state, request);
|
||||
for (i = 0; i < state.elems.size; i++)
|
||||
state.elems[i].alpha = 0;
|
||||
|
||||
if (paged)
|
||||
{
|
||||
page_text = "" + (page + 1) + " / " + request.pages;
|
||||
if (!isDefined(state.page_text) || state.page_text != page_text)
|
||||
{
|
||||
state.page_counter SetText(page_text);
|
||||
state.page_text = page_text;
|
||||
}
|
||||
state.page_counter.alpha = 1;
|
||||
}
|
||||
if (request.kind == "help")
|
||||
{
|
||||
state.help_title.alpha = 1;
|
||||
for (i = 0; i < state.command_rows.size; i++)
|
||||
{
|
||||
index = page * level.hud_service_help_rows + i;
|
||||
if (index >= state.commands.size)
|
||||
continue;
|
||||
if (state.help_page != page)
|
||||
{
|
||||
entry = state.commands[index];
|
||||
state.command_rows[i] SetText(help_command_label(entry.command));
|
||||
state.description_rows[i] SetText(help_description(entry));
|
||||
}
|
||||
state.command_rows[i].alpha = 1;
|
||||
state.description_rows[i].alpha = 1;
|
||||
}
|
||||
state.help_page = page;
|
||||
return;
|
||||
}
|
||||
|
||||
header = "Map record unavailable";
|
||||
snapshot = request.snapshot;
|
||||
if (isDefined(snapshot))
|
||||
{
|
||||
header = "No recorded high round yet";
|
||||
if (snapshot.round > 0)
|
||||
header = "Map record | Round " + snapshot.round;
|
||||
}
|
||||
if (!isDefined(state.record_header_text) || state.record_header_text != header)
|
||||
{
|
||||
state.record_title SetText(header);
|
||||
state.record_header_text = header;
|
||||
}
|
||||
state.record_title.alpha = 1;
|
||||
if (!isDefined(snapshot) || snapshot.round == 0)
|
||||
return;
|
||||
if (request.kind == "reminder")
|
||||
{
|
||||
state.rows[0] SetText(request.message);
|
||||
state.rows[0].alpha = 1;
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
index = page * 3 + i;
|
||||
if (index < snapshot.lines.size)
|
||||
{
|
||||
text = snapshot.lines[index];
|
||||
if (!isDefined(state.record_text[i]) || state.record_text[i] != text)
|
||||
{
|
||||
state.rows[i] SetText(text);
|
||||
state.record_text[i] = text;
|
||||
}
|
||||
state.rows[i].alpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service_loop(state)
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_help_refresh");
|
||||
|
||||
wait 12;
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
level endon("tws_service_game_over");
|
||||
for (;;)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 0;
|
||||
service_advance(state, getTime());
|
||||
service_render(state);
|
||||
wait 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// only connect listener in the fleet; a second one crashes the T5 SP handshake
|
||||
service_disconnect(state)
|
||||
{
|
||||
self waittill("disconnect");
|
||||
service_destroy(state);
|
||||
}
|
||||
|
||||
service_end_game(state)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level waittill("tws_service_game_over");
|
||||
service_destroy(state);
|
||||
}
|
||||
|
||||
service_destroy(state)
|
||||
{
|
||||
if (state.closed)
|
||||
return;
|
||||
state.closed = true;
|
||||
state.help = undefined;
|
||||
state.record = undefined;
|
||||
service_view_destroy(state);
|
||||
}
|
||||
|
||||
service_game_over_via(event)
|
||||
{
|
||||
level endon("tws_service_game_over");
|
||||
level waittill(event);
|
||||
level.tws_service_ended = true;
|
||||
level notify("tws_service_game_over");
|
||||
}
|
||||
|
||||
// The only connected listener; modules use the shared registries.
|
||||
on_player_connect_dispatch()
|
||||
{
|
||||
for (;;)
|
||||
@@ -211,7 +612,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
|
||||
elem.x = x;
|
||||
elem.y = y;
|
||||
elem.foreground = 1;
|
||||
elem.fontScale = scale;
|
||||
// Sub-1.0 text can render oversized on clients; keep a safe minimum.
|
||||
elem.fontScale = max(1.0, scale);
|
||||
elem.alpha = 1;
|
||||
elem.color = colour;
|
||||
|
||||
@@ -238,6 +640,147 @@ make_shader_elem(player, x, y)
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
|
||||
make_overlay_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_text_elem(player, x, y, align_x, scale, colour);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.sort = 20;
|
||||
elem.archived = true;
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_shader_elem(player, x, y);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.color = colour;
|
||||
elem.alpha = alpha;
|
||||
elem.sort = sort;
|
||||
elem.archived = true;
|
||||
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_overlay_width(width)
|
||||
{
|
||||
// Native max() returns a float; shader dimensions must be integers.
|
||||
return int(max(1, width));
|
||||
}
|
||||
|
||||
make_core_overlay_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_overlay_text(player, x, y, align_x, scale, colour);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
|
||||
// engine safe area. Horizontal conversion preserves the original size at 16:9;
|
||||
// other aspects retain the right-edge anchor with proportional horizontal size.
|
||||
make_panel_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
|
||||
level.hud_panel_top + y, align_x, scale, colour);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.sort = 20;
|
||||
elem.archived = true;
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_panel_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
|
||||
level.hud_panel_top + y);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.color = colour;
|
||||
elem.alpha = alpha;
|
||||
elem.sort = sort;
|
||||
elem.archived = true;
|
||||
elem SetShader("white", make_panel_width(width), height);
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Native shader widths are integers; dynamic fills use the same conversion.
|
||||
make_panel_width(width)
|
||||
{
|
||||
// max() returns a float even for integer inputs; cast its final result.
|
||||
return int(max(1, width * level.hud_panel_x_scale));
|
||||
}
|
||||
|
||||
// Core elements share a visibility mask; services keep their independent alpha.
|
||||
make_core_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_panel_text(player, x, y, align_x, scale, colour);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_core_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
register_core_elem(player, elem)
|
||||
{
|
||||
live = [];
|
||||
if (isDefined(player.tws_core_hud_elems))
|
||||
{
|
||||
for (i = 0; i < player.tws_core_hud_elems.size; i++)
|
||||
{
|
||||
old = player.tws_core_hud_elems[i];
|
||||
if (isDefined(old))
|
||||
live[live.size] = old;
|
||||
}
|
||||
}
|
||||
live[live.size] = elem;
|
||||
player.tws_core_hud_elems = live;
|
||||
set_core_alpha(player, elem, elem.alpha);
|
||||
}
|
||||
|
||||
set_core_alpha(player, elem, alpha)
|
||||
{
|
||||
if (!isDefined(elem))
|
||||
return;
|
||||
elem.tws_core_alpha = alpha;
|
||||
elem.alpha = alpha;
|
||||
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
|
||||
elem.alpha = 0;
|
||||
}
|
||||
|
||||
toggle_core_hud()
|
||||
{
|
||||
hidden = false;
|
||||
if (isDefined(self.tws_hud_hidden))
|
||||
hidden = self.tws_hud_hidden;
|
||||
self.tws_hud_hidden = !hidden;
|
||||
if (!isDefined(self.tws_core_hud_elems))
|
||||
return;
|
||||
live = [];
|
||||
for (i = 0; i < self.tws_core_hud_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_core_hud_elems[i];
|
||||
if (!isDefined(elem))
|
||||
continue;
|
||||
set_core_alpha(self, elem, elem.tws_core_alpha);
|
||||
live[live.size] = elem;
|
||||
}
|
||||
self.tws_core_hud_elems = live;
|
||||
}
|
||||
|
||||
destroy_on_disconnect(elems)
|
||||
{
|
||||
self waittill("disconnect");
|
||||
|
||||
+147
-165
@@ -1,4 +1,4 @@
|
||||
/* tws_t5_hud.gsc — bottom-left HUD stack. Timer / zombie count / health bar. */
|
||||
/* tws_t5_hud.gsc - minimal edge HUD, toggleable with .hud. */
|
||||
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
@@ -9,23 +9,108 @@ init()
|
||||
return;
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
scripts\sp\tws_t5_common::register_help_command(".hud", "Toggle core HUD");
|
||||
|
||||
level thread round_watcher();
|
||||
level thread game_over_watcher();
|
||||
level thread hud_chat_listener();
|
||||
|
||||
scripts\sp\tws_t5_common::register_connect_handler(::hud_connect);
|
||||
scripts\sp\tws_t5_common::register_spawn_handler(::hud_start);
|
||||
}
|
||||
|
||||
hud_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
if (toLower(message) == ".hud")
|
||||
player thread hud_toggle();
|
||||
}
|
||||
}
|
||||
|
||||
hud_toggle()
|
||||
{
|
||||
self hud_connect();
|
||||
self scripts\sp\tws_t5_common::toggle_core_hud();
|
||||
}
|
||||
|
||||
// Connection construction never seeds clocks or reads player gameplay state.
|
||||
// The first-spawn fallback also handles a spawn before the connect callback.
|
||||
hud_connect()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
if (isDefined(self.tws_hud_elems))
|
||||
return;
|
||||
|
||||
self.tws_hud_elems = [];
|
||||
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
|
||||
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
|
||||
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
|
||||
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
|
||||
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
|
||||
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
|
||||
|
||||
// Center the entire readout on the track, regardless of the digit count.
|
||||
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
|
||||
self.tws_hud_count = self hud_text(count_x,
|
||||
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
|
||||
|
||||
// Independent numeric elements avoid a new config string on every tick.
|
||||
// The readout appears only after a valid damaged-health sample.
|
||||
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
|
||||
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
|
||||
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
|
||||
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
|
||||
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
|
||||
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
|
||||
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
|
||||
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_health, 0);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_slash, 0);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_hp, 0);
|
||||
|
||||
track = scripts\sp\tws_t5_common::make_core_overlay_shader(
|
||||
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
|
||||
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
|
||||
(0.18, 0.22, 0.19), 0.8, 10);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = track;
|
||||
self.tws_hud_bar = scripts\sp\tws_t5_common::make_core_overlay_shader(
|
||||
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
|
||||
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
|
||||
level.hud_c_high, 0, 11);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar;
|
||||
|
||||
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(self.tws_hud_elems);
|
||||
}
|
||||
|
||||
hud_text(x, y, align, scale, colour, text)
|
||||
{
|
||||
elem = scripts\sp\tws_t5_common::make_core_overlay_text(self, x, y, align, scale, colour);
|
||||
elem SetText(text);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
|
||||
return elem;
|
||||
}
|
||||
|
||||
hud_start()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
self hud_connect();
|
||||
if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended))
|
||||
return;
|
||||
|
||||
self.tws_hud_bound = true;
|
||||
self thread hud_timer_row();
|
||||
self thread hud_zombie_counter();
|
||||
self thread hud_health_display();
|
||||
}
|
||||
|
||||
// BO1 maps use different end-of-match notify names; race all three
|
||||
// Race the map-dependent game-over notifications once at level scope.
|
||||
game_over_watcher()
|
||||
{
|
||||
level thread game_over_via("end_game");
|
||||
@@ -37,17 +122,14 @@ game_over_via(notify_name)
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
level waittill(notify_name);
|
||||
level.tws_ended = true;
|
||||
level notify("hud_game_over");
|
||||
}
|
||||
|
||||
/*
|
||||
Watch level.round_number rather than a notify — the notify names vary
|
||||
between BO1 map scripts, the variable doesn't. Seed on OBSERVED
|
||||
transitions only: many maps pre-set round_number = 1 at level init
|
||||
and we don't want getTime() captured there (see NOTES.md).
|
||||
*/
|
||||
// Pre-set round 1 is seeded on first spawn; only observed transitions seed here.
|
||||
round_watcher()
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
last = 0;
|
||||
|
||||
if (isDefined(level.round_number))
|
||||
@@ -77,7 +159,7 @@ hud_timer_row()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
// fallback seed for the round-1-already-active case (round_watcher misses it)
|
||||
// A visible HUD for a waiting newcomer must not start a pre-set round 1.
|
||||
if (!isDefined(level.match_start_time) &&
|
||||
isDefined(level.round_number) &&
|
||||
level.round_number >= 1)
|
||||
@@ -87,59 +169,17 @@ hud_timer_row()
|
||||
level notify("hud_match_started");
|
||||
}
|
||||
|
||||
match = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_match,
|
||||
"left", level.hud_font_small, level.hud_c_dim);
|
||||
match.alpha = 0; // revealed by match_timer_bind when round 1 opens
|
||||
|
||||
round = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_round,
|
||||
"left", level.hud_font_small, level.hud_c_dim);
|
||||
round = self.tws_hud_round;
|
||||
round SetTimerUp(0);
|
||||
|
||||
// name only shows in co-op — your own name is noise in solo
|
||||
name = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_name,
|
||||
"left", level.hud_font_small, level.hud_c_name);
|
||||
|
||||
if (isDefined(self.name))
|
||||
name SetText(self.name);
|
||||
else
|
||||
name SetText(self.playername);
|
||||
|
||||
name.alpha = 0;
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = match;
|
||||
elems[elems.size] = round;
|
||||
elems[elems.size] = name;
|
||||
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
|
||||
|
||||
self thread reset_timer_each_round(round);
|
||||
self thread match_timer_bind(match);
|
||||
self thread freeze_timers_on_game_over(match, round);
|
||||
|
||||
coop = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
is_coop = 0;
|
||||
|
||||
if (isDefined(level.players) && level.players.size > 1)
|
||||
is_coop = 1;
|
||||
|
||||
if (is_coop != coop)
|
||||
{
|
||||
name.alpha = is_coop;
|
||||
coop = is_coop;
|
||||
}
|
||||
|
||||
wait (1);
|
||||
}
|
||||
self thread match_timer_bind(self.tws_hud_match);
|
||||
self thread freeze_timers_on_game_over(self.tws_hud_match, round);
|
||||
}
|
||||
|
||||
reset_timer_each_round(timer)
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
self endon("disconnect");
|
||||
|
||||
for (;;)
|
||||
@@ -151,26 +191,20 @@ reset_timer_each_round(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimerUp only ticks with arg 0 on T5 SP; late joiners start at 0
|
||||
// T5 SP only ticks with a zero seed; late joiners retain that engine limitation.
|
||||
match_timer_bind(elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
if (!isDefined(level.match_start_time))
|
||||
level waittill("hud_match_started");
|
||||
|
||||
if (isDefined(elem))
|
||||
{
|
||||
elem SetTimerUp(0);
|
||||
elem.alpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Level-scope trigger only — per-player death/health polling is intercepted
|
||||
by the zombiemode scripts. Freeze value math relies on the timers being
|
||||
anchored at first spawn / first round transition, not level init.
|
||||
*/
|
||||
// Preserve authoritative frozen clocks when the core is shown after game over.
|
||||
freeze_timers_on_game_over(match_elem, round_elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
@@ -186,31 +220,21 @@ freeze_timers_on_game_over(match_elem, round_elem)
|
||||
hud_zombie_counter()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
label = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_count,
|
||||
"left", level.hud_font_large, level.hud_c_label);
|
||||
label SetText("Zombies");
|
||||
|
||||
count = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_col_count, level.hud_row_count,
|
||||
"left", level.hud_font_large, level.hud_c_count);
|
||||
count SetValue(0);
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = label;
|
||||
elems[elems.size] = count;
|
||||
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
|
||||
|
||||
count = self.tws_hud_count;
|
||||
last = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
remaining = get_enemy_count() + level.zombie_total;
|
||||
remaining = get_enemy_count();
|
||||
if (isDefined(level.zombie_total))
|
||||
remaining += max(0, level.zombie_total);
|
||||
|
||||
if (remaining != last)
|
||||
{
|
||||
count SetValue(remaining);
|
||||
// Plutonium r5334+: reuse text storage as the count changes.
|
||||
count SetTextUnlimited(int(remaining) + " ZOMBIES");
|
||||
last = remaining;
|
||||
}
|
||||
|
||||
@@ -238,117 +262,75 @@ get_enemy_count()
|
||||
hud_health_display()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
if (!isDefined(self.maxhealth) || self.maxhealth <= 0)
|
||||
self.maxhealth = 100;
|
||||
|
||||
bar_width = level.hud_bar_width;
|
||||
|
||||
// -2 on both axes so the back frames the bar with 2px padding
|
||||
back = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2);
|
||||
back.alpha = 0.5;
|
||||
back.color = (0, 0, 0);
|
||||
back SetShader("black", bar_width + 4, level.hud_bar_height + 4);
|
||||
|
||||
bar = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x, level.hud_row_bar);
|
||||
bar.color = level.hud_c_high;
|
||||
bar SetShader("white", bar_width, level.hud_bar_height);
|
||||
|
||||
/*
|
||||
"87/160" is three elements, not a rebuilt string. SetText allocates
|
||||
a config string per call; per-tick rebuilds crash the server.
|
||||
cur is right-aligned so digits grow leftward and the slash + max
|
||||
never shift.
|
||||
*/
|
||||
cur = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_col_health + 26, level.hud_row_bar + 3,
|
||||
"right", level.hud_font_mid, level.hud_c_high);
|
||||
cur SetValue(self.health);
|
||||
|
||||
slash = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_col_health + 30, level.hud_row_bar + 3,
|
||||
"center", level.hud_font_mid, level.hud_c_high);
|
||||
slash SetText("/");
|
||||
|
||||
max_hp = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_col_health + 34, level.hud_row_bar + 3,
|
||||
"left", level.hud_font_mid, level.hud_c_high);
|
||||
max_hp SetValue(self.maxhealth);
|
||||
|
||||
cur.alpha = 0;
|
||||
slash.alpha = 0;
|
||||
max_hp.alpha = 0;
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = back;
|
||||
elems[elems.size] = bar;
|
||||
elems[elems.size] = cur;
|
||||
elems[elems.size] = slash;
|
||||
elems[elems.size] = max_hp;
|
||||
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
|
||||
|
||||
last_width = bar_width;
|
||||
bar_width = level.hud_overlay_bar_width;
|
||||
bar = self.tws_hud_bar;
|
||||
cur = self.tws_hud_health;
|
||||
slash = self.tws_hud_slash;
|
||||
max_hp = self.tws_hud_maxhealth;
|
||||
hp = self.tws_hud_hp;
|
||||
last_width = -1;
|
||||
last_health = -1;
|
||||
last_max = -1;
|
||||
last_shown = -1;
|
||||
last_max = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (!isDefined(self.maxhealth) || self.maxhealth <= 0)
|
||||
self.maxhealth = 100;
|
||||
|
||||
frac = self.health / self.maxhealth;
|
||||
// Display fallbacks must not alter gameplay health values.
|
||||
maximum = 100;
|
||||
if (isDefined(self.maxhealth) && self.maxhealth > 0)
|
||||
maximum = self.maxhealth;
|
||||
current = 0;
|
||||
if (isDefined(self.health))
|
||||
current = max(0, self.health);
|
||||
frac = current / (maximum * 1.0);
|
||||
|
||||
if (frac > 1)
|
||||
frac = 1;
|
||||
|
||||
if (frac < 0)
|
||||
frac = 0;
|
||||
|
||||
width = int(max(frac * bar_width, 1));
|
||||
|
||||
width = scripts\sp\tws_t5_common::make_overlay_width(frac * bar_width);
|
||||
if (width != last_width)
|
||||
{
|
||||
bar SetShader("white", width, level.hud_bar_height);
|
||||
bar SetShader("white", width, level.hud_overlay_bar_height);
|
||||
last_width = width;
|
||||
}
|
||||
|
||||
colour = level.hud_c_high;
|
||||
bar_alpha = 1;
|
||||
if (frac == 0)
|
||||
bar_alpha = 0;
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, bar, bar_alpha);
|
||||
|
||||
colour = level.hud_c_high;
|
||||
if (frac <= 0.25)
|
||||
colour = level.hud_c_low;
|
||||
else if (frac <= 0.5)
|
||||
colour = level.hud_c_mid;
|
||||
|
||||
bar.color = colour;
|
||||
cur.color = colour;
|
||||
slash.color = colour;
|
||||
bar.color = colour;
|
||||
cur.color = colour;
|
||||
slash.color = colour;
|
||||
max_hp.color = colour;
|
||||
hp.color = colour;
|
||||
|
||||
// numbers only show once you're hurt
|
||||
show = 0;
|
||||
readout_alpha = 0;
|
||||
if (isDefined(self.health) && current < maximum)
|
||||
readout_alpha = 1;
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, cur, readout_alpha);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, slash, readout_alpha);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, max_hp, readout_alpha);
|
||||
scripts\sp\tws_t5_common::set_core_alpha(self, hp, readout_alpha);
|
||||
|
||||
if (frac < level.hud_health_threshold)
|
||||
show = 1;
|
||||
|
||||
if (show != last_shown)
|
||||
// Keep health numbers current even at full health or while hidden.
|
||||
if (current != last_health)
|
||||
{
|
||||
cur.alpha = show;
|
||||
slash.alpha = show;
|
||||
max_hp.alpha = show;
|
||||
last_shown = show;
|
||||
cur SetValue(current);
|
||||
last_health = current;
|
||||
}
|
||||
|
||||
if (self.health != last_health)
|
||||
if (maximum != last_max)
|
||||
{
|
||||
cur SetValue(self.health);
|
||||
last_health = self.health;
|
||||
}
|
||||
|
||||
if (self.maxhealth != last_max)
|
||||
{
|
||||
max_hp SetValue(self.maxhealth);
|
||||
last_max = self.maxhealth;
|
||||
max_hp SetValue(maximum);
|
||||
last_max = maximum;
|
||||
}
|
||||
|
||||
wait (level.hud_rate);
|
||||
|
||||
+158
-113
@@ -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 common_scripts\utility;
|
||||
@@ -24,10 +24,12 @@ init()
|
||||
level.tws_lb_pending = undefined;
|
||||
level.tws_lb_cache = undefined;
|
||||
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_connect_handler(::lb_welcome);
|
||||
level thread lb_chat_listener();
|
||||
level thread lb_worker();
|
||||
level thread lb_round_watcher();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
self endon("disconnect");
|
||||
@@ -124,120 +208,18 @@ lb_request(manual)
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
snapshot = lb_cached_record();
|
||||
if (!isDefined(snapshot))
|
||||
{
|
||||
if (manual)
|
||||
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
|
||||
return;
|
||||
}
|
||||
// A manual replay replaces any welcome announcement still waiting to run.
|
||||
// This also applies when the cache is unavailable.
|
||||
if (manual)
|
||||
self notify("tws_lb_requested");
|
||||
self lb_show_hud(snapshot);
|
||||
}
|
||||
self.tws_lb_last_request = getTime();
|
||||
snapshot = lb_cached_record();
|
||||
if (!isDefined(snapshot) && !manual)
|
||||
return;
|
||||
|
||||
lb_show_hud(snapshot)
|
||||
{
|
||||
// Stop the previous display and its cleanup threads before replacing it.
|
||||
self notify("tws_lb_hud_done");
|
||||
if (isDefined(self.tws_lb_elems))
|
||||
lb_hud_destroy(self.tws_lb_elems);
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
// Match .help's position, typography and row spacing below the health bar.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
elems = [];
|
||||
title = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
|
||||
title.alpha = 0;
|
||||
if (snapshot.round == 0)
|
||||
title SetText("No recorded high round yet");
|
||||
else
|
||||
title SetText("Record: Round " + snapshot.round);
|
||||
elems[elems.size] = title;
|
||||
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = scripts\sp\tws_t5_common::make_text_elem(
|
||||
self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
row.alpha = 0;
|
||||
elems[elems.size] = row;
|
||||
}
|
||||
|
||||
// Cleanup runs independently of the display and ends when it is replaced.
|
||||
self.tws_lb_elems = elems;
|
||||
self thread lb_hud_disconnect(elems);
|
||||
self thread lb_hud_end_game(elems, "end_game");
|
||||
self thread lb_hud_end_game(elems, "game_ended");
|
||||
self thread lb_hud_end_game(elems, "intermission");
|
||||
|
||||
offset = 0;
|
||||
for (;;)
|
||||
{
|
||||
// Text changes only once per page, never during the visibility loop.
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = elems[i + 1];
|
||||
if (offset + i < snapshot.lines.size)
|
||||
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
|
||||
else
|
||||
row SetText("");
|
||||
}
|
||||
|
||||
remaining = 12;
|
||||
while (remaining > 0)
|
||||
{
|
||||
visible = !self lb_hud_help_visible();
|
||||
for (i = 0; i < elems.size; i++)
|
||||
elems[i].alpha = visible;
|
||||
wait 0.1;
|
||||
// Pause the page while .help occupies the same area.
|
||||
if (visible)
|
||||
remaining -= 0.1;
|
||||
}
|
||||
offset += 3;
|
||||
if (offset >= snapshot.lines.size)
|
||||
break;
|
||||
}
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_help_visible()
|
||||
{
|
||||
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
|
||||
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
|
||||
}
|
||||
|
||||
lb_hud_disconnect(elems)
|
||||
{
|
||||
self endon("tws_lb_hud_done");
|
||||
self waittill("disconnect");
|
||||
lb_hud_destroy(elems);
|
||||
}
|
||||
|
||||
lb_hud_end_game(elems, event)
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
level waittill(event);
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_destroy(elems)
|
||||
{
|
||||
for (i = 0; i < elems.size; i++)
|
||||
{
|
||||
if (isDefined(elems[i]))
|
||||
elems[i] Destroy();
|
||||
}
|
||||
// Common owns pagination, help priority and the single service window.
|
||||
// Undefined snapshots produce a private unavailable page for manual replay.
|
||||
self scripts\sp\tws_t5_common::show_record(snapshot);
|
||||
}
|
||||
|
||||
lb_refresh()
|
||||
@@ -480,22 +462,85 @@ lb_holder_lines(names)
|
||||
{
|
||||
lines = [];
|
||||
line = "";
|
||||
columns = 0;
|
||||
for (i = 0; i < names.size; i++)
|
||||
{
|
||||
if (line != "" && line.size + names[i].size + 2 > 100)
|
||||
name = lb_holder_name(names[i]);
|
||||
width = lb_holder_width(name);
|
||||
if (line != "" && columns + width + 2 > 16)
|
||||
{
|
||||
lines[lines.size] = line;
|
||||
line = "";
|
||||
columns = 0;
|
||||
}
|
||||
if (line != "")
|
||||
{
|
||||
line += ", ";
|
||||
line += names[i];
|
||||
columns += 2;
|
||||
}
|
||||
line += name;
|
||||
columns += width;
|
||||
}
|
||||
if (line != "")
|
||||
lines[lines.size] = line;
|
||||
return lines;
|
||||
}
|
||||
|
||||
lb_holder_name(value)
|
||||
{
|
||||
// The service has 200 virtual units at font scale 1.1. Use a conservative
|
||||
// 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics.
|
||||
// A long name gets one bounded entry; no tied holder is dropped.
|
||||
value = lb_display_text(value, 64);
|
||||
if (value == "")
|
||||
value = "Unknown player";
|
||||
if (lb_holder_width(value) <= 16)
|
||||
return value;
|
||||
|
||||
output = "";
|
||||
columns = 0;
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
|
||||
width = 1;
|
||||
if (code >= 128)
|
||||
width = 2;
|
||||
if (columns + width > 13)
|
||||
break;
|
||||
count = lb_glyph_bytes(code);
|
||||
output += getSubStr(value, i, i + count);
|
||||
columns += width;
|
||||
i += count - 1;
|
||||
}
|
||||
return output + "...";
|
||||
}
|
||||
|
||||
lb_holder_width(value)
|
||||
{
|
||||
columns = 0;
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
|
||||
if (code >= 128)
|
||||
columns += 2;
|
||||
else
|
||||
columns++;
|
||||
i += lb_glyph_bytes(code) - 1;
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
lb_glyph_bytes(code)
|
||||
{
|
||||
if (code >= 240)
|
||||
return 4;
|
||||
if (code >= 224)
|
||||
return 3;
|
||||
if (code >= 192)
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
lb_display_text(value, limit)
|
||||
{
|
||||
output = "";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
|
||||
#include maps\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar(#"zombiemode") != "1")
|
||||
return;
|
||||
scripts\sp\tws_t5_common::ensure_config();
|
||||
level.moderation_ready = false;
|
||||
scripts\sp\tws_t5_common::register_connect_handler(::moderation_connected);
|
||||
level thread moderation_loop();
|
||||
}
|
||||
|
||||
moderation_connected()
|
||||
{
|
||||
self endon("disconnect");
|
||||
if (!level.moderation_ready)
|
||||
return;
|
||||
moderation_observe(self);
|
||||
moderationPump();
|
||||
}
|
||||
|
||||
moderation_observe(player)
|
||||
{
|
||||
if (!isDefined(player))
|
||||
return;
|
||||
guid = player getGuid();
|
||||
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
||||
return;
|
||||
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
|
||||
guid = "" + guid;
|
||||
name = player.name;
|
||||
if (!isDefined(name))
|
||||
name = "Unknown player";
|
||||
player moderationObserve(guid, name);
|
||||
}
|
||||
|
||||
moderation_loop()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
if (GetDvar("tws_moderation_enabled") == "0")
|
||||
{
|
||||
moderationStop();
|
||||
level.moderation_ready = false;
|
||||
wait 3;
|
||||
continue;
|
||||
}
|
||||
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
|
||||
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
|
||||
if (!level.moderation_ready)
|
||||
{
|
||||
wait 3;
|
||||
continue;
|
||||
}
|
||||
players = getPlayers();
|
||||
for (i = 0; i < players.size; i++)
|
||||
moderation_observe(players[i]);
|
||||
moderationPump();
|
||||
wait 0.5;
|
||||
}
|
||||
}
|
||||
@@ -101,3 +101,39 @@ acknowledgment tests (requires the pinned JSON headers). Deploy Electric Cherry
|
||||
The localhost tests compile the actual HTTP utility against the host libcurl.
|
||||
They do not validate Windows linking, its Schannel backend or game VM notification
|
||||
handling. Those checks require the Windows build and a running Plutonium server.
|
||||
|
||||
## Bank support (TWS 1.1.0)
|
||||
|
||||
`bankLoad`, `bankStore`, `bankRemove` and `bankResponse` provide durable transfer
|
||||
decisions and strict response validation for the new bank script. Build and
|
||||
install the DLL, then restart the full process. No upstream pins or external
|
||||
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py`.
|
||||
|
||||
## Bank diagnostics (TWS 1.2.0)
|
||||
|
||||
`bankVersion()` returns the loaded DLL's TWS component version, independently of
|
||||
upstream pins and the native loader's unchanged `plugin_version()` value.
|
||||
`bankResponse()` now also returns `reason`: `ok` on success, otherwise one of
|
||||
`empty_body`, `body_limit`, `invalid_json`, `schema`, `field_limit`, `state`,
|
||||
`amount_range`, `ok_state`, `missing_id`, or `zero_transfer`. Invalid responses
|
||||
clear all parsed fields. Existing acknowledgment acceptance rules are unchanged.
|
||||
The returned `code` on valid responses remains API data; bank scripts allowlist
|
||||
it before logging. Callers must never log arbitrary response fields.
|
||||
|
||||
Native journal logs name load/write/remove, limit identical failures to one
|
||||
per 30 seconds, and log recovery with suppressed counts. Exception text, journal
|
||||
contents and credentials are excluded. The new scripts require this DLL even
|
||||
when disabled by dvar because `bankVersion()` must link. No new third-party
|
||||
dependencies or upstream pin changes are required. Rebuild/install the DLL and
|
||||
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
|
||||
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
|
||||
storage and native registration adapter with sanitizers and engine shims.
|
||||
|
||||
## AFK journal API (`1.4.0+`)
|
||||
|
||||
The AFK component exports API `1.0.0` for immutable requests, reservations,
|
||||
monotonic countdowns, durable stopped-session decisions and crash recovery.
|
||||
See [AFK reserves](../../docs/afk.md). It shares the existing instance bank
|
||||
credential, uses a separate `tws-afk` journal, and never stores credentials in
|
||||
that journal. Installing this native change requires a full process restart.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/afk.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::afk::journal spool(const std::string& server, const std::string& endpoint)
|
||||
{ return {"tws-afk/t6",server,endpoint}; }
|
||||
// Fixed vocabulary only: no identities, tokens, paths or HTTP bodies.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status { bool failed = false; std::chrono::steady_clock::time_point logged{}; unsigned suppressed = 0; };
|
||||
static std::array<status,9> states;
|
||||
constexpr const char* names[] = {"load","write","remove","prepare","authorize","begin","checkpoint","stop","recover"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30)) { ++state.suppressed; return; }
|
||||
if (failed || state.failed) printf("[tws_afk] native_storage operation=%s outcome=%s suppressed=%u\n",names[operation],failed ? "storage_failure" : "recovered",state.suppressed);
|
||||
state.failed = failed; state.logged = now; state.suppressed = 0;
|
||||
}
|
||||
template <typename F> bool mutate(size_t operation, F action)
|
||||
{
|
||||
try { action(); storage_result(operation,false); return true; }
|
||||
catch (...) { storage_result(operation,true); return false; }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("afkversion", []() { return std::string("1.0.0"); });
|
||||
gsc::function::add("afkload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server,endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id; entry["action"] = value.action; entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0,false); return entries;
|
||||
}
|
||||
catch (...) { storage_result(0,true); return {}; }
|
||||
});
|
||||
gsc::function::add("afkstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(1,[&]{spool(server,endpoint).store(body);}); });
|
||||
gsc::function::add("afkremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(2,[&]{spool(server,endpoint).erase(id);}); });
|
||||
gsc::function::add("afkprepare", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{ return mutate(3,[&]{spool(server,endpoint).prepare(body);}); });
|
||||
gsc::function::add("afkauthorize", [](const std::string& server, const std::string& endpoint, const std::string& id, int seconds)
|
||||
{ return mutate(4,[&]{spool(server,endpoint).authorize(id,seconds);}); });
|
||||
gsc::function::add("afkbegin", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return mutate(5,[&]{spool(server,endpoint).begin(id);}); });
|
||||
gsc::function::add("afkremaining", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{ return spool(server,endpoint).remaining(id); });
|
||||
gsc::function::add("afkcheckpoint", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { const auto result = spool(server,endpoint).checkpoint(id); storage_result(6,false); return result; }
|
||||
catch (...) { storage_result(6,true); return -1; }
|
||||
});
|
||||
gsc::function::add("afkstop", [](const std::string& server, const std::string& endpoint, const std::string& id, const std::string& reason)
|
||||
{ return mutate(7,[&]{spool(server,endpoint).stop(id,reason);}); });
|
||||
gsc::function::add("afkrecover", [](const std::string& server, const std::string& endpoint, const std::string& current_match)
|
||||
{ return mutate(8,[&]{spool(server,endpoint).recover(current_match);}); });
|
||||
gsc::function::add("afkresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::afk::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid; result["reason"] = ack.reason;
|
||||
result["state"] = ack.state; result["code"] = ack.code; result["request_id"] = ack.id;
|
||||
result["session_id"] = ack.session_id;
|
||||
result["free_seconds"] = ack.free_seconds; result["saved_seconds"] = ack.saved_seconds;
|
||||
result["available_seconds"] = ack.available_seconds; result["reserved_seconds"] = ack.reserved_seconds;
|
||||
result["capacity_seconds"] = ack.capacity_seconds; result["authorized_seconds"] = ack.authorized_seconds;
|
||||
result["bank_balance"] = ack.bank_balance; result["purchase_points"] = ack.purchase_points;
|
||||
result["purchase_seconds"] = ack.purchase_seconds; result["max_purchase_packs"] = ack.max_purchase_packs;
|
||||
result["awarded_seconds"] = ack.awarded_seconds; result["consumed_seconds"] = ack.consumed_seconds;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(afk::component)
|
||||
@@ -0,0 +1,98 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/bank.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
|
||||
namespace bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
utils::bank::outbox spool(const std::string& server, const std::string& endpoint)
|
||||
{
|
||||
return {"tws-bank/t6", server, endpoint};
|
||||
}
|
||||
|
||||
// Only fixed operation names are printed, never exception text or paths
|
||||
// from private configuration. Per-operation state has bounded memory.
|
||||
void storage_result(size_t operation, bool failed)
|
||||
{
|
||||
struct status
|
||||
{
|
||||
bool failed = false;
|
||||
std::chrono::steady_clock::time_point logged{};
|
||||
unsigned suppressed = 0;
|
||||
};
|
||||
static std::array<status, 3> states;
|
||||
constexpr const char* names[] = {"load", "write", "remove"};
|
||||
auto& state = states[operation];
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (failed && state.failed && now - state.logged < std::chrono::seconds(30))
|
||||
{
|
||||
++state.suppressed;
|
||||
return;
|
||||
}
|
||||
if (failed || state.failed)
|
||||
printf("[tws_bank] native_storage operation=%s outcome=%s suppressed=%u\n",
|
||||
names[operation], failed ? "storage_failure" : "recovered", state.suppressed);
|
||||
state.failed = failed;
|
||||
state.logged = now;
|
||||
state.suppressed = 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
|
||||
{
|
||||
gsc::function::add("bankversion", []() { return std::string("1.2.0"); });
|
||||
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
|
||||
{
|
||||
try
|
||||
{
|
||||
scripting::array entries;
|
||||
for (const auto& value : spool(server, endpoint).load())
|
||||
{
|
||||
scripting::array entry;
|
||||
entry["id"] = value.id;
|
||||
entry["decision"] = value.decision;
|
||||
entry["body"] = value.body;
|
||||
entries.emplace_back(entry);
|
||||
}
|
||||
storage_result(0, false);
|
||||
return entries;
|
||||
}
|
||||
catch (...) { storage_result(0, true); return {}; }
|
||||
});
|
||||
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
|
||||
{
|
||||
try { spool(server, endpoint).store(body); storage_result(1, false); return true; }
|
||||
catch (...) { storage_result(1, true); return false; }
|
||||
});
|
||||
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
|
||||
{
|
||||
try { spool(server, endpoint).erase(id); storage_result(2, false); return true; }
|
||||
catch (...) { storage_result(2, true); return false; }
|
||||
});
|
||||
gsc::function::add("bankresponse", [](const std::string& response)
|
||||
{
|
||||
const auto ack = utils::bank::parse_acknowledgement(response);
|
||||
scripting::array result;
|
||||
result["valid"] = ack.valid;
|
||||
result["reason"] = ack.reason;
|
||||
result["state"] = ack.state;
|
||||
result["code"] = ack.code;
|
||||
result["request_id"] = ack.id;
|
||||
result["amount"] = ack.amount;
|
||||
result["balance"] = ack.balance;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
REGISTER_COMPONENT(bank::component)
|
||||
@@ -0,0 +1,161 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "scripting.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "command.hpp"
|
||||
#include "game/game.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/moderation.hpp>
|
||||
#include <utils/http.hpp>
|
||||
#include <utils/cryptography.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
namespace moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using runtime = utils::moderation::runtime;
|
||||
struct transfer
|
||||
{
|
||||
std::string session, response;
|
||||
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
|
||||
std::atomic<bool> done{false};
|
||||
bool success = false;
|
||||
};
|
||||
std::shared_ptr<runtime> current;
|
||||
std::shared_ptr<transfer> pending;
|
||||
std::string endpoint, credential, configured_server;
|
||||
int configured_generation = 0;
|
||||
std::chrono::steady_clock::time_point next_poll{}, next_log{};
|
||||
bool fault = false;
|
||||
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
|
||||
void failure()
|
||||
{
|
||||
if (std::chrono::steady_clock::now() >= next_log)
|
||||
{
|
||||
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
|
||||
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
}
|
||||
}
|
||||
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
|
||||
std::string execute(const std::string& kind, int slot, const std::string& reason)
|
||||
{
|
||||
auto active = current;
|
||||
if (!active) return "failed";
|
||||
// This function is called only by the server-thread GSC pump. No queue/yield
|
||||
// separates runtime target validation from synchronous engine dispatch.
|
||||
if (kind == "announce" || kind == "warn" || kind == "message")
|
||||
{
|
||||
const auto text = std::string("j \"") + reason + "\"";
|
||||
game::SV_GameSendServerCommand(slot, 0, text.c_str());
|
||||
return "confirmed";
|
||||
}
|
||||
if (slot < 0 || slot >= 64) return "failed";
|
||||
if (kind == "mute" || kind == "unmute")
|
||||
{
|
||||
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
|
||||
return "unknown"; // Engine has no mute-state readback in this adapter.
|
||||
}
|
||||
if (kind != "kick") return "failed";
|
||||
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
|
||||
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
|
||||
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
|
||||
}
|
||||
void pump()
|
||||
{
|
||||
if (!current || fault) return;
|
||||
try
|
||||
{
|
||||
if (pending && pending->done.load())
|
||||
{
|
||||
auto completed = pending; pending.reset();
|
||||
if (completed->session == current->session)
|
||||
{
|
||||
if (completed->success)
|
||||
{
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
|
||||
current->accept(completed->response, now(), age);
|
||||
}
|
||||
else failure();
|
||||
}
|
||||
}
|
||||
auto active = current;
|
||||
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
|
||||
{
|
||||
return current == active ? execute(kind, slot, reason) : std::string("failed");
|
||||
});
|
||||
if (current != active) return;
|
||||
if (!pending && std::chrono::steady_clock::now() >= next_poll)
|
||||
{
|
||||
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
|
||||
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
|
||||
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
scheduler::thread_pool.push([work, request, target, token]
|
||||
{
|
||||
try
|
||||
{
|
||||
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
|
||||
limits.max_response_bytes = 1024 * 1024;
|
||||
const auto response = utils::http::get_data(target, request,
|
||||
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
|
||||
if (response && response->code == CURLE_OK && response->status == 200)
|
||||
{ work->response = response->buffer; work->success = true; }
|
||||
}
|
||||
catch (...) { work->success = false; }
|
||||
work->done.store(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (...) { failure(); }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup(plugin::plugin* plugin) override
|
||||
{
|
||||
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
|
||||
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
|
||||
scripting::on_shutdown([]
|
||||
{
|
||||
// In-flight requests own only native data. Results from the old VM are abandoned.
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
|
||||
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
|
||||
{
|
||||
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
|
||||
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
|
||||
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
|
||||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
|
||||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
|
||||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
|
||||
try
|
||||
{
|
||||
auto configured = std::make_shared<runtime>("tws-moderation/t6", server, url, generation,
|
||||
utils::cryptography::sha256::compute(token, true));
|
||||
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
|
||||
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
|
||||
}
|
||||
catch (...) { fault = true; failure(); return false; }
|
||||
});
|
||||
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
|
||||
{
|
||||
if (!current || fault) return false;
|
||||
const auto ref = entity.get_entity_reference();
|
||||
if (ref.classnum != 0 || ref.entnum >= 64) return false;
|
||||
try { current->observe(ref.entnum, guid, name); return true; }
|
||||
catch (...) { failure(); return false; }
|
||||
});
|
||||
gsc::function::add("moderationstop", []
|
||||
{
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationpump", pump);
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(moderation::component)
|
||||
@@ -0,0 +1,381 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "afk.hpp"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000, max_file_size = 16 * 1024 * 1024, max_entries = 4096;
|
||||
constexpr int max_seconds = 604800;
|
||||
struct active_clock { journal::clock::time_point started; int authorized_seconds; };
|
||||
std::map<std::string, active_clock> active;
|
||||
struct stopped_clock { journal::clock::time_point time; std::string reason; };
|
||||
std::map<std::string, stopped_clock> stopped;
|
||||
std::string hash(const std::string& value)
|
||||
{
|
||||
uint64_t result = 14695981039346656037ull;
|
||||
for (const unsigned char byte : value) result = (result ^ byte) * 1099511628211ull;
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0') << std::setw(16) << result;
|
||||
return out.str();
|
||||
}
|
||||
bool identifier(const nlohmann::json& value)
|
||||
{
|
||||
if (!value.is_string()) return false;
|
||||
const auto& text = value.get_ref<const std::string&>();
|
||||
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(),text.end(),[](unsigned char c)
|
||||
{ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || c == '-'; });
|
||||
}
|
||||
bool integer(const nlohmann::json& value, int maximum = 1000000000)
|
||||
{
|
||||
return value.is_number_integer() && value >= 0 && value <= maximum;
|
||||
}
|
||||
std::string session_id(const session& value) { return nlohmann::json::parse(value.body).at("session_id").get<std::string>(); }
|
||||
void append(std::vector<request>& requests, const request& incoming)
|
||||
{
|
||||
for (const auto& value : requests)
|
||||
if (value.id == incoming.id)
|
||||
{
|
||||
if (nlohmann::json::parse(value.body) != nlohmann::json::parse(incoming.body))
|
||||
throw std::runtime_error("AFK immutable request conflict");
|
||||
return;
|
||||
}
|
||||
requests.push_back(incoming);
|
||||
}
|
||||
void erase_session_requests(std::vector<request>& requests, const std::string& id)
|
||||
{
|
||||
requests.erase(std::remove_if(requests.begin(), requests.end(), [&](const request& value)
|
||||
{
|
||||
const auto data = nlohmann::json::parse(value.body);
|
||||
return (value.action == "reserve" || value.action == "start") && data.value("session_id", std::string()) == id;
|
||||
}), requests.end());
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
const auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; };
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement value;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
value.state = data.at("state").get<std::string>();
|
||||
value.code = data.at("code").get<std::string>();
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
if (value.id.size() > 64 || value.code.size() > 64) return invalid("field_limit");
|
||||
const std::set<std::string> states = {"balance","rewarded","purchased","reserved","started","settled","unresolved","corrected","rejected"};
|
||||
if (!states.count(value.state)) return invalid("state");
|
||||
if (data.at("ok").get<bool>() != (value.state != "rejected")) return invalid("ok_state");
|
||||
if (value.state != "balance" && value.id.empty()) return invalid("missing_id");
|
||||
const std::pair<const char*, int acknowledgement::*> fields[] = {
|
||||
{"free_seconds",&acknowledgement::free_seconds},{"saved_seconds",&acknowledgement::saved_seconds},
|
||||
{"available_seconds",&acknowledgement::available_seconds},{"reserved_seconds",&acknowledgement::reserved_seconds},
|
||||
{"capacity_seconds",&acknowledgement::capacity_seconds},{"authorized_seconds",&acknowledgement::authorized_seconds},
|
||||
{"bank_balance",&acknowledgement::bank_balance},{"purchase_points",&acknowledgement::purchase_points},
|
||||
{"purchase_seconds",&acknowledgement::purchase_seconds},{"max_purchase_packs",&acknowledgement::max_purchase_packs}};
|
||||
for (const auto& [name, member] : fields)
|
||||
{
|
||||
if (!integer(data.at(name))) return invalid("amount_range");
|
||||
value.*member = data.at(name).get<int>();
|
||||
}
|
||||
if (value.authorized_seconds > max_seconds) return invalid("amount_range");
|
||||
if (data.contains("awarded_seconds"))
|
||||
{
|
||||
if (!integer(data.at("awarded_seconds"))) return invalid("amount_range");
|
||||
value.awarded_seconds = data.at("awarded_seconds").get<int>();
|
||||
}
|
||||
if (data.contains("session_id") && !data.at("session_id").is_null())
|
||||
{
|
||||
value.session_id = data.at("session_id").get<std::string>();
|
||||
if (value.session_id.size() > 64) return invalid("field_limit");
|
||||
}
|
||||
if (data.contains("consumed_seconds") && !data.at("consumed_seconds").is_null())
|
||||
{
|
||||
if (!integer(data.at("consumed_seconds"), max_seconds)) return invalid("amount_range");
|
||||
value.consumed_seconds = data.at("consumed_seconds").get<int>();
|
||||
}
|
||||
if ((value.state == "reserved" || value.state == "started") &&
|
||||
(value.authorized_seconds == 0 || value.session_id.empty())) return invalid("session");
|
||||
value.valid = true;
|
||||
value.reason = "ok";
|
||||
return value;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
journal::journal(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), scope_(server_ + '\0' + endpoint_)
|
||||
{ file_ = root / (hash(scope_) + ".json"); }
|
||||
std::string journal::key(const std::string& id) const { return file_.string() + '\0' + id; }
|
||||
|
||||
request journal::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid AFK request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_ || !identifier(data.at("request_id")) ||
|
||||
!identifier(data.at("guid")) || !identifier(data.at("match"))) throw std::runtime_error("invalid AFK request scope");
|
||||
request value{data.at("request_id").get<std::string>(), data.at("action").get<std::string>(), body};
|
||||
const std::set<std::string> actions = {"event","purchase","reserve","start","settle"};
|
||||
if (!actions.count(value.action)) throw std::runtime_error("invalid AFK request action");
|
||||
if ((value.action == "reserve" || value.action == "start" || value.action == "settle") && !identifier(data.at("session_id")))
|
||||
throw std::runtime_error("invalid AFK session identity");
|
||||
if (value.action == "reserve" && (!integer(data.at("seconds"), max_seconds) || data.at("seconds") == 0))
|
||||
throw std::runtime_error("invalid AFK reservation size");
|
||||
if (value.action == "event")
|
||||
{
|
||||
if (!integer(data.at("seq"),2147483647) || data.at("seq") == 0 || data.at("activity") != "playing")
|
||||
throw std::runtime_error("invalid AFK activity sequence");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (!integer(data.at(field),2147483647)) throw std::runtime_error("invalid AFK activity counter");
|
||||
if (data.at("headshots") > data.at("kills")) throw std::runtime_error("invalid AFK headshot counter");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
journal::snapshot journal::read() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_)) return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("AFK journal exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error("cannot read AFK journal");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("requests").is_array() || !data.at("sessions").is_array() ||
|
||||
data.at("requests").size() + data.at("sessions").size() > max_entries)
|
||||
throw std::runtime_error("invalid AFK journal; preserve for recovery");
|
||||
snapshot result;
|
||||
std::set<std::string> requests, sessions;
|
||||
for (const auto& body : data.at("requests"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!requests.emplace(value.id).second) throw std::runtime_error("duplicate AFK request");
|
||||
result.requests.push_back(std::move(value));
|
||||
}
|
||||
for (const auto& entry : data.at("sessions"))
|
||||
{
|
||||
session value{entry.at("body").get<std::string>(),entry.at("phase").get<std::string>(),0,0};
|
||||
const auto request = decode(value.body);
|
||||
const auto body = nlohmann::json::parse(value.body);
|
||||
if (request.action != "reserve" || !sessions.emplace(session_id(value)).second ||
|
||||
(value.phase != "prepared" && value.phase != "authorized" && value.phase != "active") ||
|
||||
!integer(entry.at("authorized_seconds"),max_seconds) || !integer(entry.at("consumed_seconds"),max_seconds))
|
||||
throw std::runtime_error("invalid AFK session");
|
||||
value.authorized_seconds = entry.at("authorized_seconds").get<int>();
|
||||
value.consumed_seconds = entry.at("consumed_seconds").get<int>();
|
||||
if (value.consumed_seconds > value.authorized_seconds || value.authorized_seconds > body.at("seconds").get<int>() ||
|
||||
(value.phase == "prepared" && value.authorized_seconds != 0) ||
|
||||
(value.phase != "prepared" && value.authorized_seconds == 0) ||
|
||||
(value.phase != "active" && value.consumed_seconds != 0))
|
||||
throw std::runtime_error("invalid AFK session accounting");
|
||||
result.sessions.push_back(std::move(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void journal::save(const snapshot& value) const
|
||||
{
|
||||
if (value.requests.size() + value.sessions.size() > max_entries) throw std::runtime_error("AFK journal full");
|
||||
nlohmann::json data = {{"version",1},{"server",server_},{"endpoint",endpoint_},
|
||||
{"requests",nlohmann::json::array()},{"sessions",nlohmann::json::array()}};
|
||||
for (const auto& request : value.requests) data["requests"].push_back(request.body);
|
||||
for (const auto& session : value.sessions) data["sessions"].push_back({{"body",session.body},{"phase",session.phase},
|
||||
{"authorized_seconds",session.authorized_seconds},{"consumed_seconds",session.consumed_seconds}});
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size) throw std::runtime_error("AFK journal full");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_; temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(),static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream) throw std::runtime_error("cannot write AFK journal; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail()) throw std::runtime_error("cannot close AFK journal; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(),file_.c_str(),MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace AFK journal; old file retained");
|
||||
#else
|
||||
std::filesystem::rename(temporary,file_);
|
||||
#endif
|
||||
}
|
||||
std::vector<request> journal::load() const { return read().requests; }
|
||||
void journal::store(const std::string& body) const
|
||||
{
|
||||
auto value = read();
|
||||
const auto incoming = decode(body);
|
||||
// Snapshot replacement is atomic with the new snapshot. Cumulative
|
||||
// counters retain every reward during an outage without an unbounded
|
||||
// request per kill. An in-flight older HTTP request remains idempotent;
|
||||
// removing its old ID cannot remove this newer request.
|
||||
if (incoming.action == "event")
|
||||
{
|
||||
const auto next = nlohmann::json::parse(body);
|
||||
for (const auto& entry : value.requests)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) continue;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
if (prior.at("guid") != next.at("guid") || prior.at("match") != next.at("match")) continue;
|
||||
if (next.at("seq") <= prior.at("seq")) throw std::runtime_error("AFK activity sequence regression");
|
||||
for (const auto* field : {"active_seconds","kills","headshots","rounds","revives"})
|
||||
if (next.at(field) < prior.at(field)) throw std::runtime_error("AFK activity counter regression");
|
||||
}
|
||||
// Check reused identities before removing any superseded request.
|
||||
append(value.requests,incoming);
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& entry)
|
||||
{
|
||||
if (entry.action != "event" || entry.id == incoming.id) return false;
|
||||
const auto prior = nlohmann::json::parse(entry.body);
|
||||
return prior.at("guid") == next.at("guid") && prior.at("match") == next.at("match");
|
||||
}),value.requests.end());
|
||||
}
|
||||
else append(value.requests,incoming);
|
||||
save(value);
|
||||
}
|
||||
void journal::erase(const std::string& id) const
|
||||
{
|
||||
auto value = read();
|
||||
value.requests.erase(std::remove_if(value.requests.begin(),value.requests.end(),[&](const request& r){return r.id == id;}),value.requests.end());
|
||||
save(value);
|
||||
}
|
||||
void journal::prepare(const std::string& body) const
|
||||
{
|
||||
const auto request = decode(body);
|
||||
if (request.action != "reserve") throw std::runtime_error("expected AFK reserve");
|
||||
const auto id = nlohmann::json::parse(body).at("session_id").get<std::string>();
|
||||
auto value = read();
|
||||
for (const auto& existing : value.sessions)
|
||||
if (session_id(existing) == id)
|
||||
{
|
||||
if (nlohmann::json::parse(existing.body) != nlohmann::json::parse(body)) throw std::runtime_error("AFK session conflict");
|
||||
return;
|
||||
}
|
||||
append(value.requests,request);
|
||||
value.sessions.push_back({body,"prepared",0,0});
|
||||
save(value);
|
||||
}
|
||||
void journal::authorize(const std::string& id, int seconds) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (seconds <= 0 || seconds > nlohmann::json::parse(session.body).at("seconds").get<int>() ||
|
||||
(session.phase != "prepared" && session.authorized_seconds != seconds)) throw std::runtime_error("AFK authorization conflict");
|
||||
if (session.phase == "prepared") { session.phase = "authorized"; session.authorized_seconds = seconds; save(value); }
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
void journal::begin(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (stopped.count(key(id))) throw std::runtime_error("AFK session already stopped");
|
||||
if (session.phase == "active" && active.count(key(id))) return;
|
||||
if (session.phase != "authorized" || active.count(key(id))) throw std::runtime_error("AFK activation conflict");
|
||||
session.phase = "active";
|
||||
save(value); // Failure prevents protection; never reset an existing clock.
|
||||
// Production clocks start after the durable write; slow storage
|
||||
// must not charge time before the script can apply protection.
|
||||
if (now == clock::time_point{}) now = clock::now();
|
||||
active.emplace(key(id),active_clock{now,session.authorized_seconds});
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
int journal::elapsed(const session& value, clock::time_point now, bool round_up) const
|
||||
{
|
||||
const auto found = active.find(key(session_id(value)));
|
||||
if (found == active.end()) throw std::runtime_error("AFK clock unavailable after restart");
|
||||
const auto seconds = std::chrono::duration<double>(now - found->second.started).count();
|
||||
return std::max(value.consumed_seconds,static_cast<int>(std::min(static_cast<double>(value.authorized_seconds), std::max(0.0,round_up ? std::ceil(seconds) : std::floor(seconds)))));
|
||||
}
|
||||
int journal::remaining(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
const auto found = active.find(key(id));
|
||||
if (found == active.end()) return -1;
|
||||
if (stopped.count(key(id))) return 0;
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - found->second.started).count();
|
||||
const auto authorized_ms = static_cast<int64_t>(found->second.authorized_seconds) * 1000;
|
||||
return static_cast<int>(std::max<int64_t>(0, authorized_ms - std::max<int64_t>(0, elapsed_ms)));
|
||||
}
|
||||
int journal::checkpoint(const std::string& id, clock::time_point now) const
|
||||
{
|
||||
auto value = read();
|
||||
for (auto& session : value.sessions)
|
||||
if (session_id(session) == id)
|
||||
{
|
||||
if (session.phase != "active" || stopped.count(key(id))) throw std::runtime_error("AFK session not active");
|
||||
const auto seconds = elapsed(session,now);
|
||||
if (seconds != session.consumed_seconds) { session.consumed_seconds = seconds; save(value); }
|
||||
return seconds;
|
||||
}
|
||||
throw std::runtime_error("missing AFK session");
|
||||
}
|
||||
request journal::settlement(const session& value, const std::string& reason, int consumed) const
|
||||
{
|
||||
auto data = nlohmann::json::parse(value.body);
|
||||
const auto id = session_id(value);
|
||||
nlohmann::json body = {{"server",server_},{"guid",data.at("guid")},{"match",data.at("match")},
|
||||
{"session_id",id},{"request_id","afk-stop-" + hash(scope_ + '\0' + id)},{"action","settle"},{"end_reason",reason}};
|
||||
if (consumed < 0) { body["consumed_seconds"] = nullptr; body["confirmed_seconds"] = value.consumed_seconds; }
|
||||
else body["consumed_seconds"] = consumed;
|
||||
return decode(body.dump());
|
||||
}
|
||||
void journal::stop(const std::string& id, const std::string& reason, clock::time_point now) const
|
||||
{
|
||||
const std::set<std::string> reasons = {"manual","disconnect","death","match_end","expired","never_started"};
|
||||
if (!reasons.count(reason)) throw std::runtime_error("invalid AFK stop reason");
|
||||
// The caller has already removed protection. Freeze the first stop even
|
||||
// if disk read/write fails, so retries cannot charge time after return.
|
||||
if (active.count(key(id))) stopped.emplace(key(id),stopped_clock{now,reason});
|
||||
auto value = read();
|
||||
for (auto it = value.sessions.begin(); it != value.sessions.end(); ++it)
|
||||
if (session_id(*it) == id)
|
||||
{
|
||||
const auto end = stopped.find(key(id));
|
||||
const auto stop_time = end == stopped.end() ? now : end->second.time;
|
||||
const auto stop_reason = end == stopped.end() ? reason : end->second.reason;
|
||||
const auto consumed = it->phase == "active" ? elapsed(*it,stop_time,true) : 0;
|
||||
if ((stop_reason == "never_started" && it->phase == "active") ||
|
||||
(stop_reason == "expired" && (it->phase != "active" || elapsed(*it,stop_time) != it->authorized_seconds)))
|
||||
throw std::runtime_error("AFK stop reason conflicts with usage");
|
||||
append(value.requests,settlement(*it,it->phase == "active" ? stop_reason : "never_started",consumed));
|
||||
erase_session_requests(value.requests,id);
|
||||
value.sessions.erase(it);
|
||||
save(value);
|
||||
active.erase(key(id));
|
||||
stopped.erase(key(id));
|
||||
return;
|
||||
}
|
||||
// Duplicate stop is safe after a successful durable transition.
|
||||
}
|
||||
void journal::recover(const std::string& current_match) const
|
||||
{
|
||||
if (current_match.empty()) throw std::runtime_error("missing current AFK match");
|
||||
auto value = read();
|
||||
for (const auto& session : value.sessions)
|
||||
{
|
||||
const auto id = session_id(session);
|
||||
if (active.count(key(id)) && nlohmann::json::parse(session.body).at("match") == current_match)
|
||||
throw std::runtime_error("cannot recover live AFK protection");
|
||||
append(value.requests,settlement(session,session.phase == "active" ? "crash" : "never_started",session.phase == "active" ? -1 : 0));
|
||||
erase_session_requests(value.requests,id);
|
||||
}
|
||||
const auto sessions = value.sessions;
|
||||
value.sessions.clear();
|
||||
save(value);
|
||||
for (const auto& session : sessions) { active.erase(key(session_id(session))); stopped.erase(key(session_id(session))); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::afk
|
||||
{
|
||||
struct request { std::string id, action, body; };
|
||||
struct session
|
||||
{
|
||||
std::string body, phase;
|
||||
int authorized_seconds = 0, consumed_seconds = 0;
|
||||
};
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string reason = "schema", state, code, id, session_id;
|
||||
int free_seconds = 0, saved_seconds = 0, available_seconds = 0,
|
||||
reserved_seconds = 0, capacity_seconds = 0, authorized_seconds = 0,
|
||||
bank_balance = 0, purchase_points = 0, purchase_seconds = 0,
|
||||
max_purchase_packs = 0, awarded_seconds = 0, consumed_seconds = -1;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// Single game-thread writer. Active clocks are process-local, so recovery
|
||||
// can never accidentally replay protection after a process restart.
|
||||
class journal
|
||||
{
|
||||
public:
|
||||
using clock = std::chrono::steady_clock;
|
||||
journal(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<request> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
void prepare(const std::string& reserve_body) const;
|
||||
void authorize(const std::string& id, int seconds) const;
|
||||
void begin(const std::string& id, clock::time_point now = clock::time_point{}) const;
|
||||
int checkpoint(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
// Hot path for the damage hook/countdown: no disk I/O or clock reset.
|
||||
// Missing/restarted clocks return -1; stopped/expired clocks return 0.
|
||||
int remaining(const std::string& id, clock::time_point now = clock::now()) const;
|
||||
void stop(const std::string& id, const std::string& reason, clock::time_point now = clock::now()) const;
|
||||
// Call only at map initialization, with its new persistent match ID.
|
||||
// Active current-map sessions are rejected. Unknown crash intervals
|
||||
// remain unresolved at EC, rather than refunding spendable authority.
|
||||
void recover(const std::string& current_match) const;
|
||||
private:
|
||||
struct snapshot { std::vector<request> requests; std::vector<session> sessions; };
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, scope_;
|
||||
request decode(const std::string& body) const;
|
||||
snapshot read() const;
|
||||
void save(const snapshot& value) const;
|
||||
std::string key(const std::string& id) const;
|
||||
int elapsed(const session& value, clock::time_point now, bool round_up = false) const;
|
||||
request settlement(const session& value, const std::string& reason, int consumed) const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "bank.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <set>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_body_size = 0x5000;
|
||||
constexpr size_t max_file_size = 16 * 1024 * 1024;
|
||||
constexpr size_t max_reports = 4096;
|
||||
|
||||
// Stable, compact filename for arbitrary server names and endpoints.
|
||||
// The envelope also checks both strings, so a collision fails closed.
|
||||
std::string scope_name(const std::string& scope)
|
||||
{
|
||||
uint64_t hash = 14695981039346656037ull;
|
||||
for (const unsigned char byte : scope)
|
||||
hash = (hash ^ byte) * 1099511628211ull;
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0') << std::setw(16) << hash;
|
||||
return result.str();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
acknowledgement parse_acknowledgement(const std::string& response)
|
||||
{
|
||||
auto invalid = [](const char* reason)
|
||||
{
|
||||
acknowledgement value;
|
||||
value.reason = reason;
|
||||
return value;
|
||||
};
|
||||
if (response.empty()) return invalid("empty_body");
|
||||
if (response.size() >= max_body_size) return invalid("body_limit");
|
||||
const auto data = nlohmann::json::parse(response, nullptr, false);
|
||||
if (data.is_discarded()) return invalid("invalid_json");
|
||||
if (!data.is_object()) return invalid("schema");
|
||||
try
|
||||
{
|
||||
acknowledgement result;
|
||||
if (!data.at("ok").is_boolean()) return invalid("schema");
|
||||
result.state = data.at("state").get<std::string>();
|
||||
result.code = data.at("code").get<std::string>();
|
||||
result.id = data.at("request_id").get<std::string>();
|
||||
if (result.id.size() > 64 || result.code.size() > 64)
|
||||
return invalid("field_limit");
|
||||
if (result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
|
||||
result.state != "cancelled" && result.state != "rejected") return invalid("state");
|
||||
for (const auto* key : {"amount", "balance"})
|
||||
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000)
|
||||
return invalid("amount_range");
|
||||
result.amount = data.at("amount").get<int>();
|
||||
result.balance = data.at("balance").get<int>();
|
||||
if (data.at("ok").get<bool>() != (result.state != "rejected")) return invalid("ok_state");
|
||||
if (result.state != "balance" && result.id.empty()) return invalid("missing_id");
|
||||
if ((result.state == "prepared" || result.state == "committed") && result.amount == 0)
|
||||
return invalid("zero_transfer");
|
||||
result.valid = true;
|
||||
result.reason = "ok";
|
||||
return result;
|
||||
}
|
||||
catch (...) { return invalid("schema"); }
|
||||
}
|
||||
|
||||
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint))
|
||||
{
|
||||
file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json");
|
||||
}
|
||||
|
||||
report outbox::decode(const std::string& body) const
|
||||
{
|
||||
if (body.empty() || body.size() >= max_body_size)
|
||||
throw std::runtime_error("invalid bank request size");
|
||||
const auto data = nlohmann::json::parse(body);
|
||||
if (!data.is_object() || data.at("server") != server_)
|
||||
throw std::runtime_error("invalid bank scope");
|
||||
report value;
|
||||
value.id = data.at("request_id").get<std::string>();
|
||||
value.decision = data.at("decision").get<std::string>();
|
||||
if (value.id.empty() || value.id.size() > 64 ||
|
||||
(value.decision != "cancel" && value.decision != "commit"))
|
||||
throw std::runtime_error("invalid bank decision");
|
||||
value.body = body;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::vector<report> outbox::load() const
|
||||
{
|
||||
if (!std::filesystem::exists(file_))
|
||||
return {};
|
||||
if (std::filesystem::file_size(file_) > max_file_size)
|
||||
throw std::runtime_error("bank outbox exceeds size limit");
|
||||
std::ifstream stream(file_, std::ios::binary);
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot open bank outbox");
|
||||
const auto data = nlohmann::json::parse(stream);
|
||||
if (stream.bad() || !data.is_object() || data.at("version") != 1 ||
|
||||
data.at("server") != server_ || data.at("endpoint") != endpoint_ ||
|
||||
!data.at("reports").is_array() || data.at("reports").size() > max_reports)
|
||||
throw std::runtime_error("invalid bank outbox; preserve file for recovery");
|
||||
std::vector<report> reports;
|
||||
std::set<std::string> identities;
|
||||
for (const auto& body : data.at("reports"))
|
||||
{
|
||||
auto value = decode(body.get<std::string>());
|
||||
if (!identities.emplace(value.id).second)
|
||||
throw std::runtime_error("duplicate identity in bank outbox");
|
||||
reports.push_back(std::move(value));
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
void outbox::save(const std::vector<report>& reports) const
|
||||
{
|
||||
if (reports.size() > max_reports)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_},
|
||||
{"reports", nlohmann::json::array()}};
|
||||
for (const auto& value : reports)
|
||||
data["reports"].push_back(value.body);
|
||||
const auto encoded = data.dump();
|
||||
if (encoded.size() > max_file_size)
|
||||
throw std::runtime_error("bank outbox is full; delivery blocked");
|
||||
std::filesystem::create_directories(file_.parent_path());
|
||||
auto temporary = file_;
|
||||
temporary += ".tmp";
|
||||
std::ofstream stream(temporary, std::ios::binary | std::ios::trunc);
|
||||
stream.write(encoded.data(), static_cast<std::streamsize>(encoded.size()));
|
||||
stream.flush();
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot write bank outbox; old file retained");
|
||||
stream.close();
|
||||
if (stream.fail())
|
||||
throw std::runtime_error("cannot close bank outbox; old file retained");
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("cannot replace bank outbox; old file retained");
|
||||
#else
|
||||
// Used by the portable tests; the production plugin targets Windows.
|
||||
std::filesystem::rename(temporary, file_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void outbox::store(const std::string& body) const
|
||||
{
|
||||
const auto incoming = decode(body);
|
||||
auto reports = load();
|
||||
for (auto& value : reports)
|
||||
{
|
||||
if (value.id != incoming.id)
|
||||
continue;
|
||||
auto old_data = nlohmann::json::parse(value.body);
|
||||
auto new_data = nlohmann::json::parse(body);
|
||||
old_data.erase("decision");
|
||||
new_data.erase("decision");
|
||||
if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit"))
|
||||
throw std::runtime_error("bank identity/decision conflict");
|
||||
if (value.body != body)
|
||||
{
|
||||
value = incoming;
|
||||
save(reports);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reports.push_back(incoming);
|
||||
save(reports);
|
||||
}
|
||||
|
||||
void outbox::erase(const std::string& id) const
|
||||
{
|
||||
auto reports = load();
|
||||
const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value)
|
||||
{
|
||||
return value.id == id;
|
||||
});
|
||||
if (end == reports.end())
|
||||
return;
|
||||
reports.erase(end, reports.end());
|
||||
save(reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils::bank
|
||||
{
|
||||
struct report { std::string id, decision, body; };
|
||||
struct acknowledgement
|
||||
{
|
||||
bool valid = false;
|
||||
std::string state, code, id;
|
||||
// Fixed diagnostic vocabulary; never includes response text.
|
||||
std::string reason = "schema";
|
||||
int amount = 0, balance = 0;
|
||||
};
|
||||
acknowledgement parse_acknowledgement(const std::string& response);
|
||||
// One game-thread writer; cancel intent is replaced by commit before points move.
|
||||
class outbox
|
||||
{
|
||||
public:
|
||||
outbox(std::filesystem::path root, std::string server, std::string endpoint);
|
||||
std::vector<report> load() const;
|
||||
void store(const std::string& body) const;
|
||||
void erase(const std::string& id) const;
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_;
|
||||
report decode(const std::string& body) const;
|
||||
void save(const std::vector<report>& reports) const;
|
||||
};
|
||||
}
|
||||
@@ -14,13 +14,17 @@ namespace utils::http
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct response_buffer { std::string bytes; size_t limit; };
|
||||
|
||||
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
|
||||
{
|
||||
const auto buffer = static_cast<std::string*>(userp);
|
||||
const auto buffer = static_cast<response_buffer*>(userp);
|
||||
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
|
||||
const auto total_size = size * nmemb;
|
||||
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
|
||||
try
|
||||
{
|
||||
buffer->append(static_cast<char*>(contents), total_size);
|
||||
buffer->bytes.append(static_cast<char*>(contents), total_size);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -68,7 +72,7 @@ namespace utils::http
|
||||
header_list.reset(appended);
|
||||
}
|
||||
|
||||
std::string buffer{};
|
||||
response_buffer buffer{{}, limits.max_response_bytes};
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.data());
|
||||
@@ -106,7 +110,7 @@ namespace utils::http
|
||||
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
|
||||
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
|
||||
if (response.code == CURLE_OK)
|
||||
response.buffer = std::move(buffer);
|
||||
response.buffer = std::move(buffer.bytes);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -25,6 +26,8 @@ namespace utils::http
|
||||
{
|
||||
long connect_timeout_ms = 5000;
|
||||
long timeout_ms = 15000;
|
||||
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
|
||||
size_t max_response_bytes = 0;
|
||||
};
|
||||
|
||||
using headers = std::unordered_map<std::string, std::string>;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "moderation.hpp"
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_file = 16 * 1024 * 1024;
|
||||
constexpr size_t max_response = 1024 * 1024;
|
||||
bool identifier(const std::string& value, size_t limit = 128)
|
||||
{
|
||||
return !value.empty() && value.size() <= limit &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
|
||||
});
|
||||
}
|
||||
std::string bounded_text(const std::string& value, size_t limit)
|
||||
{
|
||||
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
|
||||
// split a codepoint or let one bad name poison the complete JSON roster.
|
||||
std::string result;
|
||||
for (size_t i = 0; i < value.size() && result.size() < limit;)
|
||||
{
|
||||
const auto ch = static_cast<unsigned char>(value[i]);
|
||||
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
|
||||
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
|
||||
bool valid = width && i + width <= value.size();
|
||||
for (size_t j = 1; valid && j < width; ++j)
|
||||
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
|
||||
if (valid && width >= 3)
|
||||
{
|
||||
const auto second = static_cast<unsigned char>(value[i + 1]);
|
||||
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
|
||||
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
|
||||
}
|
||||
if (!valid) { result += '?'; ++i; continue; }
|
||||
if (result.size() + width > limit) break;
|
||||
result.append(value, i, width); i += width;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string player_name(const std::string& value)
|
||||
{
|
||||
auto name = bounded_text(value, 255);
|
||||
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
|
||||
auto visible = name;
|
||||
// Match ECMAScript trim whitespace so the controller cannot reject an
|
||||
// otherwise complete roster because one display name is blank.
|
||||
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
|
||||
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
|
||||
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
|
||||
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
|
||||
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
|
||||
return visible.empty() ? "Unknown player" : name;
|
||||
}
|
||||
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
|
||||
std::string hash_scope(const std::string& text)
|
||||
{
|
||||
unsigned long long hash = 14695981039346656037ull;
|
||||
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
|
||||
std::ostringstream out; out << std::hex << hash; return out.str();
|
||||
}
|
||||
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
|
||||
std::string field(const json& value, const char* key, size_t max, bool empty = false)
|
||||
{
|
||||
require(value.contains(key) && value.at(key).is_string());
|
||||
auto result = value.at(key).get<std::string>();
|
||||
require((empty || !result.empty()) && result.size() <= max);
|
||||
return result;
|
||||
}
|
||||
void reason(const json& value) { field(value, "reason", 500, true); }
|
||||
void sanction(const json& value, long long revision)
|
||||
{
|
||||
require(value.is_object());
|
||||
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
|
||||
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
|
||||
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
void action(const json& value)
|
||||
{
|
||||
require(value.is_object() && identifier(field(value, "id", 128)));
|
||||
auto kind = field(value, "kind", 16);
|
||||
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
|
||||
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
|
||||
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
|
||||
reason(value); timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
}
|
||||
long long timestamp(const std::string& value)
|
||||
{
|
||||
require(value.size() == 20 || value.size() == 24);
|
||||
require(value.back() == 'Z' && value[10] == 'T');
|
||||
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
|
||||
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
|
||||
const auto original = tm;
|
||||
#ifdef _WIN32
|
||||
auto result = _mkgmtime64(&tm);
|
||||
#else
|
||||
auto result = timegm(&tm);
|
||||
#endif
|
||||
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
|
||||
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
|
||||
return result;
|
||||
}
|
||||
std::string command_text(const std::string& value)
|
||||
{
|
||||
// Untrusted text is never a console delimiter or a quoted command escape.
|
||||
std::string result;
|
||||
for (unsigned char ch : bounded_text(value, 240))
|
||||
{
|
||||
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string canonical_guid(const std::string& value)
|
||||
{
|
||||
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
|
||||
{
|
||||
const auto first = value.find_first_not_of('0');
|
||||
return first == std::string::npos ? "0" : value.substr(first);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
std::string new_session()
|
||||
{
|
||||
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
|
||||
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
|
||||
return out.str();
|
||||
}
|
||||
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
|
||||
{
|
||||
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
|
||||
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
|
||||
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
|
||||
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
|
||||
if (std::filesystem::exists(file_))
|
||||
{
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
|
||||
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
|
||||
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
|
||||
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
|
||||
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
|
||||
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
|
||||
{
|
||||
require(identifier(it.key())); action(it.value().at("action"));
|
||||
auto state = field(it.value(), "state", 16);
|
||||
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
|
||||
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
|
||||
}
|
||||
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
|
||||
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
|
||||
saved_ = std::move(value);
|
||||
}
|
||||
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
|
||||
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
|
||||
save(next); // Allocate this boot/map sequence durably before exposing its session.
|
||||
}
|
||||
void runtime::save(const json& next)
|
||||
{
|
||||
const auto bytes = next.dump(); require(bytes.size() < max_file);
|
||||
const auto exists = std::filesystem::exists(file_);
|
||||
require(exists || !allocated_);
|
||||
if (exists)
|
||||
{
|
||||
// A synchronous engine command may replace the map/runtime before the
|
||||
// older call frame returns. It must never overwrite the new sequence.
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
|
||||
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
|
||||
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
|
||||
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
|
||||
}
|
||||
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
|
||||
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
|
||||
require(stream.good()); stream.close(); require(!stream.fail());
|
||||
#ifdef _WIN32
|
||||
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
|
||||
#else
|
||||
std::filesystem::rename(temp, file_);
|
||||
#endif
|
||||
saved_ = next; allocated_ = true;
|
||||
}
|
||||
void runtime::observe(int slot, const std::string& guid, const std::string& name)
|
||||
{
|
||||
require(slot >= 0 && slot < 64 && guid_identifier(guid));
|
||||
auto found = players.find(slot);
|
||||
if (found == players.end() || found->second.guid != canonical_guid(guid))
|
||||
{
|
||||
disconnect(slot);
|
||||
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
|
||||
}
|
||||
else found->second.name = player_name(name);
|
||||
}
|
||||
void runtime::disconnect(int slot)
|
||||
{
|
||||
auto found = players.find(slot);
|
||||
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
|
||||
}
|
||||
json runtime::request()
|
||||
{
|
||||
json roster = json::array(), receipts = json::array(); sent_.clear();
|
||||
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
|
||||
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
|
||||
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
|
||||
{
|
||||
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
|
||||
sent_.push_back(it.key());
|
||||
}
|
||||
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
|
||||
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
|
||||
}
|
||||
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
|
||||
{
|
||||
const auto received = std::chrono::steady_clock::now();
|
||||
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
|
||||
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
|
||||
auto revision = value.at("revision").get<long long>();
|
||||
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
|
||||
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
|
||||
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
|
||||
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
|
||||
std::set<std::string> ids;
|
||||
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
|
||||
ids.clear();
|
||||
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
|
||||
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
|
||||
if (revision == this->revision() && revision != 0)
|
||||
{
|
||||
auto previous = saved_.at("sanctions");
|
||||
for (auto it = previous.begin(); it != previous.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
|
||||
auto incoming = value.at("sanctions");
|
||||
for (auto it = incoming.begin(); it != incoming.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
|
||||
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
|
||||
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
|
||||
require(incoming == previous);
|
||||
}
|
||||
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
|
||||
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
|
||||
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
|
||||
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
|
||||
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
|
||||
save(next); pending_ = value.at("actions");
|
||||
const auto accepted = std::chrono::steady_clock::now();
|
||||
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
|
||||
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
|
||||
std::chrono::milliseconds(transfer_age_ms) + processing;
|
||||
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
|
||||
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
|
||||
clock_anchor_ = accepted;
|
||||
}
|
||||
long long runtime::effective_time(long long local) const
|
||||
{
|
||||
if (!server_anchor_.time_since_epoch().count()) return local;
|
||||
// Never extend deadlines when the host clock lags or jumps backward. The
|
||||
// full request age is conservative because serverTime is generated during it.
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
|
||||
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
|
||||
return std::max(local, static_cast<long long>(estimate));
|
||||
}
|
||||
void runtime::enforce(long long now, const executor& execute)
|
||||
{
|
||||
now = effective_time(now);
|
||||
auto pending = pending_; pending_ = json::array();
|
||||
for (const auto& item : pending)
|
||||
{
|
||||
auto id = item.at("id").get<std::string>();
|
||||
if (saved_["journal"].contains(id))
|
||||
{
|
||||
// A duplicate immutable action is acknowledged, never executed twice.
|
||||
require(saved_["journal"][id].at("action") == item);
|
||||
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
|
||||
}
|
||||
require(saved_["journal"].size() < 10000);
|
||||
auto kind = item.at("kind").get<std::string>(); int slot = -1;
|
||||
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
|
||||
else if (kind != "announce")
|
||||
{
|
||||
for (const auto& [number, target] : players)
|
||||
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
|
||||
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
|
||||
}
|
||||
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
|
||||
if (state == "unknown")
|
||||
{
|
||||
// All target validation and dispatch occur on the calling game thread, without yielding.
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
|
||||
else
|
||||
{
|
||||
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
|
||||
catch (...) { state = "unknown"; }
|
||||
if (state != "confirmed" && state != "failed") state = "unknown";
|
||||
}
|
||||
next = saved_; next["journal"][id]["state"] = state;
|
||||
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
|
||||
}
|
||||
}
|
||||
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
|
||||
const auto roster = players;
|
||||
for (const auto& [slot, target] : roster)
|
||||
{
|
||||
now = effective_time(now);
|
||||
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
|
||||
bool ban = false, mute = false; std::string why;
|
||||
for (const auto& policy : saved_.at("sanctions"))
|
||||
{
|
||||
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
|
||||
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
|
||||
if (policy.at("kind") == "mute") mute = true;
|
||||
}
|
||||
if (ban)
|
||||
{
|
||||
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
|
||||
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
|
||||
continue;
|
||||
}
|
||||
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
|
||||
auto owned = saved_.at("ownedMutes");
|
||||
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
|
||||
if (mute != was_muted || (!mute && managed))
|
||||
{
|
||||
if (mute && !managed)
|
||||
{
|
||||
require(owned.size() < 10000); owned.push_back(target.guid);
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
|
||||
if (!mute && managed)
|
||||
{
|
||||
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <json.hpp>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
using json = nlohmann::json;
|
||||
struct player { int slot; std::string guid, name, connection; };
|
||||
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
|
||||
long long timestamp(const std::string& value);
|
||||
std::string command_text(const std::string& value);
|
||||
std::string new_session();
|
||||
std::string canonical_guid(const std::string& value);
|
||||
class runtime
|
||||
{
|
||||
public:
|
||||
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
|
||||
std::string session = new_session();
|
||||
std::map<int, player> players;
|
||||
void observe(int slot, const std::string& guid, const std::string& name);
|
||||
void disconnect(int slot);
|
||||
json request();
|
||||
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
|
||||
void enforce(long long now, const executor& execute);
|
||||
long long revision() const { return saved_.at("revision").get<long long>(); }
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, credential_scope_;
|
||||
int generation_;
|
||||
unsigned long long serial_ = 0;
|
||||
bool allocated_ = false;
|
||||
std::chrono::system_clock::time_point server_anchor_{};
|
||||
std::chrono::steady_clock::time_point clock_anchor_{};
|
||||
json saved_, pending_ = json::array();
|
||||
std::vector<std::string> sent_;
|
||||
std::map<std::string, bool> muted_;
|
||||
std::map<std::string, long long> kicked_;
|
||||
void save(const json& next);
|
||||
long long effective_time(long long local) const;
|
||||
};
|
||||
}
|
||||
+119
-28
@@ -1,7 +1,7 @@
|
||||
# T6 port notes
|
||||
|
||||
T6 is ported from T5. Both source trees contain common, HUD, AFK, join, lock,
|
||||
telemetry and leaderboard modules; neither includes bank or database modules.
|
||||
telemetry, leaderboard and bank modules; neither includes direct-database modules.
|
||||
|
||||
## Differences retained for T6 and requested fixes
|
||||
|
||||
@@ -9,15 +9,15 @@ telemetry and leaderboard modules; neither includes bank or database modules.
|
||||
| --- | --- | --- |
|
||||
| Paths | `maps/mp/_utility`, `scripts/zm/tws_t6_common` | T6 namespaces and requested filenames |
|
||||
| Loading | No T5 `zombiemode` dvar gate; each module has `main()` and `init()` | Zombies-only folder loading; avoid r5346 missing-main warnings |
|
||||
| HUD positioning | Fullscreen screen anchors, same 10-unit margin | Requested fix for inset HUD |
|
||||
| HUD positioning | Fullscreen X/Y anchors, direct normalized `640 × 480` coordinates, unboxed core and service area | Bypass safe-area insets; validate Plutonium tag clearance and font fit at each aspect ratio |
|
||||
| HUD rendering | Precached white shader, local max-health fallback, floating-point ratio, `getPlayers()` | T6 rendering/state handling without changing player max health |
|
||||
| Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters |
|
||||
| Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen |
|
||||
| AFK | Maintain control freeze, disable guns/grenades, guard player state and stale grace threads | Requested movement/shooting fix; retain `.afk` / `.back` and two-second grace |
|
||||
| Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` sources from Plutonium's `player.guid` engine property (T5 uses `player getGuid()` method); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | T6 has no `getGuid` method but exposes the persistent identity as `.guid` on the player entity; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
|
||||
| Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` comes from the persistent `player.guid` field (T5 uses `player getGuid()` directly); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | Stock T6 connection initialization populates `.guid` before notifying `connected`; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
|
||||
| Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way |
|
||||
|
||||
Common retains the T5 HUD constants, helpers and first-spawn dispatcher.
|
||||
Common provides matching T5/T6 HUD layout constants, helpers and first-spawn dispatcher.
|
||||
HUD again owns the `hud_game_over` notification fan-out. Lock again waits on HUD
|
||||
notifications, applies T5's random password, and clears it on startup/game over.
|
||||
The added password-preservation dvars and independent lock watcher are gone.
|
||||
@@ -26,11 +26,62 @@ Common also listens for `.help` through the native `say` notification, independe
|
||||
of first-spawn initialization so newcomers can view commands while spectating.
|
||||
Each loaded command module registers its command and description with
|
||||
`register_help_command()` during `init()`. Lazy registry initialization preserves
|
||||
registrations regardless of module initialization order. Help uses client HUD
|
||||
elements below the health bar, sharing the HUD's anchors to avoid overlap with
|
||||
timers, zombie count and health numbers. Text is created once per connection and
|
||||
reused; each request restarts a 12-second visibility timer. Disconnect cleanup
|
||||
is independent of that timer. The command handlers retain their existing listeners.
|
||||
registrations regardless of module initialization order. Common owns one
|
||||
unboxed upper-right service area. Help shows all nine installed commands in
|
||||
explicit command/description rows; extra registered commands use pages of nine
|
||||
rows. Pages last 12 seconds, with automatic dismissal after the final page and
|
||||
a counter only for multi-page lists. `.help` / `.commands` restart at page one.
|
||||
Help pauses record page time, and a `.leaders` request during help replaces
|
||||
the pending replay from page one. Service state prevents old requests from
|
||||
closing newer displays. Disconnect and game-end cleanup are independent of
|
||||
page timing. Command handlers retain their existing listeners, and help/records
|
||||
work without the HUD module.
|
||||
|
||||
Both engines use explicit help rows; T5 no longer relies on newline rendering,
|
||||
and T6 has the same row geometry. Common allocates only the active service
|
||||
view: 19 nonarchived elements for nine-command help (20 with a multi-page
|
||||
counter), or four for records (five with a multi-page counter). Closing the
|
||||
service or switching kinds destroys the view; idle holds no elements. Pages
|
||||
reuse the active elements, separately from controller and pending-request state.
|
||||
Core + AFK use 10 + 1 archived elements. Hidden elements still occupy slots.
|
||||
The [T6 player HUD structure](https://github.com/skiff/t6-gsc-helper/blob/50ff6cb7397c9376894a195f48118d1743812d1d/src/game/structs.hpp#L184)
|
||||
has 31 slots in each bucket; it does not establish current Plutonium stock HUD
|
||||
usage. Keep the live overlap/element-budget checks alongside the portable checks.
|
||||
|
||||
The core is created on connection for waiting spectators with placeholders,
|
||||
while timer updates retain the existing spawn/round lifecycle. Connection-time
|
||||
creation must not seed the match clock. The unboxed layout has timers at the
|
||||
upper left, branding at top center and zombies/health at bottom center. HP
|
||||
numbers appear to the right of the bar only while injured; AFK sits above the
|
||||
zombie count. Services never reposition the core. Records are right aligned,
|
||||
with `Map record | Round N` followed by up to three bounded holder lines, no
|
||||
footer and no single-page counter. Their 12-second page timing is unchanged.
|
||||
|
||||
HUD `1.4.2` requires matching common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for
|
||||
`SetTextUnlimited`. The `N ZOMBIES` counter is one element centered at the
|
||||
health track's midpoint with uniform scale `1.25`, starting as `-- ZOMBIES`.
|
||||
Only changed counts update its text; numeric HP retains `SetValue`. On r5334+,
|
||||
update the HUD script and restart the map. Older Plutonium clients and servers
|
||||
need updates and full restarts first. AFK `1.2.0` and leaderboard `1.1.0` remain
|
||||
compatible; AFK still requires common `1.4.0+`.
|
||||
Overlay helpers use direct normalized `640 × 480` coordinates. Dynamic health
|
||||
fills use `make_overlay_width()` like their tracks. Apply `int()` after `max()`
|
||||
in that helper: native `max()` returns a float, but `SetShader` requires an
|
||||
integer width. Legacy panel helpers retain their `0.75` horizontal conversion
|
||||
for existing consumers. Font scale is at least `1.0`. There is no new
|
||||
native-plugin/backend dependency or full server-process restart requirement
|
||||
when already using Plutonium r5334+.
|
||||
|
||||
HUD owns the exact, case-insensitive `.hud` chat command and registers it in
|
||||
help only while installed. Common's core wrappers preserve each element's
|
||||
intended alpha behind a per-player visibility mask; health and AFK changes use
|
||||
`set_core_alpha()`. Data/timers keep updating while hidden and freeze at game
|
||||
over. Help, records, stock HUD and AFK protection are unaffected. Visibility
|
||||
defaults on, survives death/respawn during the connection/map, and resets on
|
||||
reconnect or map restart. All nine installed commands use one help page,
|
||||
ordered `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`, `.d`, `.w`.
|
||||
Bank commands display the full `<amount|all>` syntax.
|
||||
|
||||
AFK again listens to the native `say` notification, so it no longer needs plugin
|
||||
chat registration. The additional T6 controls are only held while AFK; `.back`
|
||||
@@ -49,7 +100,7 @@ The module is `tws_t6_leaderboard.gsc` in T6 and `tws_t5_leaderboard.gsc` in T5.
|
||||
Common's existing
|
||||
connection listener now also dispatches optional connection handlers after a
|
||||
one-frame yield. Leaderboard waits for player readiness and uses a level-owned
|
||||
HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep
|
||||
HTTP cache, independent of the first-spawn handlers. T6 spectators keep
|
||||
`hasSpawned` false, so T6 readiness uses session state and the intro-screen flag;
|
||||
T5 sets `hasSpawned` for both playable and spectator spawns.
|
||||
|
||||
@@ -60,9 +111,10 @@ Mode/location combinations follow the
|
||||
and Electric Cherry's map catalogue. Unknown selections require an explicit API
|
||||
key instead of falling back to another variant sharing the engine map.
|
||||
|
||||
Validation for this change: 135 fixture/static checks pass using the actual
|
||||
data-handling functions from both scripts with JavaScript engine-value shims.
|
||||
All seven T6 scripts compile and assemble using a local Intel macOS driver built
|
||||
Historical validation for the initial leaderboard change: 135 fixture/static
|
||||
checks passed using the actual data-handling functions from both scripts with
|
||||
JavaScript engine-value shims. All seven then-current T6 scripts compiled and
|
||||
assembled using a local Intel macOS driver built
|
||||
from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was
|
||||
not executed here. T5 compilation and live plugin/gameplay checks were not run;
|
||||
gsc-tool has no T5 backend. The production API could not be reached from this
|
||||
@@ -118,15 +170,39 @@ at link time. The other four TWS modules can run without telemetry.
|
||||
|
||||
## Checks
|
||||
|
||||
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared
|
||||
references and entry points. It requires PowerShell and gsc-tool. The `.help`
|
||||
change passed the static entry-point/reference checks, but compilation and live
|
||||
gameplay were not run. Compiler success does not certify runtime linking.
|
||||
### Newcomer eligibility
|
||||
|
||||
The stock [`_zm_gametype::onspawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_zm_gametype.gsc)
|
||||
can schedule `spawnspectator` after 0.05 seconds while
|
||||
[`_globallogic_spawn::spawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_spawn.gsc)
|
||||
emits `spawned_player` at frame end. That event alone does not prove gameplay.
|
||||
Join `1.0.1` waits 0.1 seconds before checking session state and keeps listening
|
||||
after setup spectator spawns. `killed_player` also consumes eligibility so a
|
||||
death during that interval cannot restore newcomer access. The common dispatcher
|
||||
still starts each registered module once per connection.
|
||||
|
||||
Run `node tools/check_join.mjs` for portable lifecycle and request fixtures.
|
||||
These execute the join script with engine shims, not a GSC VM. Live validation:
|
||||
join Farm Survival during round 3 and remain spectating, use `.join` once, then
|
||||
try repeated requests, death, and subsequent rounds. On a fresh connection,
|
||||
wait for a normal round-start spawn, die, and verify `.join` is rejected.
|
||||
Also check a round-1 participant, reconnecting, disconnect during setup, and
|
||||
game over. Repeat on another T6 map; compile with the checker below first.
|
||||
|
||||
Local: `tools/check.ps1` compiles all eight files for T6 PC and checks shared
|
||||
references and entry points. It requires PowerShell and gsc-tool. Run the
|
||||
[shared HUD checks](../../README.md#hud-validation) for core/service behavior and
|
||||
command regressions. Compiler success does not certify runtime linking.
|
||||
|
||||
Live checks after deploying:
|
||||
|
||||
1. Join, late-join, die/respawn, and reconnect: one HUD per player, correct corner
|
||||
position, health/Juggernog changes, zombies remaining, timers frozen at game over.
|
||||
1. Join, late-join, die/respawn, and reconnect: one unboxed core per player,
|
||||
upper-left timers, top-center branding, bottom-center zombies/health and
|
||||
spectator placeholders without an early clock start. Check damage-only HP,
|
||||
health/Juggernog changes, AFK placement and timers frozen at game over.
|
||||
Test safe-area settings and different resolutions against stock HUD elements.
|
||||
Check `0`, `6`, `9`, `10`, `99` and `100 ZOMBIES` remain centered above the
|
||||
health track, including after `.hud` toggles and game over freezes the count.
|
||||
2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid
|
||||
toggles, a round transition, death/respawn and game over.
|
||||
3. Lock fires at the threshold and clears on game over/restart. HUD must be installed.
|
||||
@@ -134,12 +210,27 @@ Live checks after deploying:
|
||||
4. Telemetry sends the initial occupied round and each new round to the existing
|
||||
endpoint. Check server ID/player count, same-round joins and an unreachable API.
|
||||
5. Repeat on the actual server maps, especially afterlife and special-enemy rounds.
|
||||
6. Type `.help` and `.HELP` before first spawn, during play and while AFK: see
|
||||
`.help`, `.afk`, `.back` and `.join` with descriptions, only on the requesting
|
||||
player's screen below the health bar. Take damage so health numbers appear
|
||||
and verify they do not overlap help. Repeat the command before 12 seconds
|
||||
elapse: one list should remain visible until 12 seconds after the latest
|
||||
request. Reopen after expiry and reconnect; verify no duplicates or leftovers.
|
||||
Remove the AFK or Join module, restart the map and confirm its commands are
|
||||
absent. Restore the modules and confirm `.afk`, `.back` and `.join` still
|
||||
behave as before.
|
||||
6. Type `.help`, `.HELP` and `.commands` before first spawn, during play and
|
||||
while AFK: one page of nine commands with all modules installed, full bank
|
||||
syntax and no page counter, only on the requesting player's screen. Verify
|
||||
dismissal after 12 seconds and repeat-to-restart behavior. Add custom
|
||||
registered commands to exercise the nine-row page limit and page counter.
|
||||
Remove optional modules and restart the map to confirm their commands are
|
||||
absent. Restore them and confirm command behavior is unchanged.
|
||||
7. Interrupt a record with help and check that its remaining page time resumes.
|
||||
Repeat `.leaders` during help; only the latest replay should start from page
|
||||
one after help closes. Check long names across pages, no records and
|
||||
unavailable records. Reopen after expiry, disconnect/reconnect and end the
|
||||
game during a service; verify no duplicates or leftovers. Repeat without HUD.
|
||||
8. Toggle `.hud` before spawning, while playing/AFK and after game over. Verify
|
||||
the core and AFK indicator hide privately while help/records remain visible.
|
||||
Show it after taking damage or changing AFK state while hidden: current
|
||||
values and intended alpha should return. Confirm updates continue hidden,
|
||||
death/respawn keeps the choice, and reconnect/map restart restores visibility.
|
||||
|
||||
The stock T6 [HUD initialization](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud.gsc)
|
||||
sets a nominal font height of 12; the corresponding
|
||||
[font helper](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud_util.gsc)
|
||||
uses `int(level.fontheight * fontscale)` for its height. The portable HUD checks
|
||||
use that nominal box to prevent holder-row, help-row and AFK overlaps. Exact
|
||||
glyph extents still need live validation, especially on T5.
|
||||
|
||||
+149
-25
@@ -2,24 +2,29 @@
|
||||
|
||||
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
|
||||
just like T5. The telemetry token is embedded in the reporter script; no
|
||||
separate token config or database setup is required.
|
||||
separate telemetry token config is required. The optional bank and moderation modules each need their own
|
||||
private server token.
|
||||
|
||||
| File | Role | Dependencies |
|
||||
| --- | --- | --- |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | Stock T6 |
|
||||
| `tws_t6_hud.gsc` | Match/round timers, name, zombie count and health bar | Common |
|
||||
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common |
|
||||
| `tws_t6_common.gsc` | Shared HUD values, helpers, paginated `.help`, service window, connection and first-spawn dispatchers | Stock T6 |
|
||||
| `tws_t6_hud.gsc` | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle | Common 1.4.0+, Plutonium r5334+ |
|
||||
| `tws_t6_afk.gsc` | Persistent AFK reserve, rewards and central countdown | Common 1.4.0+, telemetry 1.0.0+, plugin 1.4.0+, EC #151 |
|
||||
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
|
||||
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
|
||||
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
|
||||
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
|
||||
| `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_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy all seven `tws_t6_*.gsc` files into
|
||||
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
|
||||
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
|
||||
in `Plutonium/plugins/` and restart the server process. The other five scripts
|
||||
1. Copy the desired modules from the nine `tws_t6_*.gsc` files into
|
||||
`Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard
|
||||
and AFK when those modules are installed.
|
||||
2. For telemetry, leaderboard, bank or moderation, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
|
||||
in `Plutonium/plugins/` and restart the server process. Moderation requires
|
||||
plugin 1.3.0+ before its script is installed; see [moderation setup](../../docs/moderation.md). The other five scripts
|
||||
do not require that plugin.
|
||||
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
|
||||
the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6).
|
||||
@@ -29,10 +34,10 @@ If upgrading from the earlier leaderboard filename, **remove
|
||||
`tws_leaderboard.gsc` from the T6 server script directory** before installing
|
||||
`tws_t6_leaderboard.gsc`, so both copies do not load.
|
||||
|
||||
If updating an existing deployment, **remove `tws_t6_db.gsc` and
|
||||
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
|
||||
will not remove those old scripts. Remove any `exec server.example.cfg` added for
|
||||
the earlier version. Neither a database nor that config file is used anymore.
|
||||
If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
|
||||
replace the old `tws_t6_bank.gsc` with this release's file. Remove any
|
||||
`exec server.example.cfg` added for that legacy version; configure the new
|
||||
Electric Cherry bank using the private per-server token described below.
|
||||
|
||||
Source `.gsc` files load directly under Plutonium's
|
||||
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
|
||||
@@ -40,12 +45,98 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
|
||||
To omit telemetry, remove its script; disabling it at runtime does not remove
|
||||
its plugin dependency during linking. The same rule applies to leaderboard.
|
||||
|
||||
## Player bank
|
||||
|
||||
Bank `2.0.2` requires plugin `1.2.0+` for startup version reporting and bounded
|
||||
parser diagnostics. Install the DLL and restart the full server process before
|
||||
loading these scripts. Follow the [diagnostic rollout and live checks](../../README.md#bank-diagnostics--tws-14).
|
||||
|
||||
The optional `tws_t6_bank.gsc` adds `.b`, `.d <amount|all>` and
|
||||
`.w <amount|all>` and registers them in help. It requires common and telemetry
|
||||
`1.0.0` or newer, the TWS t6 plugin `1.2.0`, Electric Cherry's bank API and
|
||||
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
|
||||
including a full process restart for the plugin. Bank `2.0.0+` uses only
|
||||
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
|
||||
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
|
||||
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
|
||||
an EC rebuild must settle legacy journals before the new scripts start.
|
||||
When the endpoint already matches, only a map restart is required.
|
||||
|
||||
Bank `2.0.1` reads the persistent `player.guid` field used by telemetry and
|
||||
converts it to a string before requests or journal writes. Existing strings
|
||||
are preserved exactly; missing or invalid identities fail privately without
|
||||
changing points. Stock T6
|
||||
[`callback_playerconnect()`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_player.gsc)
|
||||
populates that field before the `connected` notification. Follow the shared
|
||||
[identity and deployment checks](../../README.md#bank-identity-validation).
|
||||
|
||||
## Core HUD and service window
|
||||
|
||||
The unboxed core puts match time above round time at the upper left,
|
||||
`TWS ZOMBIES | discord.tws.lol` at the top center, and zombies remaining above a
|
||||
slim health bar at the bottom center. `N ZOMBIES` is one centered element at
|
||||
the health track's midpoint, using uniform font scale `1.25` so the whole group
|
||||
stays centered as the digit count changes. Its initial placeholder is
|
||||
`-- ZOMBIES`. Current/maximum HP appears to the right of the bar only while
|
||||
injured; it disappears at full health. The AFK reserve countdown is independently centered on screen. There are no panel backgrounds or timer labels.
|
||||
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
|
||||
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
|
||||
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
|
||||
|
||||
The core is created once per connection, including for spectator newcomers
|
||||
with placeholders until values are available. Creation does not seed the match
|
||||
clock. It persists across death and respawn. Clocks, health and zombie-count
|
||||
displays freeze at game over. Health fallbacks only affect the display and
|
||||
never modify gameplay max health.
|
||||
|
||||
The exact, case-insensitive `.hud` command toggles your core. The AFK countdown stays visible.
|
||||
It defaults visible, keeps your choice across death/respawn on the current
|
||||
connection/map, and resets visible on reconnect or map restart. Timers and data
|
||||
continue updating while hidden; game-over values remain frozen. Help, records
|
||||
and the stock HUD are unaffected. `.hud` requires the HUD module.
|
||||
|
||||
Common provides one unboxed upper-right service area below the Plutonium tag.
|
||||
Records are right aligned, with `Map record | Round N` followed by up to three
|
||||
holder lines; there is no replay footer or single-page counter. Help displays
|
||||
the installed command list in pages of nine rows in aligned rows. Services remain private, leave 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).
|
||||
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), four for records (five with a multi-page
|
||||
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
|
||||
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
|
||||
|
||||
HUD `1.4.2` requires common `1.4.0+` and
|
||||
[Plutonium r5334+](https://plutonium.pw/docs/changelog/#r5334) for its
|
||||
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
|
||||
updated HUD script and restart the map on r5334+; no full server-process
|
||||
restart is required. Older Plutonium revisions need client/server updates and
|
||||
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).
|
||||
|
||||
## Map record announcements
|
||||
|
||||
`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials.
|
||||
It works independently of telemetry and HUD, and privately announces records
|
||||
once per connection, including newcomers waiting in spectator mode.
|
||||
|
||||
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
|
||||
`level.scr_zm_map_start_location`, falling back to the corresponding
|
||||
`ui_gametype` / `ui_zm_mapstartlocation` dvars when absent. For example,
|
||||
@@ -63,30 +154,44 @@ apply to both versions. All T6 script filenames use the `tws_t6_` prefix.
|
||||
Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and
|
||||
a short description of each. The list is shown only to the requesting player,
|
||||
including spectators who have not spawned. Commands are case-insensitive.
|
||||
Help appears below the health bar for 12 seconds, clear of the timers, zombie
|
||||
counter and health display. Typing either command again restarts that display time.
|
||||
Help uses the unboxed upper-right service area. All nine installed commands
|
||||
fit in one list with command and description columns. Additional registered
|
||||
commands use pages of nine rows, with a counter only for multi-page lists.
|
||||
Pages advance every 12 seconds and close after the last page. Typing either
|
||||
command again returns to page one with a fresh timer. Both engines use
|
||||
explicit row positions so T5 does not concatenate newline-separated text.
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `.help`, `.commands` | List available commands |
|
||||
| `.afk` | Pause with protection |
|
||||
| `.hud` | Hide or show your core HUD (AFK countdown stays visible) |
|
||||
| `.afk` | Enter protected AFK using your time reserve |
|
||||
| `.back` | Return from AFK |
|
||||
| `.join` | Join mid-round (newcomers only) |
|
||||
| `.leaders` | Show the map record again (leaderboard module required) |
|
||||
| `.b` | Check bank balance (bank module required) |
|
||||
| `.d <amount\|all>` | Deposit points (bank module required) |
|
||||
| `.w <amount\|all>` | Withdraw points (bank module required) |
|
||||
|
||||
AFK and Join register their commands during `init()` through
|
||||
Command modules register their commands during `init()` through
|
||||
`tws_t6_common::register_help_command(command, description)`. If a module is
|
||||
omitted, its commands are omitted from `.help`. Register new commands alongside
|
||||
their module's initialization so the list stays current.
|
||||
omitted, its commands are omitted from `.help`. Commands follow the order above,
|
||||
independent of initialization order. Register new commands alongside their
|
||||
module's initialization so the list stays current.
|
||||
With all command modules installed, help shows one page of nine commands,
|
||||
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
|
||||
removes `.hud` from the list.
|
||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||
cached map record privately. Repeating it restarts the record from the first page;
|
||||
help temporarily hides the record until its own display ends.
|
||||
help pauses the record's remaining page time and resumes it after the final
|
||||
help page. `.leaders` during help replaces the pending record replay from page one.
|
||||
|
||||
## Defaults, matching T5
|
||||
|
||||
- HUD values live in `tws_t6_common::common_config()`. That function and
|
||||
`ensure_config()` came from T5; they initialize in-script values and do not
|
||||
load a config file. The requested fullscreen corner alignment is retained.
|
||||
load a config file. Core and service overlays use fullscreen X/Y anchors in
|
||||
normalized `640 × 480` coordinates to bypass safe-area insets.
|
||||
- AFK uses T5's native `level waittill("say", message, player)` listener.
|
||||
`.back` restores movement and weapons immediately, then ends protection after
|
||||
two seconds. The T6 control/weapon fix remains in place.
|
||||
@@ -132,9 +237,9 @@ It sends four event types over the life of a match, mirroring the T5 script:
|
||||
Each event payload includes `event`, `server`, `map`, `match`, `seq`, `round` (except `game_start`)
|
||||
and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives`
|
||||
and `score` plus `score_total` per player in stat reports. `game_start` includes
|
||||
only player identity. `guid` reads Plutonium's `player.guid` engine property
|
||||
(T5 uses the `player getGuid()` method — T6 has no such method, but the field is
|
||||
equivalent for identity purposes). `kills`/`headshots`/`downs`/`revives` read
|
||||
only player identity. `guid` reads the persistent `player.guid` field populated
|
||||
by T6's stock connection initialization (T5 reads `player getGuid()` directly).
|
||||
`kills`/`headshots`/`downs`/`revives` read
|
||||
from T6's stock match counters in `player.pers[]`; missing or negative values
|
||||
are reported as `0`. `score` is the player's current spendable `player.score`.
|
||||
`score_total` reads `player.score_total`, the stock cumulative match score used
|
||||
@@ -175,7 +280,10 @@ another mod installed one first.
|
||||
|
||||
## Validation
|
||||
|
||||
To compile all seven scripts for T6 PC with gsc-tool:
|
||||
Run the shared [portable and live HUD checks](../../README.md#hud-validation)
|
||||
for core placement, service pagination, record interruptions and command regressions.
|
||||
|
||||
To compile all nine scripts for T6 PC with gsc-tool:
|
||||
|
||||
```powershell
|
||||
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
|
||||
@@ -183,3 +291,19 @@ To compile all seven scripts for T6 PC with gsc-tool:
|
||||
|
||||
Compilation does not verify engine/plugin linking or gameplay. T6 differences,
|
||||
research sources and live checks are in [NOTES.md](NOTES.md).
|
||||
|
||||
## Moderation
|
||||
|
||||
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
|
||||
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
|
||||
configuration, persistence, action results and deployment checks.
|
||||
|
||||
## Persistent AFK reserve
|
||||
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
|
||||
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
|
||||
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc')
|
||||
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
|
||||
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc', 'tws_t6_moderation.gsc')
|
||||
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the nine TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank, moderation.' }
|
||||
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
|
||||
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
|
||||
foreach ($file in $files) {
|
||||
@@ -32,4 +32,4 @@ foreach ($file in $files) {
|
||||
}
|
||||
Write-Output "PASS $($file.Name)"
|
||||
}
|
||||
Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
|
||||
Write-Output 'All nine T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
|
||||
|
||||
+883
-68
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
/* tws_t6_bank.gsc - shared GUID bank, with durable transfer decisions. */
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
|
||||
main()
|
||||
{
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
if (GetDvar("tws_bank_enabled") == "0")
|
||||
return;
|
||||
// Game containers use the public API, independent of the host controller URL.
|
||||
level.bank_url = "https://ec.thewrightserver.net/api/bank";
|
||||
level.bank_server = GetDvar("tws_server_id");
|
||||
level.bank_token = GetDvar("tws_bank_token");
|
||||
level.bank_logs = [];
|
||||
level.bank_diagnostic_serial = 0;
|
||||
credentials = "valid";
|
||||
if (level.bank_token.size < 40 || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
||||
credentials = "invalid";
|
||||
println("[tws_bank] startup script=2.0.3 plugin=" + bankVersion() +
|
||||
" endpoint=" + level.bank_url + " server=" + bank_log_token(level.bank_server) +
|
||||
" credentials=" + credentials);
|
||||
// A private per-server cfg supplies this; never embed bank credentials.
|
||||
if (level.bank_server == "" || level.bank_token.size < 40 ||
|
||||
getSubStr(level.bank_token, 0, 8) != "ec_bank_")
|
||||
{
|
||||
println("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token; pending=not_loaded");
|
||||
return;
|
||||
}
|
||||
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
|
||||
if (!isDefined(level.bank_queue))
|
||||
{
|
||||
println("[tws_bank] startup journal_load=failure pending=unknown bank=disabled");
|
||||
return;
|
||||
}
|
||||
println("[tws_bank] startup journal_load=success pending=" + level.bank_queue.size);
|
||||
for (i = 0; i < level.bank_queue.size; i++)
|
||||
{
|
||||
level.bank_queue[i]["since"] = getTime();
|
||||
level.bank_queue[i]["attempts"] = 0;
|
||||
}
|
||||
level.bank_read = 0;
|
||||
level.bank_write = level.bank_queue.size;
|
||||
level.bank_busy = false;
|
||||
level.bank_ended = false;
|
||||
scripts\zm\tws_t6_common::register_help_command(".b", "Show bank balance");
|
||||
scripts\zm\tws_t6_common::register_help_command(".d <amount|all>", "Deposit points");
|
||||
scripts\zm\tws_t6_common::register_help_command(".w <amount|all>", "Withdraw points");
|
||||
level thread bank_end_watcher();
|
||||
level thread bank_sender();
|
||||
level thread bank_chat_listener();
|
||||
}
|
||||
|
||||
bank_end_watcher()
|
||||
{
|
||||
level waittill_any("end_game", "game_ended", "intermission");
|
||||
level.bank_ended = true;
|
||||
}
|
||||
|
||||
bank_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
args = strTok(toLower(message), " ");
|
||||
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
|
||||
continue;
|
||||
if (level.bank_busy || level.bank_read < level.bank_write)
|
||||
{
|
||||
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
|
||||
continue;
|
||||
}
|
||||
level.bank_busy = true;
|
||||
level thread bank_request(player, args);
|
||||
}
|
||||
}
|
||||
|
||||
bank_amount(text)
|
||||
{
|
||||
if (text == "all")
|
||||
return -1;
|
||||
if (text.size == 0 || text.size > 7)
|
||||
return 0;
|
||||
digits = "0123456789";
|
||||
for (i = 0; i < text.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < digits.size; j++)
|
||||
if (text[i] == digits[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return 0;
|
||||
}
|
||||
amount = int(text);
|
||||
if (amount < 1 || amount > 1000000)
|
||||
return 0;
|
||||
return amount;
|
||||
}
|
||||
|
||||
bank_playing(player)
|
||||
{
|
||||
return !level.bank_ended && !is_true(level.intermission) &&
|
||||
isDefined(player) && isDefined(player.sessionstate) &&
|
||||
player.sessionstate == "playing" && isAlive(player) &&
|
||||
!(player maps\mp\zombies\_zm_laststand::player_is_in_laststand()) &&
|
||||
isDefined(player.score) && player.score >= 0;
|
||||
}
|
||||
|
||||
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
|
||||
bank_guard(entry)
|
||||
{
|
||||
self endon("tws_bank_finished");
|
||||
self waittill_any("disconnect", "killed_player", "spawned_player");
|
||||
entry.active = false;
|
||||
}
|
||||
|
||||
bank_say(entry, text)
|
||||
{
|
||||
if (entry.active && isDefined(entry.player))
|
||||
entry.player iPrintLnBold(text);
|
||||
}
|
||||
|
||||
bank_request(player, args)
|
||||
{
|
||||
entry = spawnStruct();
|
||||
entry.player = player;
|
||||
entry.active = true;
|
||||
player thread bank_guard(entry);
|
||||
bank_transfer(entry, args);
|
||||
if (entry.active && isDefined(player))
|
||||
player notify("tws_bank_finished");
|
||||
level.bank_busy = false;
|
||||
}
|
||||
|
||||
bank_guid(player)
|
||||
{
|
||||
if (!isDefined(player))
|
||||
return undefined;
|
||||
// Use the persistent field populated at connection, as telemetry does.
|
||||
guid = player.guid;
|
||||
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
||||
return undefined;
|
||||
// Preserve string identities without numeric casts, including large GUIDs.
|
||||
guid = "" + guid;
|
||||
if (guid.size == 0 || guid.size > 64)
|
||||
return undefined;
|
||||
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
||||
for (i = 0; i < guid.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < allowed.size; j++)
|
||||
if (guid[i] == allowed[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return undefined;
|
||||
}
|
||||
return guid;
|
||||
}
|
||||
|
||||
bank_transfer(entry, args)
|
||||
{
|
||||
player = entry.player;
|
||||
guid = bank_guid(player);
|
||||
if (!isDefined(guid))
|
||||
{
|
||||
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
|
||||
return;
|
||||
}
|
||||
data = [];
|
||||
data["server"] = level.bank_server;
|
||||
data["guid"] = guid;
|
||||
if (args[0] == ".b")
|
||||
{
|
||||
if (args.size != 1)
|
||||
{
|
||||
bank_say(entry, "^3Usage: .b");
|
||||
return;
|
||||
}
|
||||
reply = bank_http("balance", jsonSerialize(data), "");
|
||||
if (!isDefined(reply) || reply["state"] != "balance")
|
||||
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
|
||||
else
|
||||
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points");
|
||||
return;
|
||||
}
|
||||
if (args.size != 2)
|
||||
{
|
||||
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
|
||||
return;
|
||||
}
|
||||
amount = bank_amount(args[1]);
|
||||
if (amount == 0)
|
||||
{
|
||||
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
|
||||
return;
|
||||
}
|
||||
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
|
||||
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
|
||||
{
|
||||
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
|
||||
return;
|
||||
}
|
||||
operation = "withdraw";
|
||||
if (args[0] == ".d")
|
||||
{
|
||||
operation = "deposit";
|
||||
if (amount == -1)
|
||||
amount = int(player.score);
|
||||
if (amount <= 0 || amount > 1000000 || player.score < amount)
|
||||
{
|
||||
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
capacity = 1000000 - int(player.score);
|
||||
if (capacity < 0)
|
||||
capacity = 0;
|
||||
if (operation == "withdraw" && amount != -1 && amount > capacity)
|
||||
{
|
||||
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
|
||||
return;
|
||||
}
|
||||
id = telemetryNewMatch();
|
||||
if (!isDefined(id))
|
||||
{
|
||||
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
|
||||
return;
|
||||
}
|
||||
data["request_id"] = id;
|
||||
data["match"] = level.telemetry_match;
|
||||
data["operation"] = operation;
|
||||
data["amount"] = amount;
|
||||
if (amount == -1)
|
||||
data["amount"] = "all";
|
||||
data["capacity"] = capacity;
|
||||
data["decision"] = "cancel";
|
||||
body = jsonSerialize(data);
|
||||
pending_since = getTime();
|
||||
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
|
||||
if (!bank_store(body, id, "cancel"))
|
||||
{
|
||||
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||
return;
|
||||
}
|
||||
reply = bank_http(operation, body, id);
|
||||
if (isDefined(reply) && reply["state"] == "prepared")
|
||||
{
|
||||
transfer = reply["amount"];
|
||||
if (entry.active && bank_playing(player) &&
|
||||
(amount == -1 || amount == transfer) &&
|
||||
((operation == "deposit" && player.score >= transfer) ||
|
||||
(operation == "withdraw" && player.score <= 1000000 - transfer)))
|
||||
{
|
||||
data["decision"] = "commit";
|
||||
commit_body = jsonSerialize(data);
|
||||
// No yield between recording the decision and updating the balance.
|
||||
// Do not change score_total: bank funds are not earned match points.
|
||||
if (bank_store(commit_body, id, "commit"))
|
||||
{
|
||||
if (operation == "deposit")
|
||||
player.score -= transfer;
|
||||
else
|
||||
player.score += transfer;
|
||||
player.pers["score"] = player.score;
|
||||
body = commit_body;
|
||||
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
|
||||
}
|
||||
else
|
||||
{
|
||||
data["decision"] = "cancel";
|
||||
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
|
||||
}
|
||||
}
|
||||
else
|
||||
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
|
||||
}
|
||||
else if (isDefined(reply) && reply["state"] == "rejected")
|
||||
bank_say(entry, bank_error(reply["code"]));
|
||||
else
|
||||
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
|
||||
pending = [];
|
||||
pending["id"] = id;
|
||||
pending["body"] = body;
|
||||
pending["decision"] = data["decision"];
|
||||
pending["since"] = pending_since;
|
||||
pending["attempts"] = 0;
|
||||
level.bank_queue[level.bank_write] = pending;
|
||||
level.bank_write++;
|
||||
}
|
||||
|
||||
bank_error(code)
|
||||
{
|
||||
if (code == "insufficient_balance" || code == "empty")
|
||||
return "^3Not enough bank balance. Your points are unchanged.";
|
||||
if (code == "bank_limit")
|
||||
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
|
||||
if (code == "points_limit")
|
||||
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
|
||||
if (code == "not_in_match")
|
||||
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
|
||||
if (code == "pending")
|
||||
return "^3A bank transfer is pending on another server. Try again shortly.";
|
||||
return "^3Bank rejected the transfer. Your points are unchanged.";
|
||||
}
|
||||
|
||||
// Only locally generated identities/configured server names pass through here.
|
||||
// Never use this to print response fields: those use fixed vocabularies below.
|
||||
bank_log_token(value)
|
||||
{
|
||||
if (!isDefined(value) || typeof(value) != "string" || value.size == 0 || value.size > 128)
|
||||
return "unavailable";
|
||||
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
found = false;
|
||||
for (j = 0; j < allowed.size; j++)
|
||||
if (value[i] == allowed[j])
|
||||
found = true;
|
||||
if (!found)
|
||||
return "invalid";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bank_response_code(code)
|
||||
{
|
||||
// Unknown values may contain secrets or console controls, even if short.
|
||||
codes = strTok("ok insufficient_balance empty bank_limit points_limit not_in_match pending invalid_request unauthorized forbidden not_found conflict disabled", " ");
|
||||
for (i = 0; i < codes.size; i++)
|
||||
if (isDefined(code) && code == codes[i])
|
||||
return codes[i];
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bank_response_reason(reason)
|
||||
{
|
||||
reasons = strTok("empty_body body_limit invalid_json schema field_limit state amount_range ok_state missing_id zero_transfer", " ");
|
||||
for (i = 0; i < reasons.size; i++)
|
||||
if (isDefined(reason) && reason == reasons[i])
|
||||
return reasons[i];
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bank_diagnostic_number(diagnostics, key)
|
||||
{
|
||||
if (!isDefined(diagnostics) || typeof(diagnostics) != "array" || !isDefined(diagnostics[key]))
|
||||
return "unavailable";
|
||||
value = diagnostics[key];
|
||||
if ((typeof(value) != "int" && typeof(value) != "float") || value < 0 || value > 2147483647)
|
||||
return "unavailable";
|
||||
return "" + int(value);
|
||||
}
|
||||
|
||||
bank_http_details(diagnostics)
|
||||
{
|
||||
detail = "";
|
||||
keys = strTok("http_status curl_code dns_ms connect_ms tls_ms total_ms", " ");
|
||||
for (i = 0; i < keys.size; i++)
|
||||
detail += " " + keys[i] + "=" + bank_diagnostic_number(diagnostics, keys[i]);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// Fixed keys bound memory. Correlation IDs/timings never form the signature.
|
||||
// Report first failure, reason/status/code changes, 30s reminders and recovery.
|
||||
bank_log(key, signature, detail, failed)
|
||||
{
|
||||
now = getTime();
|
||||
previous = level.bank_logs[key];
|
||||
suppressed = 0;
|
||||
recovery = "";
|
||||
if (isDefined(previous))
|
||||
{
|
||||
suppressed = previous["suppressed"];
|
||||
if (failed && previous["signature"] == signature && now >= previous["time"] && now - previous["time"] < 30000)
|
||||
{
|
||||
previous["suppressed"]++;
|
||||
level.bank_logs[key] = previous;
|
||||
return;
|
||||
}
|
||||
if (previous["failed"] && !failed)
|
||||
recovery = " recovery=true";
|
||||
}
|
||||
println("[tws_bank] " + detail + recovery + " suppressed=" + suppressed);
|
||||
current = [];
|
||||
current["signature"] = signature;
|
||||
current["time"] = now;
|
||||
current["failed"] = failed;
|
||||
current["suppressed"] = 0;
|
||||
level.bank_logs[key] = current;
|
||||
}
|
||||
|
||||
bank_store(body, id, decision)
|
||||
{
|
||||
saved = bankStore(level.bank_server, level.bank_url, body);
|
||||
outcome = "success";
|
||||
if (!saved)
|
||||
outcome = "storage_failure";
|
||||
bank_log("write_" + decision, outcome, "journal_write decision=" + decision +
|
||||
" request_id=" + bank_log_token(id) + " outcome=" + outcome, !saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
bank_http_outcome(action, id, correlation, reason, code, diagnostics, failed)
|
||||
{
|
||||
level.bank_http_signature = reason + ":" + code + ":" +
|
||||
bank_diagnostic_number(diagnostics, "http_status") + ":" + bank_diagnostic_number(diagnostics, "curl_code");
|
||||
level.bank_http_detail = "correlation=" + correlation + " action=" + action +
|
||||
" request_id=" + bank_log_token(id) + " outcome=" + reason + " code=" + code + bank_http_details(diagnostics);
|
||||
// Settlement adds progress and cleanup outcome before logging this attempt.
|
||||
if (action != "commit" && action != "cancel")
|
||||
bank_log(action, level.bank_http_signature, level.bank_http_detail, failed);
|
||||
}
|
||||
|
||||
bank_http(action, body, id)
|
||||
{
|
||||
level.bank_diagnostic_serial++;
|
||||
correlation = "bank-" + getTime() + "-" + level.bank_diagnostic_serial;
|
||||
headers = [];
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Authorization"] = "Bearer " + level.bank_token;
|
||||
options = [];
|
||||
options["method"] = "POST";
|
||||
options["headers"] = headers;
|
||||
options["body"] = body;
|
||||
options["connect_timeout_ms"] = 5000;
|
||||
options["timeout_ms"] = 15000;
|
||||
request = http::request(level.bank_url + "/" + action, options);
|
||||
if (!isDefined(request))
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, "request_creation", "none", undefined, true);
|
||||
return undefined;
|
||||
}
|
||||
request waittill("done", response, success, error, diagnostics);
|
||||
reason = "ok";
|
||||
if (!isDefined(success) || !success)
|
||||
reason = "transport_failure";
|
||||
else if (bank_diagnostic_number(diagnostics, "http_status") == "unavailable")
|
||||
reason = "missing_diagnostics";
|
||||
else if (diagnostics["http_status"] != 200)
|
||||
reason = "http_rejection";
|
||||
else if (!isDefined(response))
|
||||
reason = "missing_response";
|
||||
if (reason != "ok")
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, reason, "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
reply = bankResponse(response);
|
||||
if (!isDefined(reply) || !isDefined(reply["valid"]) || !reply["valid"])
|
||||
{
|
||||
reason = "unknown";
|
||||
if (isDefined(reply))
|
||||
reason = bank_response_reason(reply["reason"]);
|
||||
bank_http_outcome(action, id, correlation, "invalid_ack_" + reason, "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
if (reply["request_id"] != id)
|
||||
{
|
||||
bank_http_outcome(action, id, correlation, "request_id_mismatch", "none", diagnostics, true);
|
||||
return undefined;
|
||||
}
|
||||
if (reply["state"] == "rejected")
|
||||
reason = "application_rejection";
|
||||
else if ((action == "balance" && reply["state"] != "balance") ||
|
||||
((action == "deposit" || action == "withdraw") && reply["state"] != "prepared") ||
|
||||
((action == "commit" || action == "cancel") && !bank_resolved(action, reply["state"])))
|
||||
reason = "unexpected_state";
|
||||
bank_http_outcome(action, id, correlation, reason, bank_response_code(reply["code"]), diagnostics, reason != "ok");
|
||||
return reply;
|
||||
}
|
||||
|
||||
bank_resolved(decision, state)
|
||||
{
|
||||
if (decision == "commit")
|
||||
return state == "committed";
|
||||
return state == "cancelled" || state == "rejected";
|
||||
}
|
||||
|
||||
bank_sender()
|
||||
{
|
||||
delay = 1;
|
||||
for (;;)
|
||||
{
|
||||
if (level.bank_busy || level.bank_read >= level.bank_write)
|
||||
{
|
||||
wait 0.1;
|
||||
continue;
|
||||
}
|
||||
entry = level.bank_queue[level.bank_read];
|
||||
entry["attempts"]++;
|
||||
level.bank_queue[level.bank_read] = entry;
|
||||
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
|
||||
duration = int((getTime() - entry["since"]) / 1000);
|
||||
progress = " decision=" + entry["decision"] + " attempt=" + entry["attempts"] +
|
||||
" pending_seconds=" + duration;
|
||||
outcome = "pending";
|
||||
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
|
||||
{
|
||||
// Cleanup failures never reapply points; settlement is idempotent.
|
||||
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
|
||||
{
|
||||
bank_log("settlement", "resolved", level.bank_http_detail + progress +
|
||||
" journal_remove=success settlement=resolved next_retry_seconds=0", false);
|
||||
level.bank_queue[level.bank_read] = undefined;
|
||||
level.bank_read++;
|
||||
if (level.bank_read == level.bank_write)
|
||||
{
|
||||
level.bank_queue = [];
|
||||
level.bank_read = 0;
|
||||
level.bank_write = 0;
|
||||
}
|
||||
delay = 1;
|
||||
continue;
|
||||
}
|
||||
outcome = "journal_remove_failure";
|
||||
}
|
||||
bank_log("settlement", level.bank_http_signature + ":" + outcome, level.bank_http_detail + progress +
|
||||
" settlement=" + outcome + " next_retry_seconds=" + delay, true);
|
||||
wait delay;
|
||||
delay *= 2;
|
||||
if (delay > 30)
|
||||
delay = 30;
|
||||
}
|
||||
}
|
||||
+608
-70
@@ -13,6 +13,9 @@ init()
|
||||
common_config();
|
||||
register_help_command(".help", "List available commands");
|
||||
|
||||
level thread service_game_over_via("end_game");
|
||||
level thread service_game_over_via("game_ended");
|
||||
level thread service_game_over_via("intermission");
|
||||
level thread help_chat_listener();
|
||||
level thread on_player_connect_dispatch();
|
||||
}
|
||||
@@ -22,41 +25,84 @@ common_config()
|
||||
if (isDefined(level.hud_x))
|
||||
return;
|
||||
|
||||
// Small inset from the physical screen corner (fullscreen HUD coordinates).
|
||||
// Retain legacy panel coordinates for existing helper consumers.
|
||||
level.hud_x = 10;
|
||||
level.hud_y = 10;
|
||||
|
||||
level.hud_row_match = level.hud_y;
|
||||
level.hud_row_round = level.hud_y + 15;
|
||||
level.hud_row_name = level.hud_y + 32;
|
||||
level.hud_row_count = level.hud_y + 54;
|
||||
level.hud_row_bar = level.hud_y + 84;
|
||||
|
||||
level.hud_font_small = 1.4;
|
||||
level.hud_font_large = 2.0;
|
||||
level.hud_font_mid = 1.6;
|
||||
|
||||
level.hud_bar_width = 120;
|
||||
level.hud_bar_height = 8;
|
||||
|
||||
level.hud_col_count = level.hud_x + 90;
|
||||
level.hud_col_name = level.hud_x + 60;
|
||||
level.hud_col_health = level.hud_x + level.hud_bar_width + 12;
|
||||
|
||||
level.hud_rate = 0.1;
|
||||
|
||||
// green/amber/red ramp is health-only
|
||||
level.hud_c_label = (0.85, 0.85, 0.85);
|
||||
level.hud_c_dim = (0.55, 0.55, 0.55);
|
||||
level.hud_c_count = (1, 1, 1);
|
||||
level.hud_c_name = (0.4, 0.9, 0.5);
|
||||
level.hud_c_high = (0.2, 1, 0.4);
|
||||
level.hud_c_mid = (1, 0.8, 0);
|
||||
level.hud_c_low = (1, 0.2, 0.2);
|
||||
level.hud_c_afk = (1, 0.8, 0.2);
|
||||
|
||||
// numbers fade in below this fraction of max hp
|
||||
level.hud_y = 30;
|
||||
// Retain the legacy fields for consumers of the original common config.
|
||||
// The panel renderers below use dedicated row/column offsets instead.
|
||||
level.hud_row_match = level.hud_y;
|
||||
level.hud_row_round = level.hud_y + 15;
|
||||
level.hud_row_name = level.hud_y + 32;
|
||||
level.hud_row_count = level.hud_y + 54;
|
||||
level.hud_col_count = level.hud_x + 90;
|
||||
level.hud_col_name = level.hud_x + 60;
|
||||
level.hud_col_health = level.hud_x + 120 + 12;
|
||||
level.hud_health_threshold = 1.0;
|
||||
level.hud_panel_right = 10;
|
||||
level.hud_panel_top = 20;
|
||||
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
|
||||
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
|
||||
level.hud_panel_x_scale = 0.75;
|
||||
level.hud_panel_width = 220;
|
||||
level.hud_core_height = 100;
|
||||
level.hud_service_y = 108;
|
||||
level.hud_service_height = 124;
|
||||
level.hud_pad = 10;
|
||||
level.hud_row_header = 10;
|
||||
level.hud_row_timers = 34;
|
||||
level.hud_row_stats = 59;
|
||||
level.hud_row_bar = 80;
|
||||
level.hud_row_afk = 86;
|
||||
|
||||
level.hud_font_small = 1.1;
|
||||
level.hud_font_large = 1.6;
|
||||
level.hud_font_mid = 1.25;
|
||||
level.hud_font_afk = 1.0;
|
||||
level.hud_bar_width = 200;
|
||||
level.hud_bar_height = 4;
|
||||
level.hud_rate = 0.1;
|
||||
level.hud_c_panel = (0.07, 0.10, 0.08);
|
||||
level.hud_panel_alpha = 0.72;
|
||||
level.hud_c_label = (0.72, 0.77, 0.74);
|
||||
level.hud_c_dim = (0.65, 0.70, 0.67);
|
||||
level.hud_c_count = (0.93, 0.96, 0.94);
|
||||
level.hud_c_name = (0.65, 0.85, 0.70);
|
||||
level.hud_c_high = (0.65, 0.85, 0.70);
|
||||
level.hud_c_mid = (1, 0.8, 0);
|
||||
level.hud_c_low = (1, 0.2, 0.2);
|
||||
level.hud_c_afk = (1, 0.8, 0.2);
|
||||
|
||||
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
|
||||
level.hud_overlay_timer_x = 6;
|
||||
level.hud_overlay_match_y = 8;
|
||||
level.hud_overlay_round_y = 30;
|
||||
level.hud_overlay_brand_x = 320;
|
||||
level.hud_overlay_brand_y = 8;
|
||||
level.hud_overlay_count_x = 300;
|
||||
level.hud_overlay_count_y = 440;
|
||||
level.hud_overlay_count_label_x = 306;
|
||||
level.hud_overlay_count_label_y = 446;
|
||||
level.hud_overlay_bar_x = 242;
|
||||
level.hud_overlay_bar_y = 466;
|
||||
level.hud_overlay_bar_width = 156;
|
||||
level.hud_overlay_bar_height = 4;
|
||||
level.hud_overlay_health_y = 460;
|
||||
level.hud_overlay_health_x = 414;
|
||||
level.hud_overlay_slash_x = 418;
|
||||
level.hud_overlay_maxhealth_x = 445;
|
||||
level.hud_overlay_hp_x = 449;
|
||||
level.hud_overlay_afk_x = 320;
|
||||
level.hud_overlay_afk_y = 421;
|
||||
level.hud_service_left = 432;
|
||||
level.hud_service_right = 633;
|
||||
level.hud_service_description_x = 538;
|
||||
level.hud_service_title_y = 54;
|
||||
level.hud_service_help_y = 80;
|
||||
level.hud_service_help_pitch = 24;
|
||||
level.hud_service_help_rows = 9;
|
||||
level.hud_service_record_y = 36;
|
||||
level.hud_service_holders_y = 60;
|
||||
level.hud_service_holders_pitch = 18;
|
||||
|
||||
if (!isDefined(level.tws_spawn_handlers))
|
||||
level.tws_spawn_handlers = [];
|
||||
@@ -112,58 +158,408 @@ help_chat_listener()
|
||||
}
|
||||
}
|
||||
|
||||
// A single per-player controller owns service priority, pages and expiry.
|
||||
show_help()
|
||||
{
|
||||
// Cache the text per connection; repeated SetText calls consume config strings.
|
||||
if (!isDefined(self.tws_help_elems))
|
||||
{
|
||||
elems = [];
|
||||
// Use the same anchors as the HUD, with room below the health numbers.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
title = make_text_elem(self, level.hud_x, y,
|
||||
"left", level.hud_font_mid, level.hud_c_afk);
|
||||
title SetText("Commands");
|
||||
elems[elems.size] = title;
|
||||
if (isDefined(level.tws_service_ended))
|
||||
return;
|
||||
self service_ensure();
|
||||
state = self.tws_service;
|
||||
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.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
|
||||
service_render(state);
|
||||
}
|
||||
|
||||
// An undefined snapshot is the explicit unavailable view for manual requests.
|
||||
show_record(snapshot)
|
||||
{
|
||||
if (isDefined(level.tws_service_ended))
|
||||
return;
|
||||
self service_ensure();
|
||||
state = self.tws_service;
|
||||
service_advance(state, getTime());
|
||||
count = 0;
|
||||
if (isDefined(snapshot))
|
||||
count = snapshot.lines.size;
|
||||
state.generation++;
|
||||
state.record = service_request("record", service_page_count(count, 3), state.generation);
|
||||
state.record.snapshot = snapshot;
|
||||
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)
|
||||
{
|
||||
return int((max(count, 1) + rows - 1) / rows);
|
||||
}
|
||||
|
||||
service_request(kind, pages, generation)
|
||||
{
|
||||
request = spawnStruct();
|
||||
request.kind = kind;
|
||||
request.generation = generation;
|
||||
request.page = 0;
|
||||
request.pages = pages;
|
||||
request.remaining = 12000;
|
||||
if (kind == "reminder")
|
||||
request.remaining = 6000;
|
||||
return request;
|
||||
}
|
||||
|
||||
service_current(state)
|
||||
{
|
||||
if (isDefined(state.help))
|
||||
return state.help;
|
||||
return state.record;
|
||||
}
|
||||
|
||||
service_advance(state, now)
|
||||
{
|
||||
elapsed = max(0, now - state.updated_at);
|
||||
state.updated_at = now;
|
||||
request = service_current(state);
|
||||
if (!isDefined(request) || state.closed)
|
||||
return;
|
||||
|
||||
// Only the visible request consumes time. Help leaves the record untouched.
|
||||
request.remaining -= elapsed;
|
||||
if (request.remaining > 0)
|
||||
return;
|
||||
request.page++;
|
||||
request.remaining = 12000;
|
||||
if (request.page >= request.pages)
|
||||
{
|
||||
if (request.kind == "help")
|
||||
state.help = undefined;
|
||||
else
|
||||
state.record = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Stable order is independent of module initialization order; absent commands
|
||||
// stay absent. Unknown future commands follow the built-in ones in registry order.
|
||||
help_rank(command)
|
||||
{
|
||||
if (command == ".help") return 0;
|
||||
if (command == ".hud") return 1;
|
||||
if (command == ".afk") return 2;
|
||||
if (command == ".back") return 3;
|
||||
if (command == ".join") return 4;
|
||||
if (command == ".leaders") return 5;
|
||||
if (command == ".b") return 6;
|
||||
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
|
||||
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
|
||||
return 9;
|
||||
}
|
||||
|
||||
help_entries()
|
||||
{
|
||||
entries = [];
|
||||
if (!isDefined(level.tws_help_commands))
|
||||
return entries;
|
||||
for (rank = 0; rank <= 9; rank++)
|
||||
{
|
||||
for (i = 0; i < level.tws_help_commands.size; i++)
|
||||
{
|
||||
entry = level.tws_help_commands[i];
|
||||
elem = make_text_elem(self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
elem SetText("^3" + entry.command + "^7 - " + entry.description);
|
||||
elems[elems.size] = elem;
|
||||
if (help_rank(entry.command) == rank)
|
||||
entries[entries.size] = entry;
|
||||
}
|
||||
|
||||
self.tws_help_elems = elems;
|
||||
self thread destroy_on_disconnect(elems);
|
||||
}
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 1;
|
||||
}
|
||||
|
||||
// Only the expiry timer is replaced; the say listener and cleanup stay alive.
|
||||
self notify("tws_help_refresh");
|
||||
self thread hide_help_after_delay();
|
||||
return entries;
|
||||
}
|
||||
|
||||
hide_help_after_delay()
|
||||
help_command_label(command)
|
||||
{
|
||||
return command;
|
||||
}
|
||||
|
||||
help_description(entry)
|
||||
{
|
||||
rank = help_rank(entry.command);
|
||||
if (rank == 0) return "Show commands";
|
||||
if (rank == 1) return "Toggle core HUD";
|
||||
if (rank == 2) return "Protect & freeze";
|
||||
if (rank == 3) return "Return to play";
|
||||
if (rank == 4) return "Join as newcomer";
|
||||
if (rank == 5) return "View records";
|
||||
if (rank == 6) return "Bank balance";
|
||||
if (rank == 7) return "Deposit";
|
||||
if (rank == 8) return "Withdraw";
|
||||
return entry.description;
|
||||
}
|
||||
|
||||
service_ensure()
|
||||
{
|
||||
if (isDefined(self.tws_service))
|
||||
return;
|
||||
ensure_config();
|
||||
state = spawnStruct();
|
||||
state.elems = [];
|
||||
state.commands = help_entries();
|
||||
state.help = undefined;
|
||||
state.record = undefined;
|
||||
state.closed = false;
|
||||
state.updated_at = getTime();
|
||||
state.rendered = false;
|
||||
state.generation = 0;
|
||||
state.last_generation = 0;
|
||||
state.last_page = -1;
|
||||
state.record_text = [];
|
||||
state.help_page = -1;
|
||||
state.rows = [];
|
||||
state.command_rows = [];
|
||||
state.description_rows = [];
|
||||
state.view_kind = undefined;
|
||||
self.tws_service = state;
|
||||
|
||||
self thread service_loop(state);
|
||||
self thread service_disconnect(state);
|
||||
self thread service_end_game(state);
|
||||
}
|
||||
|
||||
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
|
||||
service_view_create(state, request)
|
||||
{
|
||||
service_view_destroy(state);
|
||||
state.view_kind = request.kind;
|
||||
state.view_paged = request.pages > 1;
|
||||
if (state.view_paged)
|
||||
state.page_counter = service_text(self, state, level.hud_service_right,
|
||||
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
|
||||
|
||||
if (request.kind == "help")
|
||||
{
|
||||
state.help_title = service_text(self, state, level.hud_service_left,
|
||||
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
|
||||
rows = state.commands.size;
|
||||
if (rows > level.hud_service_help_rows)
|
||||
rows = level.hud_service_help_rows;
|
||||
// Explicit lines also work on T5, which can collapse multiline strings.
|
||||
for (i = 0; i < rows; i++)
|
||||
{
|
||||
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
|
||||
state.command_rows[i] = service_text(self, state, level.hud_service_left,
|
||||
y, "left", 1.0, "", level.hud_c_count);
|
||||
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
|
||||
y, "left", 1.0, "", level.hud_c_label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.record_title = service_text(self, state, level.hud_service_right,
|
||||
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
|
||||
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,
|
||||
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
|
||||
"right", 1.05, "", level.hud_c_label);
|
||||
if (isDefined(state.page_counter))
|
||||
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
|
||||
}
|
||||
|
||||
service_view_destroy(state)
|
||||
{
|
||||
for (i = 0; i < state.elems.size; i++)
|
||||
{
|
||||
if (isDefined(state.elems[i]))
|
||||
state.elems[i] Destroy();
|
||||
}
|
||||
state.elems = [];
|
||||
state.view_kind = undefined;
|
||||
state.view_paged = undefined;
|
||||
state.page_counter = undefined;
|
||||
state.help_title = undefined;
|
||||
state.command_rows = [];
|
||||
state.description_rows = [];
|
||||
state.record_title = undefined;
|
||||
state.rows = [];
|
||||
state.page_text = undefined;
|
||||
state.record_header_text = undefined;
|
||||
state.record_text = [];
|
||||
state.help_page = -1;
|
||||
}
|
||||
|
||||
service_text(player, state, x, y, align, scale, text, colour)
|
||||
{
|
||||
elem = make_overlay_text(player, x, y, align, scale, colour);
|
||||
elem.alpha = 0;
|
||||
elem.archived = false;
|
||||
if (text != "")
|
||||
elem SetText(text);
|
||||
state.elems[state.elems.size] = elem;
|
||||
return elem;
|
||||
}
|
||||
|
||||
service_render(state)
|
||||
{
|
||||
if (state.closed)
|
||||
return;
|
||||
request = service_current(state);
|
||||
page = -1;
|
||||
generation = 0;
|
||||
if (isDefined(request))
|
||||
{
|
||||
page = request.page;
|
||||
generation = request.generation;
|
||||
}
|
||||
if (state.rendered && state.last_generation == generation && state.last_page == page)
|
||||
return;
|
||||
state.rendered = true;
|
||||
state.last_generation = generation;
|
||||
state.last_page = page;
|
||||
if (!isDefined(request))
|
||||
{
|
||||
service_view_destroy(state);
|
||||
return;
|
||||
}
|
||||
paged = request.pages > 1;
|
||||
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
|
||||
service_view_create(state, request);
|
||||
for (i = 0; i < state.elems.size; i++)
|
||||
state.elems[i].alpha = 0;
|
||||
|
||||
if (paged)
|
||||
{
|
||||
page_text = "" + (page + 1) + " / " + request.pages;
|
||||
if (!isDefined(state.page_text) || state.page_text != page_text)
|
||||
{
|
||||
state.page_counter SetText(page_text);
|
||||
state.page_text = page_text;
|
||||
}
|
||||
state.page_counter.alpha = 1;
|
||||
}
|
||||
if (request.kind == "help")
|
||||
{
|
||||
state.help_title.alpha = 1;
|
||||
for (i = 0; i < state.command_rows.size; i++)
|
||||
{
|
||||
index = page * level.hud_service_help_rows + i;
|
||||
if (index >= state.commands.size)
|
||||
continue;
|
||||
if (state.help_page != page)
|
||||
{
|
||||
entry = state.commands[index];
|
||||
state.command_rows[i] SetText(help_command_label(entry.command));
|
||||
state.description_rows[i] SetText(help_description(entry));
|
||||
}
|
||||
state.command_rows[i].alpha = 1;
|
||||
state.description_rows[i].alpha = 1;
|
||||
}
|
||||
state.help_page = page;
|
||||
return;
|
||||
}
|
||||
|
||||
header = "Map record unavailable";
|
||||
snapshot = request.snapshot;
|
||||
if (isDefined(snapshot))
|
||||
{
|
||||
header = "No recorded high round yet";
|
||||
if (snapshot.round > 0)
|
||||
header = "Map record | Round " + snapshot.round;
|
||||
}
|
||||
if (!isDefined(state.record_header_text) || state.record_header_text != header)
|
||||
{
|
||||
state.record_title SetText(header);
|
||||
state.record_header_text = header;
|
||||
}
|
||||
state.record_title.alpha = 1;
|
||||
if (!isDefined(snapshot) || snapshot.round == 0)
|
||||
return;
|
||||
if (request.kind == "reminder")
|
||||
{
|
||||
state.rows[0] SetText(request.message);
|
||||
state.rows[0].alpha = 1;
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
index = page * 3 + i;
|
||||
if (index < snapshot.lines.size)
|
||||
{
|
||||
text = snapshot.lines[index];
|
||||
if (!isDefined(state.record_text[i]) || state.record_text[i] != text)
|
||||
{
|
||||
state.rows[i] SetText(text);
|
||||
state.record_text[i] = text;
|
||||
}
|
||||
state.rows[i].alpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service_loop(state)
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_help_refresh");
|
||||
|
||||
wait 12;
|
||||
|
||||
for (i = 0; i < self.tws_help_elems.size; i++)
|
||||
level endon("tws_service_game_over");
|
||||
for (;;)
|
||||
{
|
||||
elem = self.tws_help_elems[i];
|
||||
elem.alpha = 0;
|
||||
service_advance(state, getTime());
|
||||
service_render(state);
|
||||
wait 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared dispatcher: one HUD instance and account load per connection.
|
||||
service_disconnect(state)
|
||||
{
|
||||
self waittill("disconnect");
|
||||
service_destroy(state);
|
||||
}
|
||||
|
||||
service_end_game(state)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level waittill("tws_service_game_over");
|
||||
service_destroy(state);
|
||||
}
|
||||
|
||||
service_destroy(state)
|
||||
{
|
||||
if (state.closed)
|
||||
return;
|
||||
state.closed = true;
|
||||
state.help = undefined;
|
||||
state.record = undefined;
|
||||
service_view_destroy(state);
|
||||
}
|
||||
|
||||
service_game_over_via(event)
|
||||
{
|
||||
level endon("tws_service_game_over");
|
||||
level waittill(event);
|
||||
level.tws_service_ended = true;
|
||||
level notify("tws_service_game_over");
|
||||
}
|
||||
|
||||
// The only connected listener; modules use the shared registries.
|
||||
on_player_connect_dispatch()
|
||||
{
|
||||
for (;;)
|
||||
@@ -213,7 +609,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
|
||||
elem.x = x;
|
||||
elem.y = y;
|
||||
elem.foreground = 1;
|
||||
elem.fontScale = scale;
|
||||
// Sub-1.0 text can render oversized on clients; keep a safe minimum.
|
||||
elem.fontScale = max(1.0, scale);
|
||||
elem.alpha = 1;
|
||||
elem.color = colour;
|
||||
|
||||
@@ -240,6 +637,147 @@ make_shader_elem(player, x, y)
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
|
||||
make_overlay_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_text_elem(player, x, y, align_x, scale, colour);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.sort = 20;
|
||||
elem.archived = true;
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_shader_elem(player, x, y);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.color = colour;
|
||||
elem.alpha = alpha;
|
||||
elem.sort = sort;
|
||||
elem.archived = true;
|
||||
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_overlay_width(width)
|
||||
{
|
||||
// Native max() returns a float; shader dimensions must be integers.
|
||||
return int(max(1, width));
|
||||
}
|
||||
|
||||
make_core_overlay_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_overlay_text(player, x, y, align_x, scale, colour);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
|
||||
// engine safe area. Horizontal conversion preserves the original size at 16:9;
|
||||
// other aspects retain the right-edge anchor with proportional horizontal size.
|
||||
make_panel_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
|
||||
level.hud_panel_top + y, align_x, scale, colour);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.sort = 20;
|
||||
elem.archived = true;
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_panel_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
|
||||
level.hud_panel_top + y);
|
||||
elem.horzAlign = "fullscreen";
|
||||
elem.vertAlign = "fullscreen";
|
||||
elem.color = colour;
|
||||
elem.alpha = alpha;
|
||||
elem.sort = sort;
|
||||
elem.archived = true;
|
||||
elem SetShader("white", make_panel_width(width), height);
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Native shader widths are integers; dynamic fills use the same conversion.
|
||||
make_panel_width(width)
|
||||
{
|
||||
// max() returns a float even for integer inputs; cast its final result.
|
||||
return int(max(1, width * level.hud_panel_x_scale));
|
||||
}
|
||||
|
||||
// Core elements share a visibility mask; services keep their independent alpha.
|
||||
make_core_text(player, x, y, align_x, scale, colour)
|
||||
{
|
||||
elem = make_panel_text(player, x, y, align_x, scale, colour);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
make_core_shader(player, x, y, width, height, colour, alpha, sort)
|
||||
{
|
||||
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
|
||||
register_core_elem(player, elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
register_core_elem(player, elem)
|
||||
{
|
||||
live = [];
|
||||
if (isDefined(player.tws_core_hud_elems))
|
||||
{
|
||||
for (i = 0; i < player.tws_core_hud_elems.size; i++)
|
||||
{
|
||||
old = player.tws_core_hud_elems[i];
|
||||
if (isDefined(old))
|
||||
live[live.size] = old;
|
||||
}
|
||||
}
|
||||
live[live.size] = elem;
|
||||
player.tws_core_hud_elems = live;
|
||||
set_core_alpha(player, elem, elem.alpha);
|
||||
}
|
||||
|
||||
set_core_alpha(player, elem, alpha)
|
||||
{
|
||||
if (!isDefined(elem))
|
||||
return;
|
||||
elem.tws_core_alpha = alpha;
|
||||
elem.alpha = alpha;
|
||||
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
|
||||
elem.alpha = 0;
|
||||
}
|
||||
|
||||
toggle_core_hud()
|
||||
{
|
||||
hidden = false;
|
||||
if (isDefined(self.tws_hud_hidden))
|
||||
hidden = self.tws_hud_hidden;
|
||||
self.tws_hud_hidden = !hidden;
|
||||
if (!isDefined(self.tws_core_hud_elems))
|
||||
return;
|
||||
live = [];
|
||||
for (i = 0; i < self.tws_core_hud_elems.size; i++)
|
||||
{
|
||||
elem = self.tws_core_hud_elems[i];
|
||||
if (!isDefined(elem))
|
||||
continue;
|
||||
set_core_alpha(self, elem, elem.tws_core_alpha);
|
||||
live[live.size] = elem;
|
||||
}
|
||||
self.tws_core_hud_elems = live;
|
||||
}
|
||||
|
||||
destroy_on_disconnect(elems)
|
||||
{
|
||||
self waittill("disconnect");
|
||||
|
||||
+134
-152
@@ -1,4 +1,4 @@
|
||||
/* tws_t6_hud.gsc - configurable HUD stack: timers, zombie count, health bar. */
|
||||
/* tws_t6_hud.gsc - minimal edge HUD, toggleable with .hud. */
|
||||
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
@@ -11,25 +11,108 @@ main()
|
||||
init()
|
||||
{
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
scripts\zm\tws_t6_common::register_help_command(".hud", "Toggle core HUD");
|
||||
|
||||
level thread round_watcher();
|
||||
level thread game_over_watcher();
|
||||
level thread hud_chat_listener();
|
||||
|
||||
scripts\zm\tws_t6_common::register_connect_handler(::hud_connect);
|
||||
scripts\zm\tws_t6_common::register_spawn_handler(::hud_start);
|
||||
}
|
||||
|
||||
hud_chat_listener()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
level waittill("say", message, player);
|
||||
if (!isDefined(player) || !isDefined(message))
|
||||
continue;
|
||||
if (toLower(message) == ".hud")
|
||||
player thread hud_toggle();
|
||||
}
|
||||
}
|
||||
|
||||
hud_toggle()
|
||||
{
|
||||
self hud_connect();
|
||||
self scripts\zm\tws_t6_common::toggle_core_hud();
|
||||
}
|
||||
|
||||
// Connection construction never seeds clocks or reads player gameplay state.
|
||||
// The first-spawn fallback also handles a spawn before the connect callback.
|
||||
hud_connect()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
if (isDefined(self.tws_hud_elems))
|
||||
return;
|
||||
|
||||
self.tws_hud_elems = [];
|
||||
self.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
|
||||
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
|
||||
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
|
||||
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
|
||||
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
|
||||
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES | discord.tws.lol");
|
||||
|
||||
// Center the entire readout on the track, regardless of the digit count.
|
||||
count_x = level.hud_overlay_bar_x + level.hud_overlay_bar_width / 2.0;
|
||||
self.tws_hud_count = self hud_text(count_x,
|
||||
level.hud_overlay_count_y, "center", 1.25, level.hud_c_count, "-- ZOMBIES");
|
||||
|
||||
// Independent numeric elements avoid a new config string on every tick.
|
||||
// The readout appears only after a valid damaged-health sample.
|
||||
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
|
||||
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
|
||||
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
|
||||
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
|
||||
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
|
||||
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
|
||||
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
|
||||
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_health, 0);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_slash, 0);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_hp, 0);
|
||||
|
||||
track = scripts\zm\tws_t6_common::make_core_overlay_shader(
|
||||
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
|
||||
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
|
||||
(0.18, 0.22, 0.19), 0.8, 10);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = track;
|
||||
self.tws_hud_bar = scripts\zm\tws_t6_common::make_core_overlay_shader(
|
||||
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
|
||||
level.hud_overlay_bar_width, level.hud_overlay_bar_height,
|
||||
level.hud_c_high, 0, 11);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar;
|
||||
|
||||
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(self.tws_hud_elems);
|
||||
}
|
||||
|
||||
hud_text(x, y, align, scale, colour, text)
|
||||
{
|
||||
elem = scripts\zm\tws_t6_common::make_core_overlay_text(self, x, y, align, scale, colour);
|
||||
elem SetText(text);
|
||||
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
|
||||
return elem;
|
||||
}
|
||||
|
||||
hud_start()
|
||||
{
|
||||
self endon("disconnect");
|
||||
if (isDefined(level.tws_ended) && level.tws_ended)
|
||||
|
||||
self hud_connect();
|
||||
if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended))
|
||||
return;
|
||||
|
||||
self.tws_hud_bound = true;
|
||||
self thread hud_timer_row();
|
||||
self thread hud_zombie_counter();
|
||||
self thread hud_health_display();
|
||||
}
|
||||
|
||||
// Same game-over notification fan-out as T5.
|
||||
// Race the map-dependent game-over notifications once at level scope.
|
||||
game_over_watcher()
|
||||
{
|
||||
level thread game_over_via("end_game");
|
||||
@@ -45,7 +128,7 @@ game_over_via(notify_name)
|
||||
level notify("hud_game_over");
|
||||
}
|
||||
|
||||
// Poll stock round state; the first player spawn seeds pre-set round 1.
|
||||
// Pre-set round 1 is seeded on first spawn; only observed transitions seed here.
|
||||
round_watcher()
|
||||
{
|
||||
level endon("hud_game_over");
|
||||
@@ -78,7 +161,7 @@ hud_timer_row()
|
||||
{
|
||||
self endon("disconnect");
|
||||
|
||||
// fallback seed for the round-1-already-active case (round_watcher misses it)
|
||||
// A visible HUD for a waiting newcomer must not start a pre-set round 1.
|
||||
if (!isDefined(level.match_start_time) &&
|
||||
isDefined(level.round_number) &&
|
||||
level.round_number >= 1)
|
||||
@@ -88,58 +171,14 @@ hud_timer_row()
|
||||
level notify("hud_match_started");
|
||||
}
|
||||
|
||||
match = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_match,
|
||||
"left", level.hud_font_small, level.hud_c_dim);
|
||||
match.alpha = 0; // revealed by match_timer_bind when round 1 opens
|
||||
|
||||
round = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_round,
|
||||
"left", level.hud_font_small, level.hud_c_dim);
|
||||
round = self.tws_hud_round;
|
||||
round SetTimerUp(0);
|
||||
if (isDefined(level.round_start_time))
|
||||
round SetTimerUp(max(0, (getTime() - level.round_start_time) / 1000.0));
|
||||
|
||||
// name only shows in co-op — your own name is noise in solo
|
||||
name = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_name,
|
||||
"left", level.hud_font_small, level.hud_c_name);
|
||||
|
||||
if (isDefined(self.name))
|
||||
name SetText(self.name);
|
||||
else
|
||||
name SetText(self.playername);
|
||||
|
||||
name.alpha = 0;
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = match;
|
||||
elems[elems.size] = round;
|
||||
elems[elems.size] = name;
|
||||
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
|
||||
|
||||
self thread reset_timer_each_round(round);
|
||||
self thread match_timer_bind(match);
|
||||
self thread freeze_timers_on_game_over(match, round);
|
||||
|
||||
coop = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
is_coop = 0;
|
||||
|
||||
players = getPlayers();
|
||||
if (players.size > 1)
|
||||
is_coop = 1;
|
||||
|
||||
if (is_coop != coop)
|
||||
{
|
||||
name.alpha = is_coop;
|
||||
coop = is_coop;
|
||||
}
|
||||
|
||||
wait (1);
|
||||
}
|
||||
self thread match_timer_bind(self.tws_hud_match);
|
||||
self thread freeze_timers_on_game_over(self.tws_hud_match, round);
|
||||
}
|
||||
|
||||
reset_timer_each_round(timer)
|
||||
@@ -156,26 +195,20 @@ reset_timer_each_round(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// T6 timer seed includes elapsed time for late joiners.
|
||||
// T6 accepts elapsed seconds, including time before a late joiner's spawn.
|
||||
match_timer_bind(elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
if (!isDefined(level.match_start_time))
|
||||
level waittill("hud_match_started");
|
||||
|
||||
if (isDefined(elem))
|
||||
{
|
||||
elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0));
|
||||
elem.alpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Level-scope trigger only — per-player death/health polling is intercepted
|
||||
by the zombiemode scripts. Freeze value math relies on the timers being
|
||||
anchored at first spawn / first round transition, not level init.
|
||||
*/
|
||||
// Preserve authoritative frozen clocks when the core is shown after game over.
|
||||
freeze_timers_on_game_over(match_elem, round_elem)
|
||||
{
|
||||
self endon("disconnect");
|
||||
@@ -191,22 +224,9 @@ freeze_timers_on_game_over(match_elem, round_elem)
|
||||
hud_zombie_counter()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
label = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, level.hud_row_count,
|
||||
"left", level.hud_font_large, level.hud_c_label);
|
||||
label SetText("Zombies");
|
||||
|
||||
count = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_col_count, level.hud_row_count,
|
||||
"left", level.hud_font_large, level.hud_c_count);
|
||||
count SetValue(0);
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = label;
|
||||
elems[elems.size] = count;
|
||||
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
|
||||
|
||||
count = self.tws_hud_count;
|
||||
last = -1;
|
||||
|
||||
for (;;)
|
||||
@@ -219,7 +239,8 @@ hud_zombie_counter()
|
||||
|
||||
if (remaining != last)
|
||||
{
|
||||
count SetValue(remaining);
|
||||
// Plutonium r5334+: reuse text storage as the count changes.
|
||||
count SetTextUnlimited(int(remaining) + " ZOMBIES");
|
||||
last = remaining;
|
||||
}
|
||||
|
||||
@@ -230,108 +251,69 @@ hud_zombie_counter()
|
||||
hud_health_display()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("hud_game_over");
|
||||
|
||||
bar_width = level.hud_bar_width;
|
||||
|
||||
// -2 on both axes so the back frames the bar with 2px padding
|
||||
back = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2);
|
||||
back.alpha = 0.5;
|
||||
back.color = (0, 0, 0);
|
||||
back SetShader("white", bar_width + 4, level.hud_bar_height + 4);
|
||||
|
||||
bar = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x, level.hud_row_bar);
|
||||
bar.color = level.hud_c_high;
|
||||
bar SetShader("white", bar_width, level.hud_bar_height);
|
||||
|
||||
/*
|
||||
"87/160" is three elements, not a rebuilt string. SetText allocates
|
||||
a config string per call; per-tick rebuilds crash the server.
|
||||
cur is right-aligned so digits grow leftward and the slash + max
|
||||
never shift.
|
||||
*/
|
||||
cur = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_col_health + 26, level.hud_row_bar + 3,
|
||||
"right", level.hud_font_mid, level.hud_c_high);
|
||||
cur SetValue(self.health);
|
||||
|
||||
slash = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_col_health + 30, level.hud_row_bar + 3,
|
||||
"center", level.hud_font_mid, level.hud_c_high);
|
||||
slash SetText("/");
|
||||
|
||||
max_hp = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_col_health + 34, level.hud_row_bar + 3,
|
||||
"left", level.hud_font_mid, level.hud_c_high);
|
||||
max_hp SetValue(100);
|
||||
|
||||
cur.alpha = 0;
|
||||
slash.alpha = 0;
|
||||
max_hp.alpha = 0;
|
||||
|
||||
elems = [];
|
||||
elems[elems.size] = back;
|
||||
elems[elems.size] = bar;
|
||||
elems[elems.size] = cur;
|
||||
elems[elems.size] = slash;
|
||||
elems[elems.size] = max_hp;
|
||||
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
|
||||
|
||||
last_width = bar_width;
|
||||
bar_width = level.hud_overlay_bar_width;
|
||||
bar = self.tws_hud_bar;
|
||||
cur = self.tws_hud_health;
|
||||
slash = self.tws_hud_slash;
|
||||
max_hp = self.tws_hud_maxhealth;
|
||||
hp = self.tws_hud_hp;
|
||||
last_width = -1;
|
||||
last_health = -1;
|
||||
last_max = -1;
|
||||
last_shown = -1;
|
||||
last_max = -1;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
// Display fallbacks must not alter gameplay health values.
|
||||
maximum = 100;
|
||||
if (isDefined(self.maxhealth) && self.maxhealth > 0)
|
||||
maximum = self.maxhealth;
|
||||
frac = self.health / (maximum * 1.0);
|
||||
current = 0;
|
||||
if (isDefined(self.health))
|
||||
current = max(0, self.health);
|
||||
frac = current / (maximum * 1.0);
|
||||
|
||||
if (frac > 1)
|
||||
frac = 1;
|
||||
|
||||
if (frac < 0)
|
||||
frac = 0;
|
||||
|
||||
width = int(max(frac * bar_width, 1));
|
||||
|
||||
width = scripts\zm\tws_t6_common::make_overlay_width(frac * bar_width);
|
||||
if (width != last_width)
|
||||
{
|
||||
bar SetShader("white", width, level.hud_bar_height);
|
||||
bar SetShader("white", width, level.hud_overlay_bar_height);
|
||||
last_width = width;
|
||||
}
|
||||
|
||||
colour = level.hud_c_high;
|
||||
bar_alpha = 1;
|
||||
if (frac == 0)
|
||||
bar_alpha = 0;
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, bar, bar_alpha);
|
||||
|
||||
colour = level.hud_c_high;
|
||||
if (frac <= 0.25)
|
||||
colour = level.hud_c_low;
|
||||
else if (frac <= 0.5)
|
||||
colour = level.hud_c_mid;
|
||||
|
||||
bar.color = colour;
|
||||
cur.color = colour;
|
||||
slash.color = colour;
|
||||
bar.color = colour;
|
||||
cur.color = colour;
|
||||
slash.color = colour;
|
||||
max_hp.color = colour;
|
||||
hp.color = colour;
|
||||
|
||||
// numbers only show once you're hurt
|
||||
show = 0;
|
||||
readout_alpha = 0;
|
||||
if (isDefined(self.health) && current < maximum)
|
||||
readout_alpha = 1;
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, cur, readout_alpha);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, slash, readout_alpha);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, max_hp, readout_alpha);
|
||||
scripts\zm\tws_t6_common::set_core_alpha(self, hp, readout_alpha);
|
||||
|
||||
if (frac < level.hud_health_threshold)
|
||||
show = 1;
|
||||
|
||||
if (show != last_shown)
|
||||
// Keep health numbers current even at full health or while hidden.
|
||||
if (current != last_health)
|
||||
{
|
||||
cur.alpha = show;
|
||||
slash.alpha = show;
|
||||
max_hp.alpha = show;
|
||||
last_shown = show;
|
||||
}
|
||||
|
||||
if (self.health != last_health)
|
||||
{
|
||||
cur SetValue(self.health);
|
||||
last_health = self.health;
|
||||
cur SetValue(current);
|
||||
last_health = current;
|
||||
}
|
||||
|
||||
if (maximum != last_max)
|
||||
|
||||
@@ -20,9 +20,44 @@ init()
|
||||
level thread join_chat_listener();
|
||||
}
|
||||
|
||||
// Never reset this on death or round change. Spectator spawns do not count.
|
||||
// T6 first emits spawned_player during setup, then moves late connectors
|
||||
// into spectator mode after 0.05 seconds. Observe the settled state instead.
|
||||
// The common spawn dispatcher runs only once, so keep listening ourselves.
|
||||
join_spawn()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
self thread join_track_death();
|
||||
|
||||
for (;;)
|
||||
{
|
||||
self.tws_join_spawn_pending = true;
|
||||
wait 0.1;
|
||||
|
||||
if (isDefined(self.sessionstate) && self.sessionstate == "playing")
|
||||
self.tws_join_has_played = true;
|
||||
|
||||
self.tws_join_spawn_pending = false;
|
||||
if (is_true(self.tws_join_has_played))
|
||||
return;
|
||||
|
||||
self waittill("spawned_player");
|
||||
}
|
||||
}
|
||||
|
||||
// A real death during the settling window still consumes newcomer eligibility.
|
||||
// Never clear this flag on death, a round change, or a subsequent spawn.
|
||||
join_track_death()
|
||||
{
|
||||
self endon("disconnect");
|
||||
level endon("end_game");
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
self waittill("killed_player");
|
||||
self.tws_join_has_played = true;
|
||||
}
|
||||
|
||||
@@ -51,8 +86,7 @@ join_request()
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
// The spawn handler flag is set on first playable spawn only; spectator
|
||||
// spawns do not trigger it. Belt-and-suspenders against re-triggering.
|
||||
// Setup spawns do not consume eligibility; actual play and .join do.
|
||||
if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used))
|
||||
{
|
||||
self iPrintLnBold("^3.join is only for newcomers who have not played yet");
|
||||
@@ -62,7 +96,8 @@ join_request()
|
||||
// Fail closed while the stock connection setup is still in progress.
|
||||
// T6 stock does not populate self.initialized the way T5 SP zombiemode
|
||||
// does; use sessionstate as the readiness signal.
|
||||
if (!isDefined(self.sessionstate) || self.sessionstate != "spectator")
|
||||
if (is_true(self.tws_join_spawn_pending) ||
|
||||
!isDefined(self.sessionstate) || self.sessionstate != "spectator")
|
||||
{
|
||||
self iPrintLnBold("^3.join is only available while waiting to enter the game");
|
||||
return;
|
||||
|
||||
+158
-113
@@ -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 common_scripts\utility;
|
||||
@@ -26,10 +26,12 @@ init()
|
||||
level.tws_lb_pending = undefined;
|
||||
level.tws_lb_cache = undefined;
|
||||
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_connect_handler(::lb_welcome);
|
||||
level thread lb_chat_listener();
|
||||
level thread lb_worker();
|
||||
level thread lb_round_watcher();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
self endon("disconnect");
|
||||
@@ -127,120 +211,18 @@ lb_request(manual)
|
||||
level endon("game_ended");
|
||||
level endon("intermission");
|
||||
|
||||
snapshot = lb_cached_record();
|
||||
if (!isDefined(snapshot))
|
||||
{
|
||||
if (manual)
|
||||
self iPrintLnBold("^3Map record unavailable; try .leaders again shortly");
|
||||
return;
|
||||
}
|
||||
// A manual replay replaces any welcome announcement still waiting to run.
|
||||
// This also applies when the cache is unavailable.
|
||||
if (manual)
|
||||
self notify("tws_lb_requested");
|
||||
self lb_show_hud(snapshot);
|
||||
}
|
||||
self.tws_lb_last_request = getTime();
|
||||
snapshot = lb_cached_record();
|
||||
if (!isDefined(snapshot) && !manual)
|
||||
return;
|
||||
|
||||
lb_show_hud(snapshot)
|
||||
{
|
||||
// Stop the previous display and its cleanup threads before replacing it.
|
||||
self notify("tws_lb_hud_done");
|
||||
if (isDefined(self.tws_lb_elems))
|
||||
lb_hud_destroy(self.tws_lb_elems);
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
// Match .help's position, typography and row spacing below the health bar.
|
||||
y = level.hud_row_bar + level.hud_bar_height + 20;
|
||||
elems = [];
|
||||
title = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk);
|
||||
title.alpha = 0;
|
||||
if (snapshot.round == 0)
|
||||
title SetText("No recorded high round yet");
|
||||
else
|
||||
title SetText("Record: Round " + snapshot.round);
|
||||
elems[elems.size] = title;
|
||||
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = scripts\zm\tws_t6_common::make_text_elem(
|
||||
self, level.hud_x, y + 22 + i * 18,
|
||||
"left", level.hud_font_small, level.hud_c_count);
|
||||
row.alpha = 0;
|
||||
elems[elems.size] = row;
|
||||
}
|
||||
|
||||
// Cleanup runs independently of the display and ends when it is replaced.
|
||||
self.tws_lb_elems = elems;
|
||||
self thread lb_hud_disconnect(elems);
|
||||
self thread lb_hud_end_game(elems, "end_game");
|
||||
self thread lb_hud_end_game(elems, "game_ended");
|
||||
self thread lb_hud_end_game(elems, "intermission");
|
||||
|
||||
offset = 0;
|
||||
for (;;)
|
||||
{
|
||||
// Text changes only once per page, never during the visibility loop.
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
row = elems[i + 1];
|
||||
if (offset + i < snapshot.lines.size)
|
||||
row SetText("^3Held by: ^7" + snapshot.lines[offset + i]);
|
||||
else
|
||||
row SetText("");
|
||||
}
|
||||
|
||||
remaining = 12;
|
||||
while (remaining > 0)
|
||||
{
|
||||
visible = !self lb_hud_help_visible();
|
||||
for (i = 0; i < elems.size; i++)
|
||||
elems[i].alpha = visible;
|
||||
wait 0.1;
|
||||
// Pause the page while .help occupies the same area.
|
||||
if (visible)
|
||||
remaining -= 0.1;
|
||||
}
|
||||
offset += 3;
|
||||
if (offset >= snapshot.lines.size)
|
||||
break;
|
||||
}
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_help_visible()
|
||||
{
|
||||
return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 &&
|
||||
isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0;
|
||||
}
|
||||
|
||||
lb_hud_disconnect(elems)
|
||||
{
|
||||
self endon("tws_lb_hud_done");
|
||||
self waittill("disconnect");
|
||||
lb_hud_destroy(elems);
|
||||
}
|
||||
|
||||
lb_hud_end_game(elems, event)
|
||||
{
|
||||
self endon("disconnect");
|
||||
self endon("tws_lb_hud_done");
|
||||
level waittill(event);
|
||||
lb_hud_destroy(elems);
|
||||
self.tws_lb_elems = undefined;
|
||||
self notify("tws_lb_hud_done");
|
||||
}
|
||||
|
||||
lb_hud_destroy(elems)
|
||||
{
|
||||
for (i = 0; i < elems.size; i++)
|
||||
{
|
||||
if (isDefined(elems[i]))
|
||||
elems[i] Destroy();
|
||||
}
|
||||
// Common owns pagination, help priority and the single service window.
|
||||
// Undefined snapshots produce a private unavailable page for manual replay.
|
||||
self scripts\zm\tws_t6_common::show_record(snapshot);
|
||||
}
|
||||
|
||||
lb_refresh()
|
||||
@@ -529,22 +511,85 @@ lb_holder_lines(names)
|
||||
{
|
||||
lines = [];
|
||||
line = "";
|
||||
columns = 0;
|
||||
for (i = 0; i < names.size; i++)
|
||||
{
|
||||
if (line != "" && line.size + names[i].size + 2 > 100)
|
||||
name = lb_holder_name(names[i]);
|
||||
width = lb_holder_width(name);
|
||||
if (line != "" && columns + width + 2 > 16)
|
||||
{
|
||||
lines[lines.size] = line;
|
||||
line = "";
|
||||
columns = 0;
|
||||
}
|
||||
if (line != "")
|
||||
{
|
||||
line += ", ";
|
||||
line += names[i];
|
||||
columns += 2;
|
||||
}
|
||||
line += name;
|
||||
columns += width;
|
||||
}
|
||||
if (line != "")
|
||||
lines[lines.size] = line;
|
||||
return lines;
|
||||
}
|
||||
|
||||
lb_holder_name(value)
|
||||
{
|
||||
// The service has 200 virtual units at font scale 1.1. Use a conservative
|
||||
// 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics.
|
||||
// A long name gets one bounded entry; no tied holder is dropped.
|
||||
value = lb_display_text(value, 64);
|
||||
if (value == "")
|
||||
value = "Unknown player";
|
||||
if (lb_holder_width(value) <= 16)
|
||||
return value;
|
||||
|
||||
output = "";
|
||||
columns = 0;
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
|
||||
width = 1;
|
||||
if (code >= 128)
|
||||
width = 2;
|
||||
if (columns + width > 13)
|
||||
break;
|
||||
count = lb_glyph_bytes(code);
|
||||
output += getSubStr(value, i, i + count);
|
||||
columns += width;
|
||||
i += count - 1;
|
||||
}
|
||||
return output + "...";
|
||||
}
|
||||
|
||||
lb_holder_width(value)
|
||||
{
|
||||
columns = 0;
|
||||
for (i = 0; i < value.size; i++)
|
||||
{
|
||||
code = level.tws_lb_bytes[getSubStr(value, i, i + 1)];
|
||||
if (code >= 128)
|
||||
columns += 2;
|
||||
else
|
||||
columns++;
|
||||
i += lb_glyph_bytes(code) - 1;
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
lb_glyph_bytes(code)
|
||||
{
|
||||
if (code >= 240)
|
||||
return 4;
|
||||
if (code >= 224)
|
||||
return 3;
|
||||
if (code >= 192)
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
lb_display_text(value, limit)
|
||||
{
|
||||
output = "";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
|
||||
#include maps\mp\_utility;
|
||||
#include common_scripts\utility;
|
||||
// T6 loader probes main and init; initialize only once.
|
||||
main()
|
||||
{
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
|
||||
scripts\zm\tws_t6_common::ensure_config();
|
||||
level.moderation_ready = false;
|
||||
scripts\zm\tws_t6_common::register_connect_handler(::moderation_connected);
|
||||
level thread moderation_loop();
|
||||
}
|
||||
|
||||
moderation_connected()
|
||||
{
|
||||
self endon("disconnect");
|
||||
if (!level.moderation_ready)
|
||||
return;
|
||||
moderation_observe(self);
|
||||
moderationPump();
|
||||
}
|
||||
|
||||
moderation_observe(player)
|
||||
{
|
||||
if (!isDefined(player))
|
||||
return;
|
||||
guid = player.guid;
|
||||
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
||||
return;
|
||||
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
|
||||
guid = "" + guid;
|
||||
name = player.name;
|
||||
if (!isDefined(name))
|
||||
name = "Unknown player";
|
||||
player moderationObserve(guid, name);
|
||||
}
|
||||
|
||||
moderation_loop()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
if (GetDvar("tws_moderation_enabled") == "0")
|
||||
{
|
||||
moderationStop();
|
||||
level.moderation_ready = false;
|
||||
wait 3;
|
||||
continue;
|
||||
}
|
||||
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
|
||||
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
|
||||
if (!level.moderation_ready)
|
||||
{
|
||||
wait 3;
|
||||
continue;
|
||||
}
|
||||
players = getPlayers();
|
||||
for (i = 0; i < players.size; i++)
|
||||
moderation_observe(players[i]);
|
||||
moderationPump();
|
||||
wait 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
|
||||
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const root = new URL('../', import.meta.url);
|
||||
|
||||
function compile(source) {
|
||||
const strings=[];
|
||||
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
|
||||
if(token.startsWith('/')) return '';
|
||||
strings.push(token); return `__str_${strings.length-1}__`;
|
||||
});
|
||||
const compiled = [];
|
||||
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||
for(let match; (match=headers.exec(source));) {
|
||||
let end=headers.lastIndex, depth=1;
|
||||
while(depth && end<source.length) {
|
||||
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
|
||||
}
|
||||
assert.equal(depth,0);
|
||||
let body=source.slice(headers.lastIndex,end-1);
|
||||
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
|
||||
body=body.replace(/#.?(?=__str_)/g,'');
|
||||
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
|
||||
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
|
||||
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
|
||||
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
|
||||
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
|
||||
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
|
||||
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
|
||||
body=body.replace(/player getGuid\(\)/g,'player.guid');
|
||||
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
|
||||
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
|
||||
body=body.replace(/level notify\(/g,'level.notify(');
|
||||
body=body.replace(/::(afk_\w+)/g,'$1');
|
||||
body=body.replace(/http::request/g,'httpPost');
|
||||
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
|
||||
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
|
||||
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
|
||||
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
|
||||
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
|
||||
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
|
||||
if(match[1]==='afk_register_dogs')continue;
|
||||
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
|
||||
}
|
||||
return compiled.join('\n');
|
||||
}
|
||||
|
||||
function engine(game, options={}) {
|
||||
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
|
||||
let now=100000, wall=100000, serial=0;
|
||||
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
|
||||
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
|
||||
const players=[],zombies=[];
|
||||
const c=vm.createContext({level,
|
||||
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
|
||||
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
|
||||
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
|
||||
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
|
||||
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
|
||||
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
|
||||
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
|
||||
telemetryNewMatch:()=>`id-${++serial}`,
|
||||
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
|
||||
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
|
||||
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
|
||||
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
|
||||
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
|
||||
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
|
||||
afkRemove:(_s,_u,id)=>saved.delete(id),
|
||||
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
|
||||
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
|
||||
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
|
||||
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
|
||||
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
|
||||
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
|
||||
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
|
||||
httpComplete:request=>{options.during?.(request,c);const d=request.data;
|
||||
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
|
||||
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
|
||||
afkResponse:JSON.parse,
|
||||
});
|
||||
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
|
||||
function player(guid='18446744073709551615') {
|
||||
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
|
||||
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
|
||||
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
|
||||
p.getPlayerAngles=()=>p.angles;
|
||||
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
|
||||
players.push(p);p.afk_spawn();return p;
|
||||
}
|
||||
const p=player(), other=player('other');
|
||||
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
|
||||
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
|
||||
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
|
||||
};
|
||||
}
|
||||
let checks=0;
|
||||
for(const game of ['t5','t6']) {
|
||||
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
|
||||
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
|
||||
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
|
||||
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
|
||||
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
|
||||
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
|
||||
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
|
||||
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
|
||||
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
|
||||
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
|
||||
});
|
||||
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
|
||||
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
|
||||
});
|
||||
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
|
||||
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
|
||||
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
|
||||
});
|
||||
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
|
||||
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
|
||||
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
|
||||
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
|
||||
assert.equal(e.p.afk_remaining(),1799000);
|
||||
});
|
||||
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
|
||||
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
|
||||
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
|
||||
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
|
||||
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
|
||||
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
|
||||
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
|
||||
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
|
||||
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
|
||||
assert.equal(e.p.sessionstate,'playing');
|
||||
});
|
||||
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
|
||||
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
|
||||
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
|
||||
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
|
||||
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
|
||||
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
|
||||
});
|
||||
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
|
||||
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
|
||||
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
|
||||
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
|
||||
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
|
||||
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
|
||||
});
|
||||
test('manual command at zero cannot create a grace period',()=>{
|
||||
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
|
||||
});
|
||||
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
|
||||
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
|
||||
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
|
||||
});
|
||||
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
|
||||
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
|
||||
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
|
||||
}
|
||||
});
|
||||
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
|
||||
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
|
||||
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
|
||||
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('));
|
||||
});
|
||||
test('solo and last active players can enter with unavailable or pending teammates',()=>{
|
||||
for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){
|
||||
const e=engine(game);
|
||||
if(flags===null)e.players.splice(1,1);
|
||||
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',()=>{
|
||||
const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next();
|
||||
for(let i=0;i<40;i++)watcher.next(e.p);
|
||||
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
|
||||
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
|
||||
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
|
||||
});
|
||||
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
|
||||
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
|
||||
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
|
||||
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
|
||||
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
|
||||
assert(!e.source.includes('.score'));
|
||||
});
|
||||
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
|
||||
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
|
||||
});
|
||||
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
|
||||
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
|
||||
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
|
||||
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
|
||||
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
|
||||
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
|
||||
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
|
||||
});
|
||||
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
|
||||
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
|
||||
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
|
||||
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
|
||||
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
|
||||
});
|
||||
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
|
||||
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
|
||||
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
|
||||
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
|
||||
});
|
||||
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
|
||||
for(const at of ['reserve','start']){
|
||||
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
|
||||
}
|
||||
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
|
||||
});
|
||||
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
|
||||
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
|
||||
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
|
||||
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
|
||||
});
|
||||
}
|
||||
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);
|
||||
@@ -0,0 +1,392 @@
|
||||
// Actual GSC transfer logic under portable VM/HTTP/disk shims; no game runtime.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { loadEcBank } from './tests/ec_bank_contract.mjs';
|
||||
const root = new URL('../', import.meta.url);
|
||||
const publicEndpoint = 'https://ec.thewrightserver.net/api/bank';
|
||||
const privateToken = 'ec_bank_' + 'a'.repeat(43);
|
||||
const ecIndex = process.argv.indexOf('--ec-root');
|
||||
const realEc = ecIndex < 0 ? undefined : loadEcBank(process.argv[ecIndex + 1]);
|
||||
|
||||
// EC src/lib/server/fleet/bank.ts token/transfer contract, also checked against
|
||||
// that actual implementation with --ec-root (no HTTP or production database).
|
||||
function assertContract(action, data) {
|
||||
const token = value => typeof value === 'string' && value.length > 0 && value.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value);
|
||||
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min && value <= 1000000;
|
||||
assert.equal(data.server, 'server');
|
||||
assert(token(data.guid), 'EC requires a nonempty string GUID token');
|
||||
if (action === 'balance') return;
|
||||
assert(token(data.request_id)); assert(token(data.match));
|
||||
assert(['deposit','withdraw'].includes(data.operation));
|
||||
assert(integer(data.amount, 1) || (data.operation === 'withdraw' && data.amount === 'all'));
|
||||
assert(integer(data.capacity));
|
||||
assert(['commit','cancel'].includes(data.decision));
|
||||
if (action === 'deposit' || action === 'withdraw') assert.equal(action, data.operation);
|
||||
else assert.equal(action, data.decision);
|
||||
}
|
||||
function functions(source) {
|
||||
const literals = [];
|
||||
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
|
||||
if (token.startsWith('/')) return '';
|
||||
literals.push(token);
|
||||
return `__literal_${literals.length - 1}__`;
|
||||
});
|
||||
const found = new Map();
|
||||
const headers = /^(init|bank_\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||
for (let match; (match = headers.exec(protectedSource));) {
|
||||
let end = headers.lastIndex, depth = 1;
|
||||
while (depth && end < protectedSource.length) {
|
||||
const ch = protectedSource[end++];
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') depth--;
|
||||
}
|
||||
assert.equal(depth, 0);
|
||||
let body = protectedSource.slice(headers.lastIndex, end - 1);
|
||||
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
|
||||
body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost');
|
||||
body = body.replace(/player getGuid\(\)/g, 'getGuid(player)');
|
||||
body = body.replace(/typeof\(/g, 'gscType(');
|
||||
body = body.replace(/#(?=__literal_)/g, '');
|
||||
body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::register_help_command/g, 'registerHelpCommand');
|
||||
body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify(');
|
||||
body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];');
|
||||
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;');
|
||||
body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;');
|
||||
body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);');
|
||||
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
|
||||
body = body.replace(/player maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g, 'player.downed').replace(/wait ([^;]+);/g, 'yield $1;');
|
||||
const generator = match[1] === 'bank_sender';
|
||||
found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function engine(game, options = {}) {
|
||||
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_bank.gsc`, root), 'utf8');
|
||||
const excluded = new Set(['bank_chat_listener','bank_end_watcher','bank_guard','bank_say','bank_request']);
|
||||
const functionsByName = functions(source);
|
||||
if (game === 't6') assert(!/\bgetGuid\s*\(/i.test(source), 'T6 bank must use the persistent field, as telemetry does');
|
||||
const identity = Object.hasOwn(options, 'guid') ? options.guid : '18446744073709551615';
|
||||
const player = { sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true };
|
||||
if (game === 't6') player.guid = identity;
|
||||
const entry = { player, active: true };
|
||||
const recovered = (options.recovered ?? []).map(report => ({...report}));
|
||||
const saved = recovered.map(report => JSON.parse(report.body)), writes = [], sent = [], messages = [], order = [], loaded = [], dvarsRead = [], logs = [];
|
||||
let serial = 0, now = 1000;
|
||||
const dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars };
|
||||
function assertScope(server, url) {
|
||||
assert.equal(server, dvars.tws_server_id);
|
||||
assert.equal(url, publicEndpoint);
|
||||
}
|
||||
const c = vm.createContext({
|
||||
level: { telemetry_match: 'match', telemetry_started: true },
|
||||
GetDvar: name => { dvarsRead.push(name); return dvars[name] ?? ''; },
|
||||
getSubStr: (value, start, end) => value.substring(start, end), registerHelpCommand: () => {},
|
||||
isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive,
|
||||
int: Math.trunc, getTime: () => now, strTok: (text, delimiter) => text.split(delimiter),
|
||||
bankVersion: () => "1.2.0",
|
||||
...(game === 't5' ? {getGuid: p => { assert.equal(p, player); return identity; }} : {}),
|
||||
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : value && typeof value === 'object' ? 'array' : typeof value,
|
||||
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
|
||||
bank_say: (_entry, message) => messages.push(message),
|
||||
telemetryNewMatch: () => `${options.idPrefix ?? 'request'}-${++serial}`,
|
||||
bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return options.loadFailure ? undefined : recovered; },
|
||||
bankStore: (server, url, body) => {
|
||||
assertScope(server, url);
|
||||
const data = JSON.parse(body); assertContract(data.decision, data);
|
||||
writes.push(data); order.push(data.decision);
|
||||
if (options.storageFailure === data.decision) return false;
|
||||
saved.splice(0, saved.length, data); return true;
|
||||
},
|
||||
bankRemove: (server, url) => { assertScope(server, url); if (options.removeFailure) return false; saved.length = 0; return true; },
|
||||
httpPost: (url, data) => {
|
||||
assertContract(url.split('/').at(-1), JSON.parse(data.body));
|
||||
order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {};
|
||||
},
|
||||
httpComplete: () => {
|
||||
options.during?.(c, entry);
|
||||
const action = sent.at(-1).url.split('/').at(-1);
|
||||
if (options.receiver) {
|
||||
const response = options.receiver(action, JSON.parse(sent.at(-1).body));
|
||||
return [JSON.stringify(response.body), !options.timeout, '', {http_status: response.status}];
|
||||
}
|
||||
const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared';
|
||||
const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : JSON.parse(sent.at(-1).body).request_id, amount: options.amount ?? 2000, balance: options.balance ?? 5000, code: options.code ?? 'ok' };
|
||||
return [options.missingResponse ? undefined : JSON.stringify(reply), !options.timeout, privateToken, options.missingDiagnostics ? undefined : options.diagnostics ?? { http_status: options.status ?? 200, curl_code: options.timeout ? 28 : 0, dns_ms:1, connect_ms:2, tls_ms:3, total_ms:options.timeout ? 15000 : 4 }];
|
||||
},
|
||||
bankResponse: data => options.malformed ? { valid: false, reason: options.reason ?? "invalid_json" } : {valid:true, ...JSON.parse(data)},
|
||||
print: message => logs.push(message), println: message => logs.push(message)
|
||||
});
|
||||
vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
|
||||
c.init();
|
||||
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, logs, advance: ms => { now += ms; }, run: args => c.bank_transfer(entry,args) };
|
||||
}
|
||||
let checks = 0;
|
||||
function journalEntries(engine) {
|
||||
// GSC associative arrays do not serialize as JS arrays. Copy the native
|
||||
// bankLoad shape while retaining the exact serialized decision body.
|
||||
return Array.from(engine.c.level.bank_queue, entry => ({id:entry.id, body:entry.body, decision:entry.decision}));
|
||||
}
|
||||
for (const game of ['t5','t6']) {
|
||||
function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } }
|
||||
test('engine identities remain exact strings in balance, prepare, both decisions and restart replay', () => {
|
||||
for (const guid of [2741626, 0, '2741626', '0002741626', '18446744073709551615']) {
|
||||
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
|
||||
for (const timeout of [false, true]) {
|
||||
const e = engine(game, {guid, timeout}); e.run(command);
|
||||
assert.equal(e.sent.length, 1);
|
||||
for (const data of [...e.writes, ...e.sent.map(request => JSON.parse(request.body))]) assert.equal(data.guid, String(guid));
|
||||
if (command[0] === '.b') { assert.equal(e.writes.length, 0); continue; }
|
||||
assert.equal(e.writes[0].decision, 'cancel');
|
||||
assert.equal(e.saved[0].decision, timeout ? 'cancel' : 'commit');
|
||||
const recovered = journalEntries(e);
|
||||
const restarted = engine(game, {guid:undefined, recovered});
|
||||
restarted.c.bank_sender().next();
|
||||
assert.equal(restarted.sent[0].body, recovered[0].body);
|
||||
assert.equal(JSON.parse(restarted.sent[0].body).guid, String(guid));
|
||||
assert.equal(restarted.saved.length, 0);
|
||||
assert.equal(restarted.player.score, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
test('missing or invalid identities reject before HTTP, journal writes or point changes', () => {
|
||||
for (const guid of [undefined, null, '', ' ', 'bad guid', 'x'.repeat(65), 'x\n', true, {}, [], 1.5]) {
|
||||
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
|
||||
const e = engine(game, {guid}); e.run(command);
|
||||
assert.match(e.messages[0], /player identity unavailable/);
|
||||
assert.equal(e.sent.length, 0); assert.equal(e.writes.length, 0);
|
||||
assert.equal(e.c.level.bank_write, 0); assert.equal(e.player.score, 3000);
|
||||
assert.equal(e.player.score_total, 4000);
|
||||
}
|
||||
}
|
||||
const e = engine(game); e.entry.player = undefined; e.run(['.b']);
|
||||
assert.match(e.messages[0], /player identity unavailable/); assert.equal(e.sent.length, 0);
|
||||
});
|
||||
test('startup and every request ignore obsolete endpoint overrides and retain instance authentication', () => {
|
||||
for (const url of ['', 'https://controller.internal/api/bank', 'http://old-controller/api/bank', 'https://untrusted.example/api/bank']) {
|
||||
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
|
||||
const e = engine(game, { dvars: { tws_bank_url: url } });
|
||||
e.run(command);
|
||||
if (command[0] !== '.b') e.c.bank_sender().next();
|
||||
assert.equal(e.loaded.length, 1);
|
||||
assert(!e.dvarsRead.includes('tws_bank_url'));
|
||||
assert.equal(e.sent[0].url, publicEndpoint + '/' + ({'.b':'balance','.d':'deposit','.w':'withdraw'}[command[0]]));
|
||||
if (command[0] !== '.b') assert.equal(e.sent[1].url, publicEndpoint + '/commit');
|
||||
for (const request of e.sent) {
|
||||
assert.equal(request.headers.Authorization, 'Bearer ' + privateToken);
|
||||
assert.equal(JSON.parse(request.body).server, 'server');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
test('missing or invalid instance credentials and disabled banks do not load journals', () => {
|
||||
for (const dvars of [{tws_server_id:''}, {tws_bank_token:''}, {tws_bank_token:'wrong_' + 'a'.repeat(43)}, {tws_bank_enabled:'0'}]) {
|
||||
assert.equal(engine(game, {dvars}).loaded.length, 0);
|
||||
}
|
||||
});
|
||||
test('startup reports versions, scope, credential validation and journal count safely', () => {
|
||||
const e = engine(game);
|
||||
assert.match(e.logs.join('\n'), /script=2\.0\.3 plugin=1\.2\.0/);
|
||||
assert(e.logs.some(line => line.includes(`endpoint=${publicEndpoint} server=server credentials=valid`)));
|
||||
assert(e.logs.some(line => line.includes('journal_load=success pending=0')));
|
||||
const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}});
|
||||
assert(invalid.logs.some(line => line.includes('credentials=invalid')));
|
||||
assert(!invalid.logs.join('').includes('secret-invalid-token'));
|
||||
const load = engine(game, {loadFailure:true});
|
||||
assert.match(load.logs.join('\n'), /journal_load=failure pending=unknown bank=disabled/);
|
||||
assert.equal(load.c.level.bank_write, undefined);
|
||||
});
|
||||
test('requests classify failures and preserve the API identity contract', () => {
|
||||
for (const [options, outcome] of [
|
||||
[{}, 'ok'], [{noHandle:true}, 'request_creation'], [{timeout:true}, 'transport_failure'],
|
||||
[{status:401}, 'http_rejection'], [{missingDiagnostics:true}, 'missing_diagnostics'],
|
||||
[{missingResponse:true}, 'missing_response'], [{malformed:true}, 'invalid_ack_invalid_json'],
|
||||
[{malformed:true,reason:'schema'}, 'invalid_ack_schema'], [{wrongId:true}, 'request_id_mismatch'],
|
||||
[{state:'rejected', code:'pending'}, 'application_rejection'], [{state:'committed'}, 'unexpected_state']
|
||||
]) {
|
||||
const e = engine(game, options); e.run(['.d','2000']);
|
||||
const line = e.logs.find(line => line.includes('action=deposit'));
|
||||
assert(line, JSON.stringify(options)); assert(line.includes(`outcome=${outcome} `), line);
|
||||
assert.match(line, /correlation=bank-1000-1 action=deposit request_id=request-1/);
|
||||
assert.match(line, /http_status=\w+ curl_code=\w+ dns_ms=\w+ connect_ms=\w+ tls_ms=\w+ total_ms=\w+/);
|
||||
}
|
||||
const e = engine(game); e.run(['.b']); e.run(['.b']);
|
||||
const lines = e.logs.filter(line => line.includes('action=balance'));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /correlation=bank-1000-1/); assert.match(lines[1], /correlation=bank-1000-2/);
|
||||
for (const request of e.sent) assert(!Object.hasOwn(JSON.parse(request.body), 'request_id'));
|
||||
assert.equal(e.saved.length, 0);
|
||||
});
|
||||
test('diagnostics exclude secrets, arbitrary codes, bodies and malformed timing fields', () => {
|
||||
for (const diagnostics of [undefined, {}, 'secret', {http_status:200, curl_code:privateToken, dns_ms:-1, connect_ms:{}, tls_ms:1e30, total_ms:'raw-body'}]) {
|
||||
const e = engine(game, {diagnostics, state:'rejected', code:privateToken}); e.run(['.d','2000']);
|
||||
const text = e.logs.join('\n');
|
||||
for (const secret of [privateToken, 'Authorization', 'Bearer ', 'raw-body', '18446744073709551615']) assert(!text.includes(secret));
|
||||
}
|
||||
const e = engine(game, {malformed:true, reason:privateToken}); e.run(['.b']);
|
||||
assert.match(e.logs.at(-1), /invalid_ack_unknown/); assert(!e.logs.join('').includes(privateToken));
|
||||
for (const code of [privateToken, 'oops\ninjected', '^1red', 'x'.repeat(1000), 'custom_code']) {
|
||||
assert.equal(e.c.bank_response_code(code), 'unknown');
|
||||
}
|
||||
for (const diagnostics of [undefined, {}, [], false, 'secret', {http_status:'200'}, {http_status:-1}])
|
||||
assert.equal(e.c.bank_diagnostic_number(diagnostics, 'http_status'), 'unavailable');
|
||||
});
|
||||
test('failures are limited by reason, with summaries, changed status and immediate recovery', () => {
|
||||
const options = {status:401}, e = engine(game, options);
|
||||
e.run(['.b']); e.run(['.b']);
|
||||
assert.equal(e.logs.filter(line => line.includes('action=balance')).length, 1);
|
||||
e.advance(30000); e.run(['.b']); assert.match(e.logs.at(-1), /suppressed=1/);
|
||||
options.status = 503; e.run(['.b']); assert.match(e.logs.at(-1), /http_status=503/);
|
||||
options.status = 200; e.run(['.b']); assert.match(e.logs.at(-1), /outcome=ok.*recovery=true/);
|
||||
});
|
||||
test('settlement reports retry progress, reason changes, storage failures and eventual recovery', () => {
|
||||
const options = {}, e = engine(game, options); e.run(['.d','2000']);
|
||||
options.timeout = true;
|
||||
const sender = e.c.bank_sender();
|
||||
assert.equal(sender.next().value, 1);
|
||||
assert.match(e.logs.at(-1), /decision=commit attempt=1 pending_seconds=0 settlement=pending next_retry_seconds=1/);
|
||||
const count = e.logs.length;
|
||||
e.advance(1000); assert.equal(sender.next().value, 2); assert.equal(e.logs.length, count);
|
||||
options.timeout = false; options.removeFailure = true;
|
||||
e.advance(2000); assert.equal(sender.next().value, 4);
|
||||
assert.match(e.logs.at(-1), /attempt=3 pending_seconds=3 settlement=journal_remove_failure next_retry_seconds=4.*suppressed=1/);
|
||||
options.removeFailure = false; e.advance(4000); sender.next();
|
||||
assert.match(e.logs.at(-1), /attempt=4 pending_seconds=7 journal_remove=success settlement=resolved next_retry_seconds=0 recovery=true/);
|
||||
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 1000);
|
||||
for (const decision of ['cancel','commit']) {
|
||||
const failed = engine(game, {storageFailure:decision}); failed.run(['.d','2000']);
|
||||
assert(failed.logs.some(line => line.includes(`journal_write decision=${decision} request_id=request-1 outcome=storage_failure`)));
|
||||
}
|
||||
});
|
||||
test('strict positive integer syntax', () => {
|
||||
const {c} = engine(game);
|
||||
for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0);
|
||||
assert.equal(c.bank_amount('2000'),2000); assert.equal(c.bank_amount('ALL'),0); assert.equal(c.bank_amount('all'),-1);
|
||||
});
|
||||
test('deposit journals cancellation then commit before moving points', () => {
|
||||
const e=engine(game); e.run(['.d','2000']);
|
||||
assert.equal(e.player.score,1000); assert.equal(e.player.score_total,4000); assert.equal(e.player.pers.score,1000);
|
||||
assert.deepEqual(e.order,['cancel','http','commit']); assert.equal(e.saved[0].decision,'commit');
|
||||
assert.equal(e.saved[0].guid,'18446744073709551615');
|
||||
assert.equal(e.sent[0].headers.Authorization,'Bearer ' + privateToken);
|
||||
assert.equal(e.sent[0].timeout_ms,15000);
|
||||
});
|
||||
test('all captures deposit points and resolves withdrawal amount', () => {
|
||||
const e=engine(game,{amount:3000}); e.run(['.d','all']); assert.equal(e.player.score,0);
|
||||
assert.equal(JSON.parse(e.sent[0].body).amount,3000);
|
||||
const w=engine(game); w.run(['.w','all']); assert.equal(w.player.score,5000);
|
||||
assert.equal(JSON.parse(w.sent[0].body).amount,'all');
|
||||
});
|
||||
test('failure responses never change points and retain cancellation', () => {
|
||||
for (const options of [{timeout:true},{noHandle:true},{status:503},{status:401},{status:429},{missingDiagnostics:true},{malformed:true},{wrongId:true},{state:'rejected',code:'insufficient_balance'}]) {
|
||||
const e=engine(game,options); e.run(['.w','2000']); assert.equal(e.player.score,3000);
|
||||
assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.c.level.bank_queue[0].decision,'cancel');
|
||||
}
|
||||
});
|
||||
test('storage failures never send unsaved work or move points', () => {
|
||||
for (const storageFailure of ['cancel','commit']) {
|
||||
const e=engine(game,{storageFailure}); e.run(['.d','2000']); assert.equal(e.player.score,3000);
|
||||
if(storageFailure==='cancel') assert.equal(e.sent.length,0); else assert.equal(e.saved[0].decision,'cancel');
|
||||
}
|
||||
});
|
||||
test('spending, death, disconnect, map end and point cap changes cancel prepared work', () => {
|
||||
for(const during of [(_c,e)=>{e.player.score=100;},(_c,e)=>{e.player.alive=false;},(_c,e)=>{e.active=false;},(c)=>{c.level.bank_ended=true;},(_c,e)=>{e.player.downed=true;}]) {
|
||||
const e=engine(game,{during}); e.run(['.d','2000']); assert.equal(e.saved[0].decision,'cancel');
|
||||
}
|
||||
const e=engine(game,{during:(_c,e)=>{e.player.score=999999;}}); e.run(['.w','2000']); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.player.score,999999);
|
||||
});
|
||||
test('invalid requests and unavailable match fail before HTTP', () => {
|
||||
for(const args of [['.d'],['.w','1000001'],['.d','4000'],['.b','2000'],['.w','1000000']]) {
|
||||
const e=engine(game); e.run(args); assert.equal(e.sent.length,0); assert.equal(e.player.score,3000);
|
||||
}
|
||||
const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0);
|
||||
});
|
||||
test('balance reads do not journal or alter points', () => {
|
||||
const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.equal(e.messages[0], '^3Bank: ^75000 ^3points');
|
||||
});
|
||||
test('empty balance displays zero and withdrawing all reports insufficient balance', () => {
|
||||
const e = engine(game, {amount:0, balance:0}); e.run(['.b']);
|
||||
assert.equal(e.messages[0], '^3Bank: ^70 ^3points');
|
||||
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
|
||||
const w = engine(game, {amount:0, balance:0, state:'rejected', code:'insufficient_balance'}); w.run(['.w','all']);
|
||||
assert.match(w.messages[0], /Not enough bank balance/);
|
||||
assert.equal(w.player.score, 3000); assert.equal(w.saved[0].decision, 'cancel');
|
||||
w.c.bank_sender().next();
|
||||
assert.equal(w.sent[1].url, publicEndpoint + '/cancel'); assert.equal(w.saved.length, 0);
|
||||
});
|
||||
test('recovered commit and cancel decisions retry at the public endpoint without applying points', () => {
|
||||
for (const decision of ['commit','cancel']) {
|
||||
const body = JSON.stringify({server:'server', guid:'18446744073709551615', request_id:'recovered', match:'old-match', operation:'withdraw', amount:'all', capacity:999370, decision});
|
||||
const options = {timeout:true, dvars:{tws_bank_url:'https://controller.internal/api/bank'}, recovered:[{id:'recovered', body, decision}]};
|
||||
const e = engine(game, options), sender = e.c.bank_sender();
|
||||
sender.next();
|
||||
assert.equal(e.saved.length, 1); assert.equal(e.player.score, 3000);
|
||||
options.timeout = false; sender.next();
|
||||
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
|
||||
assert.equal(e.sent.length, 2);
|
||||
for (const request of e.sent) {
|
||||
assert.equal(request.url, publicEndpoint + '/' + decision);
|
||||
assert.equal(request.body, body);
|
||||
}
|
||||
}
|
||||
});
|
||||
test('recovered settlements cannot reapply points and retry cleanup safely', () => {
|
||||
const e=engine(game); e.run(['.w','2000']); const score=e.player.score;
|
||||
const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score);
|
||||
const failed=engine(game,{removeFailure:true}); failed.run(['.d','2000']); const gen=failed.c.bank_sender(); gen.next(); gen.next();
|
||||
assert.equal(failed.player.score,1000); assert.equal(failed.saved[0].decision,'commit');
|
||||
});
|
||||
test('conflicting settlement states retain their journal', () => {
|
||||
const {c}=engine(game);
|
||||
assert(!c.bank_resolved('commit','cancelled')); assert(!c.bank_resolved('cancel','committed'));
|
||||
assert(c.bank_resolved('cancel','rejected')); assert(c.bank_resolved('commit','committed'));
|
||||
});
|
||||
if (realEc) test('actual EC rejects numeric GUIDs and settles normalized script payloads exactly once', () => {
|
||||
for (const guid of [2741626, '2741626', '0002741626', '18446744073709551615']) {
|
||||
const ec = realEc(String(guid));
|
||||
try {
|
||||
// Reproduce the live rejection before any EC transaction exists.
|
||||
const numeric = {server:'server', guid:2741626, match:'match', request_id:'old-cancel', operation:'withdraw', amount:'all', capacity:999370, decision:'cancel'};
|
||||
for (const action of ['balance','withdraw','cancel','commit']) {
|
||||
const rejected = ec.request(action, numeric);
|
||||
assert.equal(rejected.status, 400); assert.equal(rejected.body.code, 'invalid_request');
|
||||
assert.equal(rejected.body.request_id, '');
|
||||
}
|
||||
assert.equal(ec.requests().length, 0);
|
||||
const e = engine(game, {guid, receiver:ec.request});
|
||||
e.run(['.b']); assert.equal(e.messages[0], '^3Bank: ^70 ^3points');
|
||||
e.run(['.w','all']); assert.match(e.messages.at(-1), /Not enough bank balance/);
|
||||
assert.equal(e.player.score, 3000); assert.equal(e.saved[0].decision, 'cancel');
|
||||
e.c.bank_sender().next(); assert.equal(e.saved.length, 0);
|
||||
|
||||
e.run(['.d','2000']); assert.equal(e.player.score, 1000);
|
||||
assert.equal(e.saved[0].decision, 'commit');
|
||||
const recovered = journalEntries(e);
|
||||
const options = {guid:undefined, receiver:ec.request, recovered, removeFailure:true};
|
||||
const replay = engine(game, options), sender = replay.c.bank_sender();
|
||||
sender.next(); sender.next(); // EC commits once even when cleanup fails.
|
||||
assert.equal(replay.saved.length, 1); assert.equal(replay.player.score, 3000);
|
||||
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}]);
|
||||
options.removeFailure = false; sender.next(); assert.equal(replay.saved.length, 0);
|
||||
for (const request of replay.sent) assert.equal(request.body, recovered[0].body);
|
||||
|
||||
const withdrawal = engine(game, {guid, receiver:ec.request, idPrefix:'withdraw'});
|
||||
withdrawal.run(['.w','all']); assert.equal(withdrawal.player.score, 5000);
|
||||
withdrawal.c.bank_sender().next(); assert.equal(withdrawal.saved.length, 0);
|
||||
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}, {guid:String(guid), amount:-2000}]);
|
||||
|
||||
const cancel = engine(game, {guid, receiver:ec.request, idPrefix:'cancel', during:(_c, entry) => {entry.player.alive = false;}});
|
||||
cancel.run(['.d','2000']); assert.equal(cancel.saved[0].decision, 'cancel');
|
||||
const restart = engine(game, {guid:undefined, receiver:ec.request, recovered:journalEntries(cancel)});
|
||||
restart.c.bank_sender().next(); assert.equal(restart.saved.length, 0);
|
||||
assert.equal(cancel.player.score, 3000); assert.equal(ec.ledger().length, 2);
|
||||
assert.equal(ec.requests().find(row => row.request_id === 'cancel-1').state, 'cancelled');
|
||||
assert(ec.requests().every(row => row.guid === String(guid)));
|
||||
} finally { ec.close(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`);
|
||||
console.log(realEc ? 'PASS actual EC bank service and SQLite integration.' : 'Actual EC integration skipped; pass --ec-root /path/to/electric-cherry to include it.');
|
||||
@@ -0,0 +1,269 @@
|
||||
// Execute selected core HUD functions with engine-value shims.
|
||||
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
|
||||
// These checks are not a GSC VM, compiler, linker, or live layout test.
|
||||
// Common visibility helpers are stubbed here and executed directly by check_hud.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const root = new URL('../', import.meta.url);
|
||||
let checks = 0;
|
||||
function check(name, fn) { fn(); checks++; console.log(name); }
|
||||
function body(source, name) {
|
||||
const match = new RegExp(`^${name}\\(([^\\n]*)\\)\\s*\\n\\{`, 'm').exec(source);
|
||||
assert(match, name);
|
||||
let at = match.index + match[0].length, end = at, depth = 1;
|
||||
while (depth) {
|
||||
assert(end < source.length, `unclosed ${name}`);
|
||||
const ch = source[end++];
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') depth--;
|
||||
}
|
||||
return [match[1], source.slice(at, end - 1)];
|
||||
}
|
||||
function install(context, source, names) {
|
||||
for (const name of names) {
|
||||
const [args, original] = body(source, name);
|
||||
let code = original.replace(/\/\/[^\n]*/g, '');
|
||||
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+|--)/g)].map(match => match[1]))];
|
||||
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
|
||||
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
|
||||
.replace(/maps\\mp\\zombies\\_zm_utility::get_current_zombie_count/g, 'get_enemy_count')
|
||||
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
|
||||
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
|
||||
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
|
||||
.replace(/self (\w+)\(/g, 'self.$1(')
|
||||
.replace(/(\w+) (SetText|SetTextUnlimited|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
|
||||
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
|
||||
.replace(/level waittill\(([^;]*)\);/g, 'yield { event: $1 };')
|
||||
.replace(/\.size\b/g, '.length')
|
||||
.replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]')
|
||||
.replace(/\bwait\s+([^;]+);/g, 'yield $1;');
|
||||
vm.runInContext(`function${code.includes('yield ') ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context);
|
||||
}
|
||||
}
|
||||
function engine(game, source, afk, hidden = false) {
|
||||
const queued = [], elems = [], actions = [];
|
||||
let now = 123456;
|
||||
const self = { active: true };
|
||||
if (hidden) self.tws_hud_hidden = true;
|
||||
for (const name of ['enableInvulnerability', 'disableInvulnerability', 'freezeControls',
|
||||
'disableWeapons', 'disableOffhandWeapons', 'enableWeapons', 'enableOffhandWeapons', 'iPrintLnBold']) {
|
||||
self[name] = (...args) => actions.push([name, ...args]);
|
||||
}
|
||||
function element(args, alpha = 1) {
|
||||
const result = { args, alpha, writes: [],
|
||||
SetText(value) { this.text = value; this.writes.push(['text', value]); },
|
||||
SetTextUnlimited(value) { this.text = value; this.writes.push(['unlimitedText', value]); },
|
||||
SetValue(value) { this.value = value; this.writes.push(['value', value]); },
|
||||
SetShader(...args) { this.shader = args; },
|
||||
SetTimerUp(value) { this.timer = value; this.writes.push(['timer', value]); },
|
||||
};
|
||||
elems.push(result);
|
||||
return result;
|
||||
}
|
||||
function register(player, elem) {
|
||||
(player.tws_core_hud_elems ??= []).push(elem);
|
||||
common.set_core_alpha(player, elem, elem.alpha);
|
||||
return elem;
|
||||
}
|
||||
const common = {
|
||||
make_core_overlay_text: (...args) => register(args[0], element(args)),
|
||||
make_core_overlay_shader: (...args) => register(args[0], element(args, args[6])),
|
||||
make_overlay_width: width => Math.trunc(Math.max(1, width)),
|
||||
set_core_alpha(player, elem, alpha) {
|
||||
if (!elem) return;
|
||||
elem.tws_core_alpha = alpha;
|
||||
elem.alpha = player.tws_hud_hidden ? 0 : alpha;
|
||||
},
|
||||
toggle_core_hud() {
|
||||
self.tws_hud_hidden = !self.tws_hud_hidden;
|
||||
for (const elem of self.tws_core_hud_elems ?? []) common.set_core_alpha(self, elem, elem.tws_core_alpha);
|
||||
},
|
||||
player_active: player => player.active,
|
||||
format_mmss: ms => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`,
|
||||
};
|
||||
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2, zombie_team: 'axis',
|
||||
hud_overlay_timer_x: 6, hud_overlay_match_y: 8, hud_overlay_round_y: 30,
|
||||
hud_overlay_brand_x: 320, hud_overlay_brand_y: 8,
|
||||
hud_overlay_count_x: 300, hud_overlay_count_y: 440,
|
||||
hud_overlay_count_label_x: 306, hud_overlay_count_label_y: 446,
|
||||
hud_overlay_bar_x: 242, hud_overlay_bar_y: 466,
|
||||
hud_overlay_bar_width: 156, hud_overlay_bar_height: 4,
|
||||
hud_overlay_health_y: 460, hud_overlay_health_x: 414, hud_overlay_slash_x: 418,
|
||||
hud_overlay_maxhealth_x: 445, hud_overlay_hp_x: 449,
|
||||
hud_overlay_afk_x: 320, hud_overlay_afk_y: 421,
|
||||
hud_c_count: 'white', hud_c_dim: 'dim', hud_c_afk: 'yellow',
|
||||
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
|
||||
const context = vm.createContext({ self, level, common, queued, getTime: () => now,
|
||||
get_enemy_count: () => level.fixture_enemies ?? 0,
|
||||
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
|
||||
int: Math.trunc, max: Math.max });
|
||||
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
|
||||
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
|
||||
return { context, self, level, queued, elems, actions, common,
|
||||
setTime(value) { now = value; },
|
||||
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
|
||||
};
|
||||
}
|
||||
for (const game of ['t5', 't6']) {
|
||||
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_hud.gsc`, root), 'utf8');
|
||||
const afk = readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`, root), 'utf8');
|
||||
const make = hidden => engine(game, source, afk, hidden);
|
||||
check(`${game}: native .hud chat and shared lifecycle retain engine-specific clock seeds`, () => {
|
||||
assert(source.includes('register_help_command(".hud", "Toggle core HUD")'));
|
||||
assert(source.includes('register_connect_handler(::hud_connect)'));
|
||||
assert(source.includes('register_spawn_handler(::hud_start)'));
|
||||
assert(!source.includes('waittill("connected"'));
|
||||
const chat = body(source, 'hud_chat_listener')[1];
|
||||
assert(chat.includes('level waittill("say", message, player)'));
|
||||
assert(chat.includes('toLower(message) == ".hud"'));
|
||||
assert(chat.includes('player thread hud_toggle()'), 'disconnect scope stays in the player worker');
|
||||
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
|
||||
assert(!source.includes('tws_hud_hidden ='), 'spawn/construction must not reset the preference');
|
||||
for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind', 'hud_zombie_counter'])
|
||||
assert(body(source, name)[1].includes('level endon("hud_game_over")'));
|
||||
const bind = body(source, 'match_timer_bind')[1];
|
||||
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
|
||||
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
|
||||
assert(afk.includes('NewClientHudElem(self)'), 'protection countdown is an independent critical HUD');
|
||||
assert(!afk.includes('set_core_alpha'), 'hiding the optional HUD must not hide the protection deadline');
|
||||
});
|
||||
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
|
||||
const e = make(); e.context.hud_connect();
|
||||
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
|
||||
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
|
||||
assert.equal(e.elems.length, 10);
|
||||
assert.equal(e.self.tws_core_hud_elems.length, 10);
|
||||
e.context.hud_connect(); assert.equal(e.elems.length, 10);
|
||||
assert.equal(e.queued.length, 1); assert.equal(e.queued[0][0], 'common.destroy_on_disconnect');
|
||||
assert.equal(e.queued[0][1].length, e.elems.length);
|
||||
const textAt = (text, x, y, align, scale, colour) => {
|
||||
const elem = e.elems.find(item => item.text === text);
|
||||
assert(elem, text); assert.deepEqual(elem.args.slice(1), [x, y, align, scale, colour]);
|
||||
};
|
||||
assert.deepEqual(e.self.tws_hud_match.args.slice(1), [6, 8, 'left', 1.4, 'white']);
|
||||
assert.deepEqual(e.self.tws_hud_round.args.slice(1), [6, 30, 'left', 1.25, 'dim']);
|
||||
textAt('TWS ZOMBIES | discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
|
||||
textAt('-- ZOMBIES', 320, 440, 'center', 1.25, 'white');
|
||||
assert(!e.elems.some(elem => elem.text === 'ZOMBIES'), 'count and label share one alignment');
|
||||
textAt('HP', 449, 460, 'left', 1, 'white');
|
||||
for (const [key, x, align] of [['health', 414, 'right'], ['slash', 418, 'left'], ['maxhealth', 445, 'right']]) {
|
||||
const elem = e.self[`tws_hud_${key}`];
|
||||
assert.deepEqual(elem.args.slice(1), [x, 460, align, 1, 'white']);
|
||||
assert.equal(elem.alpha, 0); assert.equal(elem.tws_core_alpha, 0);
|
||||
}
|
||||
assert.equal(e.self.tws_hud_hp.alpha, 0);
|
||||
const shaders = e.elems.filter(elem => elem.args.length === 8);
|
||||
assert.equal(shaders.length, 2, 'only the health track and fill allocate shaders');
|
||||
for (const elem of shaders) assert.deepEqual(elem.args.slice(1, 5), [242, 466, 156, 4]);
|
||||
assert.equal(shaders[0].alpha, .8); assert.equal(shaders[1].alpha, 0);
|
||||
assert(!e.elems.some(elem => elem.text === 'MATCH' || elem.text === 'ROUND'));
|
||||
});
|
||||
check(`${game}: toggling before first spawn preserves hidden state and binds workers once`, () => {
|
||||
const e = make(); e.context.hud_toggle();
|
||||
assert.equal(e.self.tws_hud_hidden, true);
|
||||
assert(e.elems.every(elem => elem.alpha === 0));
|
||||
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
|
||||
assert.equal(e.queued.length, 1, 'toggle only queues disconnect cleanup');
|
||||
e.context.hud_start(); e.context.hud_start();
|
||||
assert.equal(e.elems.length, 10); assert.equal(e.self.tws_hud_hidden, true);
|
||||
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
|
||||
e.context.hud_timer_row();
|
||||
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
|
||||
const threads = e.queued.length;
|
||||
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
|
||||
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 10);
|
||||
});
|
||||
check(`${game}: complete zombie text stays centered through digit changes and hidden updates without config-string churn`, () => {
|
||||
const e = make();
|
||||
// Center on the configured track, including non-default and fractional midpoints.
|
||||
e.level.hud_overlay_bar_x = 210;
|
||||
e.level.hud_overlay_bar_width = 155;
|
||||
e.context.hud_connect();
|
||||
const count = e.self.tws_hud_count;
|
||||
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
|
||||
const run = e.context.hud_zombie_counter();
|
||||
for (const value of [0, 6, 9, 10, 99, 100, 999, 1000, 6]) {
|
||||
e.level.fixture_enemies = value;
|
||||
run.next();
|
||||
assert.equal(count.text, `${value} ZOMBIES`);
|
||||
assert.deepEqual(count.args.slice(1, 4), [287.5, 440, 'center']);
|
||||
const writes = count.writes.length;
|
||||
run.next(); assert.equal(count.writes.length, writes, 'unchanged counts do not rewrite text');
|
||||
}
|
||||
e.level.zombie_total = 94; run.next();
|
||||
assert.equal(count.text, '100 ZOMBIES', 'remaining spawn count remains included');
|
||||
e.context.hud_toggle();
|
||||
e.level.zombie_total = -4; run.next();
|
||||
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 0);
|
||||
e.context.hud_toggle();
|
||||
assert.equal(count.text, '6 ZOMBIES'); assert.equal(count.alpha, 1);
|
||||
assert.equal(count.writes.filter(([kind]) => kind === 'text').length, 1,
|
||||
'only the fixed placeholder uses a normal config string');
|
||||
assert(count.writes.slice(1).every(([kind]) => kind === 'unlimitedText'));
|
||||
assert.equal(e.elems.length, 10, 'updates and toggles never allocate replacement elements');
|
||||
});
|
||||
check(`${game}: health numbers appear only while damaged, follow the bar colour, and avoid config-string churn`, () => {
|
||||
const e = make(); e.context.hud_connect(); const { self } = e;
|
||||
const numbers = [self.tws_hud_health, self.tws_hud_slash, self.tws_hud_maxhealth, self.tws_hud_hp];
|
||||
const run = e.context.hud_health_display(); run.next();
|
||||
assert(numbers.every(elem => elem.alpha === 0), 'undefined health does not flash a damage readout');
|
||||
assert.equal(self.health, undefined); assert.equal(self.maxhealth, undefined);
|
||||
self.health = 100; run.next();
|
||||
assert.equal(self.tws_hud_health.value, 100); assert.equal(self.tws_hud_maxhealth.value, 100);
|
||||
assert(numbers.every(elem => elem.alpha === 0));
|
||||
assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 156);
|
||||
const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes);
|
||||
self.health = 35; run.next();
|
||||
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'yellow'));
|
||||
assert.equal(self.tws_hud_health.value, 35); assert.equal(self.tws_hud_bar.shader[1], 54);
|
||||
self.maxhealth = 250; self.health = 50; run.next();
|
||||
assert.equal(self.tws_hud_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 31);
|
||||
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'red'));
|
||||
assert.equal(self.tws_hud_bar.color, 'red');
|
||||
self.health = 0; run.next(); assert.equal(self.tws_hud_bar.alpha, 0); assert.equal(self.tws_hud_health.value, 0);
|
||||
assert(numbers.every(elem => elem.alpha === 1));
|
||||
self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green');
|
||||
assert(numbers.every(elem => elem.alpha === 0 && elem.tws_core_alpha === 0));
|
||||
self.health = 500; run.next(); assert.equal(self.tws_hud_bar.shader[1], 156);
|
||||
assert(numbers.every(elem => elem.alpha === 0), 'overheal must not show the damage readout');
|
||||
self.maxhealth = 0; self.health = 100; run.next();
|
||||
assert.equal(self.maxhealth, 0, 'invalid maximum is display-only fallback');
|
||||
assert.equal(self.tws_hud_maxhealth.value, 100); assert(numbers.every(elem => elem.alpha === 0));
|
||||
assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1);
|
||||
});
|
||||
check(`${game}: hidden health updates remain private and restore the current bar state`, () => {
|
||||
const e = make(true), other = make();
|
||||
e.context.hud_connect(); other.context.hud_connect();
|
||||
e.self.health = 100; const run = e.context.hud_health_display(); run.next();
|
||||
assert(e.elems.every(elem => elem.alpha === 0));
|
||||
assert.equal(e.self.tws_hud_bar.tws_core_alpha, 1);
|
||||
e.self.health = 0; run.next(); e.context.hud_toggle();
|
||||
assert.equal(e.self.tws_hud_health.value, 0); assert.equal(e.self.tws_hud_bar.alpha, 0);
|
||||
assert.equal(e.elems[0].alpha, 1); assert.equal(e.elems.at(-2).alpha, .8);
|
||||
e.context.hud_toggle(); e.self.health = 50; run.next();
|
||||
assert.equal(e.self.tws_hud_bar.alpha, 0); assert.equal(e.self.tws_hud_bar.shader[1], 78);
|
||||
e.context.hud_toggle();
|
||||
assert.equal(e.self.tws_hud_bar.alpha, 1); assert.equal(e.self.tws_hud_health.value, 50);
|
||||
assert.equal(other.self.tws_hud_hidden, undefined); assert.equal(other.self.tws_hud_health.alpha, 0);
|
||||
assert.equal(e.elems.length, 10); assert.equal(e.queued.length, 1);
|
||||
e.context.hud_toggle(); e.self.health = 100; run.next(); e.context.hud_toggle();
|
||||
assert.equal(e.self.tws_hud_health.alpha, 0, 'healing while hidden conceals the readout when shown');
|
||||
assert.equal(e.self.tws_hud_hp.alpha, 0); assert.equal(e.self.tws_hud_bar.alpha, 1);
|
||||
});
|
||||
check(`${game}: toggling frozen clocks does not restart or reseed them`, () => {
|
||||
const e = make(); e.context.hud_start(); e.context.hud_timer_row();
|
||||
const match = e.self.tws_hud_match, round = e.self.tws_hud_round;
|
||||
const freeze = e.context.freeze_timers_on_game_over(match, round);
|
||||
assert.equal(freeze.next().value.event, 'hud_game_over');
|
||||
e.context.hud_toggle(); e.setTime(248456); e.level.tws_ended = true; freeze.next();
|
||||
assert.equal(match.text, '2:05'); assert.equal(round.text, '2:05');
|
||||
const before = JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]);
|
||||
e.setTime(999999); e.context.hud_toggle(); e.context.hud_start();
|
||||
assert.equal(match.alpha, 1); assert.equal(round.alpha, 1);
|
||||
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
|
||||
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
|
||||
});
|
||||
|
||||
}
|
||||
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
|
||||
@@ -0,0 +1,702 @@
|
||||
// Execute the actual common GSC service functions with HUD/event shims.
|
||||
// These fixtures do not emulate the GSC VM, fonts or HUD pool limits. Geometry
|
||||
// checks model the 640x480 fullscreen placement transform, not a live renderer.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import './check_core_hud.mjs';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
let checks = 0;
|
||||
function check(name, run) {
|
||||
try { run(); checks++; }
|
||||
catch (error) { throw new Error(name, { cause: error }); }
|
||||
}
|
||||
const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via', 'destroy_on_disconnect']);
|
||||
function translate(source) {
|
||||
const literals = [];
|
||||
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
|
||||
if (token.startsWith('/')) return '';
|
||||
literals.push(token);
|
||||
return `__literal_${literals.length - 1}__`;
|
||||
});
|
||||
const headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm;
|
||||
const result = [];
|
||||
for (let match; (match = headers.exec(protectedSource));) {
|
||||
const name = match[1];
|
||||
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;
|
||||
let end = headers.lastIndex, depth = 1;
|
||||
while (depth) {
|
||||
assert(end < protectedSource.length, `unclosed ${name}`);
|
||||
const ch = protectedSource[end++];
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') depth--;
|
||||
}
|
||||
let body = protectedSource.slice(headers.lastIndex, end - 1);
|
||||
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
|
||||
body = body
|
||||
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '')
|
||||
.replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]')
|
||||
.replace(/\.size\b/g, '.length')
|
||||
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
|
||||
.replace(/self thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);')
|
||||
.replace(/(self|level) waittill\(([^;]+)\);/g, 'yield { entity: $1, event: $2 };')
|
||||
.replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);')
|
||||
.replace(/self (\w+)\(/g, '$1(')
|
||||
.replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };')
|
||||
.replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetTextUnlimited|SetValue|SetShader|Destroy)\(/g, '$1.$2(')
|
||||
.replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]);
|
||||
result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
const commands = [
|
||||
['.help', 'List available commands'], ['.afk', 'Pause with protection'],
|
||||
['.back', 'Return from AFK'], ['.join', 'Join mid-round (newcomers only)'],
|
||||
['.leaders', 'Show the map record again'], ['.b', 'Show bank balance'],
|
||||
['.d <amount|all>', 'Deposit points'], ['.w <amount|all>', 'Withdraw points'],
|
||||
['.x', 'Extra command'],
|
||||
];
|
||||
function engine(game, count = 8, withHud = false) {
|
||||
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
|
||||
let now = 0, running, peakLive = 0;
|
||||
const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = [];
|
||||
const players = new Set([self]);
|
||||
function resume(task) {
|
||||
const prior = running;
|
||||
running = task;
|
||||
const result = task.generator.next();
|
||||
task.done = result.done;
|
||||
task.wait = result.value;
|
||||
task.at = result.value?.milliseconds !== undefined ? now + result.value.milliseconds : Infinity;
|
||||
running = prior;
|
||||
}
|
||||
function startThread(fn, args) {
|
||||
const task = { generator: fn(...args), ends: [] };
|
||||
tasks.push(task);
|
||||
resume(task);
|
||||
}
|
||||
function emit(entity, event) {
|
||||
const listeners = tasks.filter(t => !t.done);
|
||||
for (const task of listeners) if (task.ends.some(([e, n]) => e === entity && n === event)) task.done = true;
|
||||
for (const task of listeners) if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
|
||||
}
|
||||
function advance(milliseconds) {
|
||||
const until = now + milliseconds;
|
||||
for (;;) {
|
||||
const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
now = next.at;
|
||||
resume(next);
|
||||
}
|
||||
now = until;
|
||||
}
|
||||
function newElement(owner) {
|
||||
assert(players.has(owner), 'every HUD element must be owned by a known client');
|
||||
const element = {
|
||||
owner,
|
||||
alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0,
|
||||
SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
|
||||
SetTextUnlimited(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; },
|
||||
SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; },
|
||||
SetShader(shader, width, height) {
|
||||
assert(!this.destroyCalls);
|
||||
assert(Number.isInteger(width) && Number.isInteger(height), 'SetShader requires integer dimensions');
|
||||
Object.assign(this, { shader, width, height });
|
||||
},
|
||||
Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; },
|
||||
};
|
||||
elements.push(element);
|
||||
peakLive = Math.max(peakLive, elements.filter(elem => !elem.destroyCalls).length);
|
||||
return element;
|
||||
}
|
||||
const c = vm.createContext({
|
||||
self, level, NewClientHudElem: newElement, getTime: () => now,
|
||||
spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null && !v.destroyCalls,
|
||||
max: Math.max, int: Math.trunc,
|
||||
getSubStr: (text, begin, end) => { assert(end <= text.length); return text.slice(begin, end); },
|
||||
endon: (entity, event) => running?.ends.push([entity, event]), startThread, emit,
|
||||
});
|
||||
vm.runInContext(translate(source), c);
|
||||
c.common_config();
|
||||
for (const [command, description] of commands.slice(0, count).reverse()) c.register_help_command(command, description);
|
||||
if (withHud) c.register_help_command('.hud', 'Toggle core HUD');
|
||||
for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]);
|
||||
return { game, c, self, level, elements, tasks, advance, source,
|
||||
createCore() {
|
||||
assert.equal(game, 't5', 'combined fixture currently covers T5 construction');
|
||||
for (const module of ['hud', 'afk'])
|
||||
vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c);
|
||||
c.hud_connect(); c.afk_hud_create();
|
||||
},
|
||||
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
|
||||
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),
|
||||
get state() { return self.tws_service; },
|
||||
get live() { return elements.filter(e => !e.destroyCalls); },
|
||||
get peakLive() { return peakLive; },
|
||||
get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); },
|
||||
get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); },
|
||||
get helpCommands() {
|
||||
return Array.from(self.tws_service.command_rows).filter(row => row.alpha > 0).map(row => row.text);
|
||||
},
|
||||
get helpDescriptions() {
|
||||
return Array.from(self.tws_service.description_rows).filter(row => row.alpha > 0).map(row => row.text);
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines });
|
||||
function assertCounter(e, page, pages) {
|
||||
const counter = e.state.page_counter;
|
||||
if (pages === 1) assert.equal(counter, undefined, 'single-page services do not allocate a counter');
|
||||
else {
|
||||
assert.equal(counter.alpha, 1);
|
||||
assert.equal(counter.text, `${page} / ${pages}`);
|
||||
}
|
||||
}
|
||||
function assertTextOnly(e) {
|
||||
assert(e.state.elems.every(elem => elem.shader === undefined && elem.archived === false),
|
||||
'service elements are private, non-archived text with no background shaders');
|
||||
}
|
||||
for (const game of ['t5', 't6']) {
|
||||
check(`${game} installed HUD joins all nine commands in one view`, () => {
|
||||
const e = engine(game, 8, true);
|
||||
e.help();
|
||||
assert.deepEqual(e.helpCommands,
|
||||
['.help', '.hud', '.afk', '.back', '.join', '.leaders', '.b', '.d <amount|all>', '.w <amount|all>']);
|
||||
assert.deepEqual(e.helpDescriptions,
|
||||
['Show commands', 'Toggle core HUD', 'Protect & freeze', 'Return to play', 'Join as newcomer',
|
||||
'View records', 'Bank balance', 'Deposit', 'Withdraw']);
|
||||
assert.equal(e.state.help.pages, 1);
|
||||
assertCounter(e, 1, 1);
|
||||
assert.equal(e.live.length, 19);
|
||||
assertTextOnly(e);
|
||||
e.advance(12000);
|
||||
assert.equal(e.live.length, 0);
|
||||
const withoutHud = engine(game);
|
||||
withoutHud.help();
|
||||
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
|
||||
});
|
||||
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
|
||||
const e = engine(game);
|
||||
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
|
||||
const bar = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0, 11);
|
||||
const label = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
|
||||
label.SetText('48:16');
|
||||
const other = e.addPlayer();
|
||||
const otherLabel = e.c.make_core_overlay_text(other, 6, 8, 'left', 1.4, [1, 1, 1]);
|
||||
e.record(snapshot()); e.help();
|
||||
const serviceAlpha = Array.from(e.state.elems, elem => elem.alpha);
|
||||
const help = e.state.help, record = e.state.record;
|
||||
e.c.toggle_core_hud();
|
||||
assert.equal(e.self.tws_hud_hidden, true);
|
||||
assert([track, bar, label].every(elem => elem.alpha === 0));
|
||||
assert.equal(otherLabel.alpha, 1);
|
||||
assert.equal(other.tws_hud_hidden, undefined);
|
||||
e.c.set_core_alpha(e.self, bar, 1);
|
||||
const afk = e.c.make_core_overlay_text(e.self, 320, 421, 'center', 1.0, [1, 1, 1]);
|
||||
assert.equal(afk.alpha, 0, 'late core elements inherit the hidden state');
|
||||
e.c.set_core_alpha(e.self, afk, 0);
|
||||
const count = e.elements.length;
|
||||
e.c.toggle_core_hud();
|
||||
assert.deepEqual([track.alpha, bar.alpha, label.alpha, afk.alpha], [0.8, 1, 1, 0]);
|
||||
assert.equal(label.text, '48:16', 'toggle does not rewrite displayed data');
|
||||
assert.equal(e.elements.length, count);
|
||||
assert.deepEqual(Array.from(e.state.elems, elem => elem.alpha), serviceAlpha);
|
||||
assert.equal(e.state.help, help); assert.equal(e.state.record, record);
|
||||
e.c.toggle_core_hud();
|
||||
e.c.set_core_alpha(e.self, bar, 0);
|
||||
e.c.set_core_alpha(e.self, afk, 1);
|
||||
assert.equal(afk.alpha, 0);
|
||||
e.c.toggle_core_hud();
|
||||
assert.equal(bar.alpha, 0);
|
||||
assert.equal(afk.alpha, 1);
|
||||
label.Destroy();
|
||||
e.c.toggle_core_hud();
|
||||
assert(!e.self.tws_core_hud_elems.includes(label), 'toggle prunes destroyed elements');
|
||||
assert.equal(label.destroyCalls, 1, 'registry never owns element destruction');
|
||||
});
|
||||
check(`${game} hiding before construction survives late core creation and prunes dead registrations`, () => {
|
||||
const e = engine(game);
|
||||
e.c.toggle_core_hud();
|
||||
const old = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
|
||||
assert.equal(old.alpha, 0);
|
||||
old.Destroy();
|
||||
const fresh = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
|
||||
assert.equal(fresh.alpha, 0);
|
||||
assert.equal(e.self.tws_core_hud_elems.length, 1);
|
||||
e.c.toggle_core_hud();
|
||||
assert.equal(fresh.alpha, 1);
|
||||
assert.equal(e.level.match_start_time, undefined);
|
||||
assert.equal(e.level.round_start_time, undefined);
|
||||
const reconnected = e.addPlayer();
|
||||
const newLabel = e.c.make_core_overlay_text(reconnected, 6, 8, 'left', 1.4, [1, 1, 1]);
|
||||
assert.equal(newLabel.alpha, 1, 'a new player defaults to visible');
|
||||
});
|
||||
for (const count of [0, 1, 4, 8, 9]) check(`${game} ${count} registry entries order and dismiss`, () => {
|
||||
const e = engine(game, count);
|
||||
assert.equal(e.elements.length, 0, 'service is lazy and independent of core');
|
||||
e.help();
|
||||
const s = e.state;
|
||||
assert.equal(s.help.pages, 1);
|
||||
assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0]));
|
||||
assertCounter(e, 1, 1);
|
||||
assert.deepEqual(e.helpCommands, Array.from(s.commands, entry => e.c.help_command_label(entry.command)));
|
||||
assert.deepEqual(e.helpDescriptions, Array.from(s.commands, entry => e.c.help_description(entry)));
|
||||
assert.equal(e.live.length, count * 2 + 1);
|
||||
assertTextOnly(e);
|
||||
e.advance(12000);
|
||||
assert.equal(s.help, undefined);
|
||||
assert.equal(e.live.length, 0, 'idle must release even hidden elements');
|
||||
});
|
||||
check(`${game} overflow help pages reuse rows and repeated help resets expiry`, () => {
|
||||
const e = engine(game, 9, true);
|
||||
e.help();
|
||||
assert.equal(e.state.help.pages, 2);
|
||||
assert.equal(e.live.length, 20);
|
||||
assertCounter(e, 1, 2);
|
||||
const rows = [...e.state.command_rows, ...e.state.description_rows];
|
||||
const count = e.elements.length, texts = e.textCalls;
|
||||
e.help();
|
||||
assert.equal(e.textCalls, texts, 'repeating current page does not reassign text');
|
||||
e.advance(12000);
|
||||
assert.equal(e.state.help.page, 1);
|
||||
assert.deepEqual(e.helpCommands, ['.x']);
|
||||
assertCounter(e, 2, 2);
|
||||
assert.deepEqual(Array.from(e.state.command_rows, row => row.alpha), [1, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
e.help();
|
||||
assert.equal(e.state.help.page, 0);
|
||||
e.advance(11900);
|
||||
e.help();
|
||||
e.advance(100);
|
||||
assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement');
|
||||
assert.deepEqual([...e.state.command_rows, ...e.state.description_rows], rows);
|
||||
e.advance(11900);
|
||||
assertCounter(e, 2, 2);
|
||||
e.advance(12000);
|
||||
assert.equal(e.state.help, undefined);
|
||||
assert.equal(e.elements.length, count);
|
||||
assert.equal(e.live.length, 0);
|
||||
});
|
||||
check(`${game} help pauses record time and resumes the same page`, () => {
|
||||
const e = engine(game);
|
||||
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
|
||||
e.advance(15000);
|
||||
assert.equal(e.state.record.page, 1);
|
||||
const remaining = e.state.record.remaining;
|
||||
e.help();
|
||||
e.advance(12000);
|
||||
assert.equal(e.state.help, undefined);
|
||||
assert.equal(e.state.record.page, 1);
|
||||
assert.equal(e.state.record.remaining, remaining);
|
||||
assert.equal(e.state.rows[0].text, 'Four');
|
||||
assert.equal(e.live.length, 5);
|
||||
const resumedTexts = e.textCalls;
|
||||
e.c.service_render(e.state);
|
||||
assert.equal(e.textCalls, resumedTexts, 'subsequent ticks must reuse the rendered view');
|
||||
e.advance(remaining);
|
||||
assert.equal(e.state.record, undefined);
|
||||
assert.equal(e.live.length, 0);
|
||||
});
|
||||
check(`${game} latest record replaces pending one without disturbing help`, () => {
|
||||
const e = engine(game);
|
||||
e.help();
|
||||
e.advance(3000);
|
||||
const help = e.state.help;
|
||||
e.record(snapshot(['Old']));
|
||||
e.record(snapshot(['Newest']));
|
||||
assert.equal(e.state.help, help);
|
||||
assert.equal(e.state.record.page, 0);
|
||||
assert.equal(e.state.record.remaining, 12000);
|
||||
assert.equal(e.state.rows.length, 0, 'pending records allocate no holder elements');
|
||||
e.advance(9000);
|
||||
assert.equal(e.state.rows[0].text, 'Newest');
|
||||
assert.equal(e.state.record.remaining, 12000);
|
||||
assertCounter(e, 1, 1);
|
||||
e.advance(11900);
|
||||
e.record(snapshot(['Newest']));
|
||||
const texts = e.textCalls;
|
||||
e.advance(100);
|
||||
assert(e.state.record, 'previous expiry cannot hide newer request');
|
||||
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`, () => {
|
||||
const e = engine(game);
|
||||
e.record(undefined);
|
||||
assert.equal(e.state.record_title.alpha, 1);
|
||||
assert.equal(e.state.record_title.text, 'Map record unavailable');
|
||||
assert.equal(e.live.length, 4);
|
||||
assertCounter(e, 1, 1);
|
||||
const first = [...e.live];
|
||||
e.record({ round: 0, lines: [] });
|
||||
assert.equal(e.state.record_title.text, 'No recorded high round yet');
|
||||
assert.deepEqual(e.live, first);
|
||||
assertCounter(e, 1, 1);
|
||||
e.record(snapshot(['A', 'B', 'C', 'D']));
|
||||
assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first');
|
||||
assert.equal(e.state.record_title.text, 'Map record | Round 82');
|
||||
assert.equal(e.live.length, 5);
|
||||
const paged = [...e.live];
|
||||
assertCounter(e, 1, 2);
|
||||
e.advance(12000);
|
||||
assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]);
|
||||
assert.equal(e.state.rows[0].text, 'D');
|
||||
assertCounter(e, 2, 2);
|
||||
assert.deepEqual(e.live, paged, 'page changes reuse the same allocation');
|
||||
e.record(snapshot(['Single page']));
|
||||
assert(paged.every(elem => elem.destroyCalls === 1));
|
||||
assertCounter(e, 1, 1);
|
||||
assert.equal(e.state.rows[0].text, 'Single page');
|
||||
assert.equal(e.live.length, 4);
|
||||
assert.equal(e.peakLive, 5, 'new allocations never overlap the released view');
|
||||
assertTextOnly(e);
|
||||
});
|
||||
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => {
|
||||
const e = engine(game);
|
||||
e.record(snapshot()); e.help(); e.advance(2000);
|
||||
if (event === 'disconnect') e.event(event); else e.end(event);
|
||||
assert(e.state.closed);
|
||||
assert.equal(e.state.help, undefined);
|
||||
assert.equal(e.state.record, undefined);
|
||||
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||
const count = e.elements.length;
|
||||
e.advance(60000);
|
||||
if (event !== 'disconnect') {
|
||||
e.help(); e.record(snapshot()); e.event('disconnect');
|
||||
assert.equal(e.elements.length, count, 'game end blocks new service requests');
|
||||
}
|
||||
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||
});
|
||||
check(`${game} ending before first service blocks lazy creation`, () => {
|
||||
const e = engine(game);
|
||||
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);
|
||||
});
|
||||
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
|
||||
const e = engine(game);
|
||||
e.record(snapshot());
|
||||
const calls = e.textCalls, count = e.elements.length;
|
||||
e.advance(11000);
|
||||
assert.equal(e.textCalls, calls);
|
||||
assert.equal(e.elements.length, count);
|
||||
assert.equal(e.state.record_title.text, 'Map record | Round 82');
|
||||
assert.equal(e.state.commands[3].command, '.join');
|
||||
assert.match(e.c.help_description(e.state.commands[3]), /newcomer/);
|
||||
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
|
||||
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
|
||||
assertTextOnly(e);
|
||||
});
|
||||
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
|
||||
const e = engine(game, 8, true);
|
||||
e.record(snapshot(['One', 'Two', 'Three']));
|
||||
const s = e.state;
|
||||
assert.deepEqual([s.record_title.x, s.record_title.y, s.record_title.alignX], [633, 36, 'right']);
|
||||
assert.deepEqual(Array.from(s.rows, row => [row.x, row.y, row.alignX]),
|
||||
[[633, 60, 'right'], [633, 78, 'right'], [633, 96, 'right']]);
|
||||
const records = [...s.elems];
|
||||
e.help();
|
||||
assert.deepEqual([s.help_title.x, s.help_title.y], [432, 54]);
|
||||
for (const [i, row] of s.command_rows.entries()) {
|
||||
assert.equal(row.x, 432); assert.equal(row.y, 80 + i * 24);
|
||||
assert.equal(s.description_rows[i].x, 538);
|
||||
assert.equal(s.description_rows[i].y, row.y);
|
||||
}
|
||||
for (const [width, height] of [[1920, 1080], [1280, 720], [1600, 1200], [3440, 1440]]) {
|
||||
const project = elem => ({ x: elem.x * width / 640, y: elem.y * height / 480 });
|
||||
for (const elem of [...records, ...s.elems]) {
|
||||
assert.equal(elem.horzAlign, 'fullscreen');
|
||||
assert.equal(elem.vertAlign, 'fullscreen');
|
||||
const point = project(elem);
|
||||
assert(point.x > width / 2 && point.x < width);
|
||||
assert(point.y < height * 0.6, 'leave scores and ammo clear');
|
||||
}
|
||||
assert.equal(width - project(records[0]).x, 7 * width / 640);
|
||||
if (width === 1920) assert(project(records[0]).y > 27, 'clear the reference Plutonium tag');
|
||||
}
|
||||
});
|
||||
check(`${game} new and legacy shader helpers retain integer dimensions`, () => {
|
||||
const e = engine(game);
|
||||
// Native GSC max returns VAR_FLOAT even for integral values. Boxed
|
||||
// numbers retain that distinction here; the explicit int() unboxes it.
|
||||
e.c.max = (...args) => new Number(Math.max(...args));
|
||||
const legacy = e.c.make_panel_shader(e.self, 0, 0, 220, 100,
|
||||
e.level.hud_c_panel, e.level.hud_panel_alpha, 0);
|
||||
assert.equal(legacy.width, 165);
|
||||
assert.equal(legacy.alpha, 0.72);
|
||||
assert.deepEqual(Array.from(legacy.color), [0.07, 0.10, 0.08]);
|
||||
const overlay = e.c.make_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0.8, 10);
|
||||
assert.deepEqual([overlay.x, overlay.y, overlay.width, overlay.height], [242, 466, 156, 4]);
|
||||
for (const [design, panelWidth, overlayWidth] of [[200, 150, 200], [40, 30, 40], [65.9, 49, 65], [0, 1, 1], [0.1, 1, 1]]) {
|
||||
assert.equal(e.c.make_panel_width(design), panelWidth);
|
||||
assert.equal(e.c.make_overlay_width(design), overlayWidth);
|
||||
legacy.SetShader('white', e.c.make_panel_width(design), 4);
|
||||
overlay.SetShader('white', e.c.make_overlay_width(design), 4);
|
||||
}
|
||||
assert.throws(() => overlay.SetShader('white', e.c.max(1, 156), 4), /requires integer dimensions/);
|
||||
const oldCore = e.c.make_core_text(e.self, 10, 10, 'left', 1.1, [1, 1, 1]);
|
||||
const oldShader = e.c.make_core_shader(e.self, 10, 80, 200, 4, [1, 1, 1], 0.5, 10);
|
||||
e.c.toggle_core_hud();
|
||||
assert.equal(oldCore.alpha, 0); assert.equal(oldShader.alpha, 0);
|
||||
e.c.toggle_core_hud();
|
||||
assert.equal(oldCore.alpha, 1); assert.equal(oldShader.alpha, 0.5);
|
||||
});
|
||||
check(`${game} explicit service rows fit nominal font bounds without newline dependence`, () => {
|
||||
const e = engine(game, 8, true);
|
||||
e.record(snapshot(['One', 'Two', 'Three']));
|
||||
const height = elem => Math.trunc(12 * elem.fontScale);
|
||||
let s = e.state;
|
||||
assert(s.record_title.y + height(s.record_title) <= s.rows[0].y);
|
||||
for (let i = 1; i < s.rows.length; i++)
|
||||
assert(s.rows[i - 1].y + height(s.rows[i - 1]) <= s.rows[i].y);
|
||||
e.help();
|
||||
const rows = [...s.command_rows, ...s.description_rows];
|
||||
assert.equal(new Set(rows).size, 18);
|
||||
assert.equal(new Set(s.command_rows.map(row => row.y)).size, 9);
|
||||
assert(rows.every(row => row.alpha === 1 && !/[\r\n]/.test(row.text)));
|
||||
for (let i = 0; i < s.command_rows.length; i++) {
|
||||
const command = s.command_rows[i], description = s.description_rows[i];
|
||||
assert.equal(command.y, description.y);
|
||||
if (i > 0) assert(s.command_rows[i - 1].y + height(s.command_rows[i - 1]) < command.y);
|
||||
assert(command.y + height(command) < 320);
|
||||
}
|
||||
assert(e.elements.filter(elem => elem.fontScale !== undefined).every(elem => elem.fontScale >= 1.0));
|
||||
assert.equal(e.c.make_text_elem(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
|
||||
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
|
||||
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 1.6, [1, 1, 1]).fontScale, 1.6);
|
||||
});
|
||||
check(`${game} help rebuilds after records, caching each new view without allocation overlap`, () => {
|
||||
const e = engine(game, 8, true);
|
||||
e.help();
|
||||
const rows = [...e.state.command_rows, ...e.state.description_rows];
|
||||
const calls = e.textCalls;
|
||||
e.help(); e.advance(11000);
|
||||
assert.equal(e.textCalls, calls);
|
||||
e.record(snapshot()); e.advance(1000);
|
||||
assert(rows.every(row => row.destroyCalls === 1));
|
||||
assert.equal(e.live.length, 4);
|
||||
e.help();
|
||||
assert.equal(e.live.length, 19);
|
||||
assert(e.state.command_rows.every(row => !rows.includes(row)));
|
||||
assert.equal(e.peakLive, 19);
|
||||
const recreatedCalls = e.textCalls;
|
||||
e.help();
|
||||
assert.equal(e.textCalls, recreatedCalls);
|
||||
e.event('disconnect');
|
||||
assert(e.elements.every(row => row.destroyCalls === 1));
|
||||
});
|
||||
for (const event of ['disconnect', 'end_game', 'game_ended', 'intermission'])
|
||||
check(`${game} ${event} after idle cannot double-destroy or recreate a released view`, () => {
|
||||
const e = engine(game);
|
||||
e.record(snapshot()); e.advance(12000);
|
||||
assert.equal(e.live.length, 0);
|
||||
const allocated = e.elements.length;
|
||||
if (event === 'disconnect') e.event(event); else e.end(event);
|
||||
assert(e.state.closed);
|
||||
e.advance(60000);
|
||||
if (event !== 'disconnect') { e.help(); e.record(snapshot()); e.event('disconnect'); }
|
||||
assert.equal(e.elements.length, allocated);
|
||||
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||
});
|
||||
check(`${game} script entrypoints and common references resolve`, () => {
|
||||
const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1]));
|
||||
const folder = new URL(`${game}/scripts/`, root);
|
||||
const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc'));
|
||||
assert.equal(scripts.length, 9);
|
||||
let connected = 0;
|
||||
for (const file of scripts) {
|
||||
const source = readFileSync(new URL(file, folder), 'utf8');
|
||||
assert.match(source, /^init\(\)/m);
|
||||
if (game === 't6') {
|
||||
assert.match(source, /^main\(\)/m);
|
||||
assert(!/scripts\\sp\\|maps\\_utility|#"zombiemode"/.test(source));
|
||||
}
|
||||
connected += (source.match(/waittill\("connected"/g) ?? []).length;
|
||||
for (const ref of source.matchAll(/tws_t[56]_common::(\w+)/g)) assert(functions.has(ref[1]), `${file} missing ${ref[1]}`);
|
||||
}
|
||||
assert.equal(connected, 1);
|
||||
});
|
||||
}
|
||||
function eSource(game) { return readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); }
|
||||
check('T5 service transitions preserve actual core element identities, styles, data and visibility', () => {
|
||||
const e = engine('t5', 8, true);
|
||||
e.createCore();
|
||||
const core = Array.from(e.self.tws_core_hud_elems);
|
||||
assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown');
|
||||
assert.equal(e.live.length, 12);
|
||||
e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES');
|
||||
e.self.tws_hud_health.SetValue(100);
|
||||
e.self.tws_hud_maxhealth.SetValue(100);
|
||||
e.c.set_core_alpha(e.self, e.self.tws_hud_bar, 1);
|
||||
const state = () => core.map(elem => ({ alpha: elem.alpha, text: elem.text, value: elem.value,
|
||||
x: elem.x, y: elem.y, width: elem.width, height: elem.height, shader: elem.shader,
|
||||
color: elem.color, fontScale: elem.fontScale, alignX: elem.alignX,
|
||||
textCalls: elem.textCalls, valueCalls: elem.valueCalls, destroyCalls: elem.destroyCalls }));
|
||||
const initial = state();
|
||||
e.advance(3000);
|
||||
e.record(snapshot(['Jeff', 'Lokester', 'Moniialx']));
|
||||
const controller = e.state, tasks = e.tasks.length;
|
||||
assert.equal(e.live.length, 16);
|
||||
assert.deepEqual(state(), initial);
|
||||
e.advance(12000);
|
||||
assert.equal(e.state, controller);
|
||||
assert.equal(e.state.closed, false, 'idle releases the view, not its controller');
|
||||
assert.equal(e.state.elems.length, 0);
|
||||
assert.equal(e.live.length, 12);
|
||||
assert.deepEqual(state(), initial);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
|
||||
assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion');
|
||||
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
|
||||
e.c.toggle_core_hud();
|
||||
assert(core.every(elem => elem.alpha === 0));
|
||||
const allocations = e.elements.length;
|
||||
e.record(snapshot(['Latest']));
|
||||
assert.equal(e.elements.length, allocations, 'a pending record must not allocate another view');
|
||||
e.advance(12000);
|
||||
assert.equal(e.state.rows[0].text, 'Latest');
|
||||
assert.equal(e.state.record.remaining, 12000);
|
||||
assert.equal(e.live.length, 16);
|
||||
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
|
||||
e.c.toggle_core_hud();
|
||||
assert.deepEqual(state(), initial);
|
||||
e.advance(12000);
|
||||
assert.equal(e.live.length, 12);
|
||||
assert.equal(e.state.elems.length, 0);
|
||||
assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers');
|
||||
assert.deepEqual(state(), initial);
|
||||
}
|
||||
assert.equal(e.peakLive, 31);
|
||||
assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core);
|
||||
e.event('disconnect');
|
||||
assert.equal(e.live.length, 0);
|
||||
assert(e.elements.every(elem => elem.destroyCalls === 1));
|
||||
});
|
||||
check('paired service requests, rendering and lifecycle remain identical', () => {
|
||||
const sections = game => {
|
||||
const source = eSource(game);
|
||||
const service = source.slice(source.indexOf('show_help()\n{'), source.indexOf('on_player_connect_dispatch()\n{'));
|
||||
assert(service.length > 500, 'parity comparison must include actual service functions');
|
||||
return service;
|
||||
};
|
||||
assert.deepEqual(sections('t5'), sections('t6'));
|
||||
});
|
||||
console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);
|
||||
@@ -0,0 +1,227 @@
|
||||
// Execute the actual T6 join functions with a deterministic event/timer shim.
|
||||
// This checks script decisions, not GSC compilation or live engine behavior.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = readFileSync(new URL('../t6/scripts/tws_t6_join.gsc', import.meta.url), 'utf8');
|
||||
const functions = [];
|
||||
for (const name of ['join_spawn', 'join_track_death', 'join_request']) {
|
||||
const match = new RegExp(`^${name}\\(\\)\\s*\\n\\{`, 'm').exec(source);
|
||||
// The original implementation has no death observer; allow regression runs.
|
||||
if (!match && name === 'join_track_death') continue;
|
||||
assert(match, `missing ${name}`);
|
||||
let end = match.index + match[0].length, depth = 1;
|
||||
const start = end;
|
||||
while (depth) {
|
||||
assert(end < source.length, `unclosed ${name}`);
|
||||
const ch = source[end++];
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') depth--;
|
||||
}
|
||||
let body = source.slice(start, end - 1).replace(/\/\/[^\n]*/g, '');
|
||||
body = body
|
||||
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
|
||||
.replace(/self thread (\w+)\(\);/g, 'startThread($1);')
|
||||
.replace(/self waittill\(([^;]+)\);/g, 'yield { entity: self, event: $1 };')
|
||||
.replace(/\bwait ([\d.]+);/g, 'yield { seconds: $1 };')
|
||||
.replace(/self iPrintLnBold\(/g, 'messages.push(')
|
||||
.replace(/self \[\[level.spawnplayer\]\]\(\);/g, 'spawnPlayer();')
|
||||
.replace(/maps\\mp\\zombies\\_zm::spectator_respawn/g, 'stockRespawn')
|
||||
.replace(/!join_has_active_player\(\)/g, '!activePlayer')
|
||||
.replace(/\.size\b/g, '.length');
|
||||
functions.push(`function* ${name}() { let points; ${body} }`);
|
||||
}
|
||||
|
||||
function engine() {
|
||||
let now = 0, current;
|
||||
const tasks = [], messages = [];
|
||||
const self = { sessionstate: 'playing', spectator_respawn: {} };
|
||||
const level = { round_number: 3, spawnplayer: true };
|
||||
let spawns = 0;
|
||||
function resume(task) {
|
||||
const previous = current;
|
||||
current = task;
|
||||
const result = task.generator.next();
|
||||
task.done = result.done;
|
||||
task.wait = result.value;
|
||||
if (task.wait?.seconds !== undefined) task.at = now + task.wait.seconds;
|
||||
else task.at = Infinity;
|
||||
current = previous;
|
||||
}
|
||||
function startThread(fn) {
|
||||
const task = { generator: fn(), ends: [] };
|
||||
tasks.push(task);
|
||||
resume(task);
|
||||
}
|
||||
function event(entity, event) {
|
||||
const listeners = tasks.filter((task) => !task.done);
|
||||
for (const task of listeners) {
|
||||
if (task.ends.some(([e, name]) => e === entity && name === event)) task.done = true;
|
||||
}
|
||||
for (const task of listeners) {
|
||||
if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
|
||||
}
|
||||
}
|
||||
function advance(seconds) {
|
||||
const until = now + seconds;
|
||||
for (;;) {
|
||||
const next = tasks.filter((task) => !task.done && task.at <= until)
|
||||
.sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
now = next.at;
|
||||
resume(next);
|
||||
}
|
||||
now = until;
|
||||
}
|
||||
const c = vm.createContext({
|
||||
self, level, messages, activePlayer: true, stockRespawn: () => {},
|
||||
isDefined: (value) => value !== undefined && value !== null,
|
||||
is_true: (value) => value === true || value === 1,
|
||||
endon: (entity, event) => current.ends.push([entity, event]),
|
||||
startThread,
|
||||
getstructarray: () => [{}], randomInt: () => 0,
|
||||
spawnPlayer: () => {
|
||||
spawns++;
|
||||
self.sessionstate = 'playing';
|
||||
event(self, 'spawned_player');
|
||||
},
|
||||
});
|
||||
vm.runInContext(functions.join('\n'), c);
|
||||
return { self, level, c, messages, tasks, advance,
|
||||
start: () => startThread(c.join_spawn),
|
||||
request: () => startThread(c.join_request),
|
||||
event: (name) => event(self, name),
|
||||
end: (name) => event(level, name),
|
||||
get spawns() { return spawns; },
|
||||
};
|
||||
}
|
||||
|
||||
let checks = 0;
|
||||
function check(name, run) {
|
||||
try { run(); checks++; }
|
||||
catch (error) { throw new Error(name, { cause: error }); }
|
||||
}
|
||||
function newcomer() {
|
||||
const e = engine();
|
||||
e.start(); // Common's once-per-connection callback, at spawned_player.
|
||||
e.advance(0.05);
|
||||
e.self.sessionstate = 'spectator'; // Stock delayed setup transition.
|
||||
e.event('spawned_spectator');
|
||||
e.advance(0.1);
|
||||
return e;
|
||||
}
|
||||
|
||||
check('Farm round 3 setup spawn leaves a newcomer eligible for exactly one join', () => {
|
||||
const e = newcomer();
|
||||
assert(!e.self.tws_join_has_played);
|
||||
e.request();
|
||||
assert.equal(e.spawns, 1);
|
||||
assert.equal(e.self.tws_join_used, true);
|
||||
e.request();
|
||||
e.self.sessionstate = 'spectator';
|
||||
e.event('killed_player');
|
||||
e.level.round_number++;
|
||||
e.request();
|
||||
assert.equal(e.spawns, 1);
|
||||
assert.match(e.messages.at(-1), /only for newcomers/);
|
||||
});
|
||||
check('setup transition blocks requests until settled', () => {
|
||||
const e = engine();
|
||||
e.start();
|
||||
e.advance(0.05);
|
||||
e.self.sessionstate = 'spectator';
|
||||
e.request();
|
||||
assert.equal(e.spawns, 0);
|
||||
assert.match(e.messages.at(-1), /waiting to enter/);
|
||||
e.advance(0.1);
|
||||
e.request();
|
||||
assert.equal(e.spawns, 1);
|
||||
});
|
||||
check('a normal round-start spawn closes eligibility even after later death', () => {
|
||||
const e = newcomer();
|
||||
e.level.round_number++;
|
||||
e.self.sessionstate = 'playing';
|
||||
e.event('spawned_player');
|
||||
e.advance(0.11);
|
||||
assert.equal(e.self.tws_join_has_played, true);
|
||||
e.self.sessionstate = 'spectator';
|
||||
e.request();
|
||||
assert.equal(e.spawns, 0);
|
||||
assert.match(e.messages.at(-1), /only for newcomers/);
|
||||
});
|
||||
check('round-1 participants cannot join again after death', () => {
|
||||
const e = engine();
|
||||
e.level.round_number = 1;
|
||||
e.start();
|
||||
e.advance(0.11);
|
||||
e.self.sessionstate = 'spectator';
|
||||
e.request();
|
||||
assert.equal(e.spawns, 0);
|
||||
assert.equal(e.self.tws_join_has_played, true);
|
||||
});
|
||||
for (const initial of [true, false]) {
|
||||
check(`death during ${initial ? 'initial' : 'later'} spawn settling consumes eligibility`, () => {
|
||||
const e = initial ? engine() : newcomer();
|
||||
if (initial) e.start();
|
||||
else {
|
||||
e.self.sessionstate = 'playing';
|
||||
e.event('spawned_player');
|
||||
}
|
||||
e.advance(0.02);
|
||||
e.event('killed_player');
|
||||
e.self.sessionstate = 'spectator';
|
||||
e.advance(0.11);
|
||||
e.request();
|
||||
assert.equal(e.self.tws_join_has_played, true);
|
||||
assert.equal(e.spawns, 0);
|
||||
});
|
||||
}
|
||||
check('repeated spectator setup events keep listening for an actual spawn', () => {
|
||||
const e = newcomer();
|
||||
e.event('spawned_player');
|
||||
e.advance(0.11);
|
||||
assert(!e.self.tws_join_has_played);
|
||||
e.self.sessionstate = 'playing';
|
||||
e.event('spawned_player');
|
||||
e.advance(0.11);
|
||||
assert.equal(e.self.tws_join_has_played, true);
|
||||
});
|
||||
for (const name of ['disconnect', 'end_game', 'game_ended', 'intermission']) {
|
||||
check(`${name} stops observers during setup and while waiting`, () => {
|
||||
for (const setup of [true, false]) {
|
||||
const e = setup ? engine() : newcomer();
|
||||
if (setup) e.start();
|
||||
if (name === 'disconnect') e.event(name);
|
||||
else e.end(name);
|
||||
e.advance(1);
|
||||
e.event('killed_player');
|
||||
assert(!e.self.tws_join_has_played);
|
||||
assert(e.tasks.every((task) => task.done));
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const [name, set] of [
|
||||
['intermission', (e) => { e.level.intermission = true; }],
|
||||
['game ended', (e) => { e.level.gameended = true; }],
|
||||
['no active player', (e) => { e.c.activePlayer = false; }],
|
||||
['missing round', (e) => { delete e.level.round_number; }],
|
||||
['missing spawn callback', (e) => { delete e.level.spawnplayer; }],
|
||||
]) {
|
||||
check(`${name} still rejects joining`, () => {
|
||||
const e = newcomer();
|
||||
set(e);
|
||||
e.request();
|
||||
assert.equal(e.spawns, 0);
|
||||
assert.match(e.messages.at(-1), /not ready/);
|
||||
});
|
||||
}
|
||||
check('reconnecting starts independent eligibility', () => {
|
||||
const first = newcomer();
|
||||
first.request();
|
||||
first.event('disconnect');
|
||||
const second = newcomer();
|
||||
second.request();
|
||||
assert.equal(second.spawns, 1);
|
||||
});
|
||||
console.log(`PASS ${checks} T6 join lifecycle/request checks (engine shims; live validation required)`);
|
||||
+293
-9
@@ -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.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -34,9 +34,13 @@ function functions(source) {
|
||||
let body = protectedSource.slice(headers.lastIndex, end - 1);
|
||||
// Each GSC function has its own implicit locals, including recursion.
|
||||
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)]);
|
||||
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;
|
||||
}
|
||||
@@ -44,15 +48,17 @@ function functions(source) {
|
||||
function engine(game) {
|
||||
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
|
||||
const all = functions(source);
|
||||
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy', 'lb_chat_listener', 'lb_request', 'lb_hud_disconnect']);
|
||||
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 = [];
|
||||
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 logs = [], queries = [], responses = [];
|
||||
let now = 1000000;
|
||||
const context = vm.createContext({
|
||||
level: { tws_lb_game: game, tws_lb_override: '', tws_lb_bytes: table },
|
||||
GetDvar: (key) => dvars[key] ?? '',
|
||||
getTime: () => 1000000,
|
||||
getTime: () => now,
|
||||
spawnStruct: () => ({}),
|
||||
isDefined: (value) => value !== undefined && value !== null,
|
||||
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(); },
|
||||
});
|
||||
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) {
|
||||
@@ -111,6 +186,193 @@ for (const game of ['t5', 't6']) {
|
||||
c.level.tws_lb_cache = undefined;
|
||||
assert.equal(queries.length, 0);
|
||||
});
|
||||
check(`${game} common owns the record service window`, () => {
|
||||
const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common';
|
||||
assert(source.includes(`self ${commonPath}::show_record(snapshot);`));
|
||||
assert(!source.includes('lb_show_hud'));
|
||||
assert(!source.includes('lb_hud_'));
|
||||
assert(!source.includes('tws_help_elems'));
|
||||
assert(!source.includes('iPrintLnBold'));
|
||||
assert(!source.includes('make_text_elem'));
|
||||
assert(!source.includes('tws_t5_hud') && !source.includes('tws_t6_hud'));
|
||||
});
|
||||
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
|
||||
const calls = [];
|
||||
c.self = { notify: (event) => calls.push(['notify', event]) };
|
||||
c.recordService = (snapshot) => calls.push(['record', snapshot]);
|
||||
const request = all.get('lb_request')
|
||||
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
|
||||
.replace(/\bself notify\(/g, 'self.notify(')
|
||||
.replace(/\bself scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService(');
|
||||
vm.runInContext(request, c);
|
||||
for (const cached of [undefined, { saved_at: 1, round: 52, lines: ['Alice'] }]) {
|
||||
c.level.tws_lb_cache = cached;
|
||||
c.lb_request(false);
|
||||
assert.deepEqual(calls, []);
|
||||
c.lb_request(true);
|
||||
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', undefined]]);
|
||||
calls.length = 0;
|
||||
}
|
||||
const snapshot = { saved_at: 1000000, round: 0, lines: [] };
|
||||
c.level.tws_lb_cache = snapshot;
|
||||
c.lb_request(false);
|
||||
assert.deepEqual(calls, [['record', snapshot]]);
|
||||
calls.length = 0;
|
||||
c.lb_request(true);
|
||||
assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]);
|
||||
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`, () => {
|
||||
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é 🧟');
|
||||
@@ -193,11 +455,33 @@ for (const game of ['t5', 't6']) {
|
||||
assert.equal(c.lb_refresh(), undefined);
|
||||
c.level.tws_lb_override = '';
|
||||
});
|
||||
check(`${game} all holders fit bounded messages`, () => {
|
||||
check(`${game} all holders fit bounded service rows`, () => {
|
||||
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
|
||||
const lines = Array.from(c.lb_holder_lines(names));
|
||||
assert(lines.every((line) => line.length <= 100));
|
||||
assert(lines.every((line) => c.lb_holder_width(line) <= 16));
|
||||
assert.deepEqual(lines.join(', ').split(', '), names);
|
||||
assert.deepEqual(Array.from(c.lb_holder_lines([])), []);
|
||||
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']);
|
||||
});
|
||||
check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => {
|
||||
const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5),
|
||||
'abcdefghijklééé', '^1Alice\n^7 René 🧟', '^1\n\t'];
|
||||
for (const sample of samples) {
|
||||
const value = c.lb_holder_name(bytes(sample));
|
||||
assert(c.lb_holder_width(value) <= 16, sample);
|
||||
assert(c.lb_utf8_valid(value), sample);
|
||||
assert(!text(value).includes('\uFFFD'), sample);
|
||||
assert(!/[\x00-\x1f\x7f^]/.test(value), sample);
|
||||
assert(value.length > 0, sample);
|
||||
}
|
||||
assert.equal(c.lb_holder_name('M'.repeat(64)), 'M'.repeat(13) + '...');
|
||||
assert.equal(text(c.lb_holder_name(bytes('🧟'.repeat(16)))), '🧟'.repeat(6) + '...');
|
||||
assert.equal(c.lb_holder_name('^1\n\t'), 'Unknown player');
|
||||
assert.equal(c.lb_holder_width(bytes('René 🧟')), 8);
|
||||
const names = Array(100).fill(bytes('同じ名前'.repeat(5)));
|
||||
const rows = Array.from(c.lb_holder_lines(names));
|
||||
assert.equal(rows.length, names.length, 'identical long names still represent every holder');
|
||||
assert(rows.every((line) => c.lb_holder_width(line) <= 16 && c.lb_utf8_valid(line)));
|
||||
});
|
||||
if (game === 't6') {
|
||||
const variants = [
|
||||
@@ -230,7 +514,7 @@ check('paired data helpers stay identical', () => {
|
||||
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'));
|
||||
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::');
|
||||
assert.equal(normalized, fn, name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Exercise the real GSC identity adapter under a small VM shim; native policy tests are separate.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
for (const game of ['t5','t6']) {
|
||||
const source=readFileSync(new URL(`../${game}/scripts/tws_${game}_moderation.gsc`,import.meta.url),'utf8');
|
||||
assert.equal((source.match(/level waittill\("connected"/g)??[]).length,0,'must use the shared connection dispatcher');
|
||||
assert(source.includes('register_connect_handler(::moderation_connected)'));
|
||||
assert(!source.includes('executeCommand'),'all effects belong to the native synchronous adapter');
|
||||
const start=source.indexOf('moderation_observe(player)\n{');
|
||||
const end=source.indexOf('\nmoderation_loop()',start);
|
||||
let body=source.slice(start,end).replace('moderation_observe(player)','function observe(player)');
|
||||
body=body.replace(' guid =',' var guid =').replace(' name =',' var name =');
|
||||
body=body.replace('player getGuid()','getGuid(player)').replaceAll('typeof(', 'kind(').replace('player moderationObserve(guid, name)', 'record(player,guid,name)');
|
||||
const calls=[];
|
||||
const c=vm.createContext({isDefined:v=>v!==null&&v!==undefined,kind:v=>typeof v==='number'?(Number.isInteger(v)?'int':'float'):typeof v,
|
||||
getGuid:p=>p.identity,record:(...args)=>calls.push(args)});
|
||||
vm.runInContext(body,c);
|
||||
const player=identity=>({identity,guid:identity,name:'same name'});
|
||||
c.observe(player('18446744073709551615')); assert.equal(calls.at(-1)[1],'18446744073709551615');
|
||||
c.observe(player(2741626)); assert.equal(calls.at(-1)[1],'2741626');
|
||||
c.observe(player('0000123')); assert.equal(calls.at(-1)[1],'0000123');
|
||||
const count=calls.length; for(const value of [undefined,null,1.5,{},[]])c.observe(player(value)); assert.equal(calls.length,count);
|
||||
c.observe(undefined); assert.equal(calls.length,count);
|
||||
c.observe({...player('abc'),name:undefined}); assert.equal(calls.at(-1)[2],'Unknown player');
|
||||
let loop=source.slice(source.indexOf('moderation_loop()\n{'))
|
||||
.replace('moderation_loop()', 'function* loop()').replaceAll(/wait ([0-9.]+);/g, 'yield $1;')
|
||||
.replace('players = getPlayers();', 'var players = getPlayers();').replace('for (i = 0;', 'for (var i = 0;').replace('players.size', 'players.length');
|
||||
let enabled=true, configured=false, stopped=0, pumped=0, observed=0;
|
||||
const level={moderation_ready:false};
|
||||
const runtime=vm.createContext({level,GetDvar:key=>key==='tws_moderation_enabled'?(enabled?'1':'0'):'configured',GetDvarInt:()=>1,
|
||||
moderationConfigure:()=>configured,moderationStop:()=>++stopped,moderationPump:()=>++pumped,getPlayers:()=>[{guid:'123'}],moderation_observe:()=>++observed});
|
||||
vm.runInContext(loop,runtime); const poll=runtime.loop();
|
||||
assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false);
|
||||
configured=true; assert.equal(poll.next().value,0.5); assert.equal(level.moderation_ready,true);
|
||||
assert.equal(pumped,1); assert.equal(observed,1);
|
||||
enabled=false; assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false); assert.equal(stopped,1);
|
||||
enabled=true; assert.equal(poll.next().value,0.5); assert.equal(pumped,2);
|
||||
console.log(`PASS ${game}: actual moderation identity, retry/disable/resume loop and shared dispatcher integration`);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
|
||||
|
||||
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
|
||||
pointing to the single header at the revision in the plugin's upstream.json.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
|
||||
args = parser.parse_args()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
|
||||
directory = Path(temp)
|
||||
for game in ("t5", "t6"):
|
||||
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
|
||||
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
|
||||
if not header.is_file():
|
||||
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
|
||||
shutil.copyfile(header, directory / "json.hpp")
|
||||
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
|
||||
executable = directory / game
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
|
||||
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
|
||||
"-o", str(executable)], check=True)
|
||||
work = directory / f"{game}-data"
|
||||
subprocess.run([str(executable), str(work)], check=True)
|
||||
# Separate OS processes prove snapshots/identities don't depend on VM or
|
||||
# DLL memory. A newly booted producer recovers before its own start.
|
||||
subprocess.run([str(executable), str(work), "produce"], check=True)
|
||||
subprocess.run([str(executable), str(work), "recover"], check=True)
|
||||
subprocess.run([str(executable), str(work), "empty"], check=True)
|
||||
|
||||
# Compile the actual registration adapter as well, substituting only
|
||||
# engine interfaces. This does not validate the Windows ABI/VM linker.
|
||||
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
|
||||
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
|
||||
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
|
||||
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
|
||||
template <typename F> void add(const char* name, F callback)
|
||||
{
|
||||
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
|
||||
{
|
||||
if constexpr (std::is_invocable_v<F>) return callback();
|
||||
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
|
||||
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
|
||||
};
|
||||
}
|
||||
""")
|
||||
(directory / "component_stub.hpp").write_text(stub)
|
||||
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
|
||||
source = directory / "component.cpp"
|
||||
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
|
||||
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
|
||||
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
|
||||
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
|
||||
lines = result.stdout.splitlines()
|
||||
for operation in ("load", "write", "remove"):
|
||||
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
|
||||
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
|
||||
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
|
||||
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test native bank storage/acknowledgments with ASan/UBSan, without the VM.
|
||||
|
||||
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
|
||||
pointing to the single header at the revision in the plugin's upstream.json.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
|
||||
args = parser.parse_args()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tws-bank-") as temp:
|
||||
directory = Path(temp)
|
||||
for game in ("t5", "t6"):
|
||||
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
|
||||
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
|
||||
if not header.is_file():
|
||||
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
|
||||
shutil.copyfile(header, directory / "json.hpp")
|
||||
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
|
||||
executable = directory / game
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
|
||||
str(plugin / "src/utils/bank.cpp"), str(ROOT / "tools/tests/bank_cases.cpp"),
|
||||
"-o", str(executable)], check=True)
|
||||
work = directory / f"{game}-data"
|
||||
subprocess.run([str(executable), str(work)], check=True)
|
||||
# Separate OS processes prove snapshots/identities don't depend on VM or
|
||||
# DLL memory. A newly booted producer recovers before its own start.
|
||||
subprocess.run([str(executable), str(work), "produce"], check=True)
|
||||
subprocess.run([str(executable), str(work), "recover"], check=True)
|
||||
subprocess.run([str(executable), str(work), "empty"], check=True)
|
||||
|
||||
# Compile the actual registration adapter as well, substituting only
|
||||
# engine interfaces. This does not validate the Windows ABI/VM linker.
|
||||
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
|
||||
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
|
||||
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
|
||||
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
|
||||
template <typename F> void add(const char* name, F callback)
|
||||
{
|
||||
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
|
||||
{
|
||||
if constexpr (std::is_invocable_v<F>) return callback();
|
||||
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
|
||||
else return callback(args.at(0), args.at(1), args.at(2));
|
||||
};
|
||||
}
|
||||
""")
|
||||
(directory / "component_stub.hpp").write_text(stub)
|
||||
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/bank.cpp").read_text(), flags=re.MULTILINE)
|
||||
source = directory / "component.cpp"
|
||||
source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/bank_component_cases.cpp').read_text())
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
|
||||
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
|
||||
str(plugin / "src/utils/bank.cpp"), "-o", str(directory / "component")], check=True)
|
||||
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
|
||||
lines = result.stdout.splitlines()
|
||||
for operation in ("load", "write", "remove"):
|
||||
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
|
||||
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
|
||||
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
|
||||
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
|
||||
@@ -66,9 +66,9 @@ def main():
|
||||
base = f"http://127.0.0.1:{http.server_address[1]}"
|
||||
env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"}
|
||||
|
||||
def probe(path, connect="1000", total="1000"):
|
||||
def probe(path, connect="1000", total="1000", cap=None):
|
||||
url = path if "://" in path else base + path
|
||||
completed = subprocess.run([str(executable), url, connect, total], env=env, capture_output=True, text=True, check=True, timeout=8)
|
||||
completed = subprocess.run([str(executable), url, connect, total] + ([] if cap is None else [str(cap)]), env=env, capture_output=True, text=True, check=True, timeout=8)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
normal = probe("/200", "-", "-")
|
||||
@@ -84,6 +84,11 @@ def main():
|
||||
assert timed_out["body_size"] == 0, timed_out
|
||||
if status: assert timed_out["first_byte_ms"] > 0, timed_out
|
||||
assert probe("/200")["curl_code"] == 0 # The next request completes after the stall.
|
||||
exact = probe("/200", cap=11)
|
||||
assert exact["curl_code"] == 0 and exact["body_size"] == 11, exact
|
||||
bounded = probe("/200", cap=10)
|
||||
assert bounded["curl_code"] == 23 and bounded["body_size"] == 0, bounded
|
||||
assert probe("/200")["body_size"] == 11
|
||||
slow = probe("/slow")
|
||||
assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow
|
||||
tls_url = f"https://127.0.0.1:{tls.server_address[1]}/"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile both real native moderation cores with ASan/UBSan (no Windows/VM emulation)."""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--json-header', help='Pinned single header; {game} expands to t5/t6')
|
||||
a=p.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='tws-moderation-tests-') as tmp:
|
||||
temp=Path(tmp)
|
||||
for game in ('t5','t6'):
|
||||
plugin=ROOT/game/'plugins'/f'tws-{game}-gsc-utils'
|
||||
header=Path(a.json_header.format(game=game)) if a.json_header else plugin/'deps/json/single_include/nlohmann/json.hpp'
|
||||
if not header.is_file(): p.error(f'Missing {header}; restore pinned dependencies or pass --json-header')
|
||||
shutil.copyfile(header,temp/'json.hpp')
|
||||
(temp/'stdinc.hpp').write_text('#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n')
|
||||
exe=temp/game
|
||||
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(ROOT/'tools/tests/moderation_cases.cpp'),'-o',str(exe)],check=True)
|
||||
subprocess.run([str(exe),str(temp/f'{game}-data')],check=True)
|
||||
subprocess.run([str(exe),str(temp/f'{game}-restart'),'write'],check=True)
|
||||
subprocess.run([str(exe),str(temp/f'{game}-restart'),'read'],check=True)
|
||||
component=re.sub(r'^#include[^\n]*\n','',(plugin/'src/component/moderation.cpp').read_text(),flags=re.MULTILINE)
|
||||
(temp/'component.cpp').write_text('#include "moderation_component_stub.hpp"\n#include "moderation.hpp"\n#include <iomanip>\n'+component+(ROOT/'tools/tests/moderation_component_cases.cpp').read_text())
|
||||
shutil.copyfile(ROOT/'tools/tests/moderation_component_stub.hpp',temp/'moderation_component_stub.hpp')
|
||||
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(temp/'component.cpp'),'-o',str(temp/'component')],check=True)
|
||||
component_dir=temp/f'{game}-component'; component_dir.mkdir()
|
||||
subprocess.run([str(temp/'component')],cwd=component_dir,check=True)
|
||||
print(f'PASS {game}: moderation native policy, journal and process restart',flush=True)
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "afk.hpp"
|
||||
#include <json.hpp>
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
using utils::afk::journal;
|
||||
using utils::afk::parse_acknowledgement;
|
||||
using namespace std::chrono_literals;
|
||||
namespace fs = std::filesystem;
|
||||
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
|
||||
std::string reserve(const std::string& id)
|
||||
{
|
||||
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
|
||||
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
|
||||
}
|
||||
std::string request(const std::string& id, const std::string& action)
|
||||
{
|
||||
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
|
||||
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
|
||||
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
|
||||
}
|
||||
template <typename F> void must_fail(F fn)
|
||||
{
|
||||
bool failed = false;
|
||||
try { fn(); } catch (const std::exception&) { failed = true; }
|
||||
assert(failed);
|
||||
}
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
assert(argc >= 2);
|
||||
const fs::path root(argv[1]);
|
||||
const journal queue(root,server,endpoint);
|
||||
if (argc == 3)
|
||||
{
|
||||
const std::string mode(argv[2]);
|
||||
if (mode == "produce")
|
||||
{
|
||||
queue.prepare(reserve("pending"));
|
||||
queue.store(request("pending-start","start"));
|
||||
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
|
||||
queue.prepare(reserve("active")); queue.authorize("active",120);
|
||||
const auto now = journal::clock::now();
|
||||
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
|
||||
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
|
||||
}
|
||||
else if (mode == "recover")
|
||||
{
|
||||
// Separate process: the same match ID cannot recreate a live clock.
|
||||
assert(queue.remaining("active") == -1);
|
||||
must_fail([&]{queue.begin("active");});
|
||||
queue.recover("match");
|
||||
const auto values = queue.load();
|
||||
assert(values.size() == 5);
|
||||
int recovered = 0;
|
||||
for (const auto& value : values)
|
||||
{
|
||||
if (value.action != "settle") continue;
|
||||
auto body = nlohmann::json::parse(value.body);
|
||||
assert(value.id.size() == 25);
|
||||
if (body.at("session_id") == "active")
|
||||
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
|
||||
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
|
||||
++recovered;
|
||||
}
|
||||
assert(recovered == 3);
|
||||
queue.recover("new-match"); // Stable requests are unchanged across restarts.
|
||||
assert(queue.load().size() == values.size());
|
||||
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
|
||||
for (const auto& value : values) queue.erase(value.id);
|
||||
}
|
||||
else assert(mode == "empty" && queue.load().empty());
|
||||
return 0;
|
||||
}
|
||||
fs::create_directories(root);
|
||||
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
|
||||
assert(queue.load().size() == 1);
|
||||
must_fail([&]{queue.store(request("one","event"));});
|
||||
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
|
||||
must_fail([&]{queue.store(changed.dump());});
|
||||
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
|
||||
assert(other_server.load().empty() && other_endpoint.load().empty());
|
||||
queue.erase("one");
|
||||
queue.store(request("first-event","event"));
|
||||
auto snapshot = nlohmann::json::parse(request("second-event","event"));
|
||||
snapshot["seq"] = 2; snapshot["revives"] = 2;
|
||||
queue.store(snapshot.dump());
|
||||
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
|
||||
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
|
||||
assert(queue.load().size() == 1);
|
||||
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
|
||||
must_fail([&]{queue.store(regressed.dump());});
|
||||
queue.store(snapshot.dump());
|
||||
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
|
||||
queue.erase("second-event");
|
||||
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
|
||||
assert(queue.load().size() == 1);
|
||||
must_fail([&]{queue.begin("normal");});
|
||||
must_fail([&]{queue.authorize("normal",601);});
|
||||
queue.authorize("normal",60); queue.authorize("normal",60);
|
||||
must_fail([&]{queue.authorize("normal",120);});
|
||||
const auto now = journal::clock::now();
|
||||
queue.begin("normal",now); queue.begin("normal",now+20s);
|
||||
assert(queue.remaining("normal",now+1250ms) == 58750);
|
||||
assert(queue.remaining("normal",now+59999ms) == 1);
|
||||
assert(queue.remaining("normal",now+60s) == 0);
|
||||
assert(queue.remaining("normal",now+61s) == 0);
|
||||
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
|
||||
assert(queue.checkpoint("normal",now+59001ms) == 59);
|
||||
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
|
||||
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
|
||||
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
|
||||
auto settlement = nlohmann::json::parse(queue.load()[0].body);
|
||||
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
|
||||
queue.erase(queue.load()[0].id);
|
||||
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
|
||||
assert(queue.checkpoint("expire",now+60s) == 60);
|
||||
queue.stop("expire","expired",now+61s);
|
||||
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
|
||||
queue.erase(queue.load()[0].id);
|
||||
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
|
||||
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
|
||||
queue.erase(queue.load()[0].id);
|
||||
// Interrupted temp writes preserve the prior durable snapshot.
|
||||
const auto file = fs::directory_iterator(root)->path();
|
||||
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
|
||||
fs::create_directory(file.string()+".tmp");
|
||||
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
|
||||
assert(queue.remaining("failure",now+30s) == 0);
|
||||
must_fail([&]{queue.checkpoint("failure",now+30s);});
|
||||
must_fail([&]{queue.begin("failure",now+30s);});
|
||||
fs::remove(file.string()+".tmp");
|
||||
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
|
||||
settlement = nlohmann::json::parse(queue.load()[0].body);
|
||||
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
|
||||
queue.erase(queue.load()[0].id);
|
||||
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
|
||||
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
|
||||
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
|
||||
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
|
||||
assert(parse_acknowledgement(valid.dump()).valid);
|
||||
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
|
||||
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
|
||||
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
|
||||
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
|
||||
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
|
||||
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
|
||||
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
|
||||
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
|
||||
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
|
||||
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
|
||||
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
|
||||
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
|
||||
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
|
||||
fs::remove(file);
|
||||
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Compile the actual GSC registration adapter against portable VM interfaces.
|
||||
#include <fstream>
|
||||
int main()
|
||||
{
|
||||
afk::component component; component.on_startup(nullptr);
|
||||
const auto call = [](const std::string& name, std::vector<std::string> args = {})
|
||||
{ return gsc::function::functions.at(name)(args); };
|
||||
assert(call("afkversion").as<std::string>() == "1.0.0");
|
||||
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
|
||||
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
|
||||
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
|
||||
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
|
||||
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
|
||||
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
|
||||
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
|
||||
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
|
||||
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
|
||||
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
|
||||
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
|
||||
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
|
||||
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
|
||||
std::filesystem::path file;
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
|
||||
if (entry.path().extension() == ".json") file=entry.path();
|
||||
assert(!file.empty());
|
||||
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
|
||||
for (int i=0;i<2;++i)
|
||||
{
|
||||
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
|
||||
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
|
||||
}
|
||||
std::filesystem::remove(file);
|
||||
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
|
||||
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "bank.hpp"
|
||||
#include <json.hpp>
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using utils::bank::outbox;
|
||||
using utils::bank::parse_acknowledgement;
|
||||
const std::string server = "test-server", endpoint = "https://example.test/api/bank";
|
||||
|
||||
std::string body(const std::string& id, const std::string& decision)
|
||||
{
|
||||
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},
|
||||
{"match","match"},{"request_id",id},{"operation","deposit"},
|
||||
{"amount",2000},{"capacity",1000000},{"decision",decision}}.dump();
|
||||
}
|
||||
template <typename F> void must_fail(F action)
|
||||
{
|
||||
bool failed = false;
|
||||
try { action(); } catch (const std::exception&) { failed = true; }
|
||||
assert(failed);
|
||||
}
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
assert(argc >= 2);
|
||||
const fs::path root(argv[1]);
|
||||
const outbox queue(root,server,endpoint);
|
||||
if (argc == 3)
|
||||
{
|
||||
const std::string mode(argv[2]);
|
||||
if (mode == "produce")
|
||||
{
|
||||
queue.store(body("uncertain-prepare","cancel"));
|
||||
queue.store(body("applied-points","cancel"));
|
||||
queue.store(body("applied-points","commit"));
|
||||
}
|
||||
else if (mode == "recover")
|
||||
{
|
||||
const auto entries = queue.load();
|
||||
assert(entries.size() == 2);
|
||||
assert(entries[0].decision == "cancel" && entries[1].decision == "commit");
|
||||
for (const auto& entry : entries) queue.erase(entry.id);
|
||||
}
|
||||
else assert(mode == "empty" && queue.load().empty());
|
||||
return 0;
|
||||
}
|
||||
fs::create_directories(root);
|
||||
assert(queue.load().empty());
|
||||
queue.store(body("one","cancel"));
|
||||
queue.store(body("one","cancel"));
|
||||
assert(queue.load().size() == 1);
|
||||
queue.store(body("one","commit"));
|
||||
queue.store(body("one","commit"));
|
||||
assert(queue.load()[0].decision == "commit");
|
||||
must_fail([&]{queue.store(body("one","cancel"));});
|
||||
auto changed = nlohmann::json::parse(body("one","commit"));
|
||||
changed["amount"] = 100;
|
||||
must_fail([&]{queue.store(changed.dump());});
|
||||
assert(queue.load()[0].body == body("one","commit"));
|
||||
const outbox isolated(root,"another-server",endpoint);
|
||||
assert(isolated.load().empty());
|
||||
const outbox another_endpoint(root,server,endpoint+"/other");
|
||||
assert(another_endpoint.load().empty());
|
||||
// An interrupted temp write cannot replace the committed decision.
|
||||
const auto file = fs::directory_iterator(root)->path();
|
||||
{ std::ofstream stream(file.string()+".tmp"); stream << "incomplete"; }
|
||||
assert(queue.load()[0].decision == "commit");
|
||||
fs::remove(file.string()+".tmp");
|
||||
// A failed replacement preserves cancellation and must stop points moving.
|
||||
queue.erase("one");
|
||||
queue.store(body("two","cancel"));
|
||||
fs::create_directory(file.string()+".tmp");
|
||||
must_fail([&]{queue.store(body("two","commit"));});
|
||||
assert(queue.load()[0].decision == "cancel");
|
||||
fs::remove(file.string()+".tmp");
|
||||
queue.erase("two");
|
||||
queue.erase("two");
|
||||
assert(queue.load().empty());
|
||||
auto valid = nlohmann::json{{"ok",true},{"state","prepared"},{"request_id","one"},
|
||||
{"code","ok"},{"amount",2000},{"balance",3000}};
|
||||
const auto ack = parse_acknowledgement(valid.dump());
|
||||
assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one");
|
||||
auto empty = valid;
|
||||
empty["state"] = "balance"; empty["request_id"] = "";
|
||||
empty["amount"] = 0; empty["balance"] = 0;
|
||||
const auto zero = parse_acknowledgement(empty.dump());
|
||||
assert(zero.valid && zero.amount == 0 && zero.balance == 0);
|
||||
empty["ok"] = false; empty["state"] = "rejected";
|
||||
empty["request_id"] = "one"; empty["code"] = "insufficient_balance";
|
||||
assert(parse_acknowledgement(empty.dump()).valid);
|
||||
for (const auto& field : {"amount","balance"})
|
||||
for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)})
|
||||
{
|
||||
auto value = valid; value[field] = bad;
|
||||
assert(!parse_acknowledgement(value.dump()).valid);
|
||||
}
|
||||
auto bad = valid; bad["ok"] = false;
|
||||
assert(!parse_acknowledgement(bad.dump()).valid);
|
||||
bad = valid; bad["request_id"] = "";
|
||||
assert(!parse_acknowledgement(bad.dump()).valid);
|
||||
bad = valid; bad["state"] = "unknown";
|
||||
assert(!parse_acknowledgement(bad.dump()).valid);
|
||||
for (const auto& text : {"", "<html>bad gateway</html>", "{", "null", "{\"ok\":true}"})
|
||||
assert(!parse_acknowledgement(text).valid);
|
||||
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
|
||||
// Every validation failure exposes a fixed reason and clears response data.
|
||||
const auto invalid = [](const std::string& text, const std::string& reason)
|
||||
{
|
||||
const auto result = parse_acknowledgement(text);
|
||||
assert(!result.valid && result.reason == reason);
|
||||
assert(result.state.empty() && result.code.empty() && result.id.empty());
|
||||
};
|
||||
assert(ack.reason == "ok");
|
||||
invalid("", "empty_body");
|
||||
invalid(std::string(0x5000, 'x'), "body_limit");
|
||||
invalid("secret-token-not-json", "invalid_json");
|
||||
invalid("null", "schema");
|
||||
invalid("{}", "schema");
|
||||
bad = valid; bad["code"] = std::string(65, 'x'); invalid(bad.dump(), "field_limit");
|
||||
bad = valid; bad["state"] = "secret-state"; invalid(bad.dump(), "state");
|
||||
bad = valid; bad["amount"] = -1; invalid(bad.dump(), "amount_range");
|
||||
bad = valid; bad["ok"] = false; invalid(bad.dump(), "ok_state");
|
||||
bad = valid; bad["request_id"] = ""; invalid(bad.dump(), "missing_id");
|
||||
bad = valid; bad["amount"] = 0; invalid(bad.dump(), "zero_transfer");
|
||||
bad = valid; bad["code"] = "private-token\nAuthorization: secret";
|
||||
// Compatibility: valid but unknown API codes remain available to callers;
|
||||
// scripts permit only fixed known codes in diagnostics.
|
||||
assert(parse_acknowledgement(bad.dump()).valid);
|
||||
{ std::ofstream stream(file); stream << "corrupt"; }
|
||||
must_fail([&]{queue.load();});
|
||||
must_fail([&]{queue.store(body("three","cancel"));});
|
||||
must_fail([&]{queue.erase("three");});
|
||||
fs::remove(file);
|
||||
std::cout << "bank decisions, corruption, cancellation, response guards passed\n";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
// Executed with the actual registration adapter and portable engine interfaces.
|
||||
#include <fstream>
|
||||
int main()
|
||||
{
|
||||
bank::component component;
|
||||
component.on_startup(nullptr);
|
||||
const auto call = [](const std::string& name, std::vector<std::string> args = {})
|
||||
{
|
||||
return gsc::function::functions.at(name)(args);
|
||||
};
|
||||
assert(call("bankversion").as<std::string>() == "1.2.0");
|
||||
auto ack = call("bankresponse", {"private-secret-invalid-json"}).as<scripting::array>();
|
||||
assert(!ack["valid"].as<bool>());
|
||||
assert(ack["reason"].as<std::string>() == "invalid_json");
|
||||
assert(ack["code"].as<std::string>().empty());
|
||||
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
|
||||
for (int i = 0; i < 2; ++i)
|
||||
assert(!call("bankstore", {"server", "endpoint", "private-secret Authorization"}).as<bool>());
|
||||
const std::string body = R"({"server":"server","request_id":"one","decision":"cancel"})";
|
||||
assert(call("bankstore", {"server", "endpoint", body}).as<bool>());
|
||||
const auto root = bank::spool("server", "endpoint");
|
||||
assert(root.load().size() == 1);
|
||||
// Locate only the test process's temporary outbox.
|
||||
std::filesystem::path file;
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-bank"))
|
||||
if (entry.path().extension() == ".json") file = entry.path();
|
||||
assert(!file.empty());
|
||||
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
assert(call("bankload", {"server", "endpoint"}).get_raw().type == game::SCRIPT_NONE);
|
||||
assert(!call("bankremove", {"server", "endpoint", "one"}).as<bool>());
|
||||
}
|
||||
std::filesystem::remove(file);
|
||||
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
|
||||
assert(call("bankremove", {"server", "endpoint", "one"}).as<bool>());
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Optional integration with a local EC checkout. Execute its real bank service
|
||||
// and bank migrations against disposable SQLite, with minimal fleet fixtures.
|
||||
// No EC configuration, credentials, HTTP routes or production DB are loaded.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
export function loadEcBank(directory) {
|
||||
assert(directory, '--ec-root requires an EC checkout with installed dependencies');
|
||||
const requireEc = createRequire(resolve(directory, 'package.json'));
|
||||
const ts = requireEc('typescript');
|
||||
const Database = requireEc('better-sqlite3');
|
||||
function compile(file) {
|
||||
const source = readFileSync(resolve(directory, 'src/lib/server', file), 'utf8');
|
||||
return new vm.Script(ts.transpileModule(source, {
|
||||
compilerOptions: {module:ts.ModuleKind.CommonJS, target:ts.ScriptTarget.ES2022}
|
||||
}).outputText, {filename:file});
|
||||
}
|
||||
const service = compile('fleet/bank.ts');
|
||||
const bankMigration = compile('db/bank.ts');
|
||||
const credentialMigration = compile('db/bank-credentials.ts');
|
||||
return guid => {
|
||||
const database = new Database(':memory:');
|
||||
function execute(script) {
|
||||
const context = vm.createContext({
|
||||
exports: {}, process: {env:{}},
|
||||
require: name => {
|
||||
assert.equal(name, '../db/index.js', 'unexpected EC runtime dependency');
|
||||
return {db:() => database};
|
||||
}
|
||||
});
|
||||
script.runInContext(context);
|
||||
return context.exports;
|
||||
}
|
||||
database.exec(`
|
||||
CREATE TABLE native_instances (id TEXT PRIMARY KEY, server TEXT, retired_at TEXT);
|
||||
INSERT INTO native_instances VALUES ('instance', 'server', NULL);
|
||||
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
|
||||
INSERT INTO servers VALUES ('server', NULL);
|
||||
CREATE TABLE leaderboard_matches (id INTEGER PRIMARY KEY, server TEXT, reporter_match_key TEXT, status TEXT);
|
||||
INSERT INTO leaderboard_matches VALUES (1, 'server', 'match', 'active');
|
||||
CREATE TABLE leaderboard_match_players (match_id INTEGER, guid TEXT, present INTEGER);
|
||||
`);
|
||||
database.prepare('INSERT INTO leaderboard_match_players VALUES (1, ?, 1)').run(guid);
|
||||
execute(bankMigration).migrateBank(database);
|
||||
execute(credentialMigration).migrateBankCredentials(database);
|
||||
const {bankRequest} = execute(service);
|
||||
return {
|
||||
request: (action, body) => bankRequest(action, body, 'server', 'instance'),
|
||||
ledger: () => database.prepare('SELECT guid, amount FROM bank_ledger ORDER BY id').all(),
|
||||
requests: () => database.prepare('SELECT request_id, guid, state FROM bank_requests ORDER BY request_id').all(),
|
||||
close: () => database.close()
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc != 4 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
|
||||
if ((argc != 4 && argc != 5) || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
|
||||
return 2;
|
||||
utils::http::timeouts limits;
|
||||
// A dash exercises the defaults compiled into the actual plugin source.
|
||||
if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]);
|
||||
if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]);
|
||||
if (argc == 5) limits.max_response_bytes = std::stoul(argv[4]);
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}",
|
||||
{{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
#include "moderation.hpp"
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
using namespace utils::moderation;
|
||||
static const long long NOW = timestamp("2026-10-15T12:00:00.000Z");
|
||||
static const std::string END = "2026-10-15T12:00:30.000Z";
|
||||
static json reply(int revision, json sanctions = json::array(), json actions = json::array())
|
||||
{
|
||||
if (sanctions.is_null()) sanctions = json::array();
|
||||
if (sanctions.is_object()) sanctions = json::array({sanctions});
|
||||
if (actions.is_object()) actions = json::array({actions});
|
||||
return {{"ok", true}, {"revision", revision}, {"serverTime", "2026-10-15T12:00:00.000Z"}, {"sanctions", sanctions}, {"actions", actions}, {"pollAfterMs", 3000}};
|
||||
}
|
||||
static json policy(std::string kind, std::string expiry = END)
|
||||
{
|
||||
return {{"id", "policy"}, {"revision", 1}, {"guid", "18446744073709551615"}, {"kind", kind}, {"reason", "rule"}, {"expiresAt", expiry.empty() ? json(nullptr) : json(expiry)}};
|
||||
}
|
||||
static json act(std::string id, std::string connection, std::string kind = "kick")
|
||||
{
|
||||
return {{"id", id}, {"guid", "18446744073709551615"}, {"connection", connection}, {"kind", kind}, {"reason", "rule; say bad\n\"bad\\^1"}, {"expiresAt", END}};
|
||||
}
|
||||
template<class F> void rejects(F f) { bool threw = false; try { f(); } catch (...) { threw = true; } assert(threw); }
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
assert(argc >= 2); auto root = std::filesystem::path(argv[1]);
|
||||
runtime game(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a");
|
||||
if (argc == 3)
|
||||
{
|
||||
if (std::string(argv[2]) == "write")
|
||||
{
|
||||
assert(game.request()["sessionSequence"] == 1);
|
||||
game.observe(0, "18446744073709551615", "one"); auto id = game.players.at(0).connection;
|
||||
game.accept(reply(1, {policy("ban", "")}, {act("crash", id)}).dump(), NOW);
|
||||
game.enforce(NOW, [](const auto&, int, const auto&) -> std::string { return "unknown"; });
|
||||
}
|
||||
else
|
||||
{
|
||||
assert(game.request()["sessionSequence"] == 2);
|
||||
assert(game.revision() == 1); assert(game.request()["receipts"][0]["id"] == "crash");
|
||||
assert(game.request()["receipts"][0]["state"] == "unknown");
|
||||
int kicks = 0; game.observe(0, "18446744073709551615", "one");
|
||||
game.enforce(NOW + 10, [&](const auto& kind, int, const auto&) { if (kind == "kick") ++kicks; return "confirmed"; });
|
||||
assert(kicks == 1); // cached permanent policy enforces without a network response
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
assert(canonical_guid("000123") == "123" && canonical_guid("000") == "0");
|
||||
assert(canonical_guid("18446744073709551615") == "18446744073709551615" && canonical_guid("abc001") == "abc001");
|
||||
assert(timestamp("2026-10-15T12:00:00Z") == NOW);
|
||||
for (const auto* invalid : {"garbage", "2026-02-31T12:00:00Z", "2026-10-15T12:00:00+01:00", "2026-10-15T12:00:00.abcZ"}) rejects([&] { timestamp(invalid); });
|
||||
assert(command_text("a\";\\\n\r^1b") == "a 1b");
|
||||
assert(command_text(std::string(239, 'a') + "é") == std::string(239, 'a'));
|
||||
assert(command_text(std::string(238, 'a') + "é") == std::string(238, 'a') + "é");
|
||||
game.observe(1, "000123", std::string(254, 'a') + "é");
|
||||
assert(game.players.at(1).guid == "123" && game.players.at(1).name == std::string(254, 'a'));
|
||||
game.observe(1, "123", std::string("bad") + char(0xff)); assert(game.players.at(1).name == "bad?");
|
||||
game.observe(1, "123", std::string("bad") + char(1) + "name"); assert(game.players.at(1).name == "bad name");
|
||||
for (const std::string blank : {"", " ", "\t\r\n\x01\x7f", "\xc2\xa0\xe2\x80\x83\xef\xbb\xbf"})
|
||||
{
|
||||
game.observe(1, "123", blank); assert(game.players.at(1).name == "Unknown player");
|
||||
assert(game.request()["players"][0]["name"] == "Unknown player");
|
||||
}
|
||||
rejects([&] { game.observe(1, "abc.001", "name"); });
|
||||
game.request().dump(); game.disconnect(1);
|
||||
game.observe(0, "18446744073709551615", "same name"); auto first = game.players.at(0).connection;
|
||||
game.observe(0, "18446744073709551615", "new name"); assert(game.players.at(0).connection == first);
|
||||
game.disconnect(0); game.observe(0, "18446744073709551615", "same name"); assert(game.players.at(0).connection != first);
|
||||
int executed = 0;
|
||||
auto execute = [&](const std::string&, int, const std::string& text) { ++executed; assert(text.find_first_of("\r\n;\"\\^") == std::string::npos); return "confirmed"; };
|
||||
game.accept(reply(1, {}, {act("stale", first)}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 0);
|
||||
assert(game.request()["receipts"][0]["state"] == "failed");
|
||||
const auto valid = act("once", game.players.at(0).connection);
|
||||
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
|
||||
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
|
||||
auto changed = valid; changed["reason"] = "different action";
|
||||
rejects([&] { game.accept(reply(1, {}, {changed}).dump(), NOW); });
|
||||
auto expired = act("expired", game.players.at(0).connection); game.accept(reply(1, {}, {expired}).dump(), NOW + 31); game.enforce(NOW + 31, execute); assert(executed == 1);
|
||||
auto future = reply(2); future["serverTime"] = "2026-10-16T12:00:00Z"; rejects([&] { game.accept(future.dump(), NOW); });
|
||||
rejects([&] { game.accept(reply(0).dump(), NOW); });
|
||||
auto invalid = reply(2, {policy("mute")}); invalid["sanctions"][0]["guid"] = "bad guid"; rejects([&] { game.accept(invalid.dump(), NOW); }); assert(game.revision() == 1);
|
||||
for (const auto* malformed : {"{}", "[]", "not json", "{\"ok\":false}"}) rejects([&] { game.accept(malformed, NOW); });
|
||||
auto too_many = reply(2); for (int i = 0; i < 1001; ++i) too_many["sanctions"].push_back(policy("mute")); rejects([&] { game.accept(too_many.dump(), NOW); });
|
||||
rejects([&] { game.accept(std::string(1024 * 1024 + 1, ' '), NOW); });
|
||||
rejects([&] { game.accept(reply(1, {policy("mute")}).dump(), NOW); });
|
||||
std::vector<std::string> effects;
|
||||
auto record = [&](const auto& kind, int, const auto&) { effects.push_back(kind); return "unknown"; };
|
||||
game.accept(reply(2, {policy("mute")}).dump(), NOW); game.enforce(NOW, record); game.enforce(NOW + 1, record); assert(effects == std::vector<std::string>{"mute"});
|
||||
game.enforce(NOW + 31, record); assert(effects == (std::vector<std::string>{"mute", "unmute"}));
|
||||
auto expired_snapshot = reply(2); expired_snapshot["serverTime"] = "2026-10-15T12:00:31.000Z";
|
||||
game.accept(expired_snapshot.dump(), NOW); // server expiry wins even when local clock lags within allowed skew
|
||||
game.accept(reply(3, {policy("mute", "")}).dump(), NOW); game.enforce(NOW, record);
|
||||
game.accept(reply(4).dump(), NOW); game.enforce(NOW, record); assert(effects.back() == "unmute");
|
||||
game.accept(reply(5, {policy("ban", "")}).dump(), NOW);
|
||||
auto disconnecting = [&](const auto& kind, int slot, const auto&) { assert(kind == "kick"); game.disconnect(slot); return "confirmed"; };
|
||||
game.enforce(NOW, disconnecting); assert(game.players.empty());
|
||||
// Slow host time and buffered responses cannot extend a one-time deadline.
|
||||
{
|
||||
runtime slow(root / "slow-clock", "server", "endpoint", 1, "credential-a");
|
||||
slow.observe(0, "18446744073709551615", "one");
|
||||
slow.accept(reply(1, {}, {act("slow", slow.players.at(0).connection)}).dump(), NOW - 60, 31000);
|
||||
const auto count = executed; slow.enforce(NOW - 60, execute); assert(executed == count);
|
||||
assert(slow.request()["receipts"][0]["state"] == "expired");
|
||||
}
|
||||
// A faster next response cannot roll the conservative server-time anchor back.
|
||||
{
|
||||
runtime monotonic(root / "monotonic-clock", "server", "endpoint", 1, "credential-a");
|
||||
monotonic.observe(0, "18446744073709551615", "one");
|
||||
auto timed = reply(1, {policy("mute", "2026-10-15T12:00:07.000Z")});
|
||||
const auto count = effects.size();
|
||||
monotonic.accept(timed.dump(), NOW, 8000); monotonic.enforce(NOW, record);
|
||||
monotonic.accept(timed.dump(), NOW, 0); monotonic.enforce(NOW, record);
|
||||
assert(effects.size() == count);
|
||||
}
|
||||
// A superseded runtime cannot overwrite a newer session counter or execute.
|
||||
{
|
||||
const auto replacement_root = root / "replacement";
|
||||
runtime older(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
older.observe(0, "18446744073709551615", "one");
|
||||
older.accept(reply(1, {}, {act("superseded", older.players.at(0).connection)}).dump(), NOW);
|
||||
runtime newer(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
const auto count = executed; rejects([&] { older.enforce(NOW, execute); }); assert(executed == count);
|
||||
newer.accept(reply(2).dump(), NOW); assert(newer.request()["sessionSequence"] == 2);
|
||||
runtime recovered(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
assert(recovered.request()["sessionSequence"] == 3 && recovered.revision() == 2);
|
||||
for (const auto& file : std::filesystem::directory_iterator(replacement_root)) std::filesystem::remove(file.path());
|
||||
rejects([&] { recovered.accept(reply(3).dump(), NOW); }); // cache deletion cannot silently reset its sequence
|
||||
}
|
||||
// Managed mute ownership survives restart and allows revocation without clearing unrelated mutes.
|
||||
const auto mute_root = root / "mute-restart";
|
||||
{
|
||||
runtime original(mute_root, "server", "endpoint", 1, "credential-a");
|
||||
original.observe(0, "18446744073709551615", "one");
|
||||
original.accept(reply(1, {policy("mute", "")}).dump(), NOW);
|
||||
original.enforce(NOW, record);
|
||||
}
|
||||
{
|
||||
runtime recovered(mute_root, "server", "endpoint", 1, "credential-a");
|
||||
recovered.accept(reply(2).dump(), NOW);
|
||||
recovered.observe(1, "18446744073709551615", "one");
|
||||
const auto before = effects.size(); recovered.enforce(NOW, record);
|
||||
assert(effects.size() == before + 1 && effects.back() == "unmute");
|
||||
recovered.observe(2, "someone-else", "two"); recovered.enforce(NOW, record);
|
||||
assert(effects.size() == before + 1);
|
||||
}
|
||||
// Pre-dispatch disk failure must prevent the irreversible effect.
|
||||
game.observe(0, "18446744073709551615", "one");
|
||||
game.accept(reply(6, {}, {act("disk", game.players.at(0).connection)}).dump(), NOW);
|
||||
std::filesystem::path journal;
|
||||
for (const auto& file : std::filesystem::directory_iterator(root)) if (file.path().extension() == ".json") journal = file.path();
|
||||
auto temporary = journal; temporary += ".tmp"; std::filesystem::create_directory(temporary);
|
||||
const auto before_disk = executed; rejects([&] { game.enforce(NOW, execute); }); assert(executed == before_disk);
|
||||
std::filesystem::remove(temporary);
|
||||
runtime different_credential(root, "server", "https://controller/api/moderation/runtime", 1, "credential-b");
|
||||
assert(different_credential.revision() == 0 && different_credential.request()["receipts"].empty());
|
||||
// Corruption is visible: runtime never replaces a bad persistent cache with empty policy.
|
||||
{ std::ofstream file(journal); file << "corrupt"; }
|
||||
rejects([&] { runtime broken(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); });
|
||||
std::cout << "moderation policy, identity, expiry, revocation, journal and schema checks passed\n";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
int main()
|
||||
{
|
||||
plugin::plugin plugin; moderation::component component; component.on_startup(&plugin);
|
||||
const std::string url="https://controller/api/moderation/runtime", token="ec_moderation_"+std::string(43,'a');
|
||||
assert(!gsc::function::configure("server","http://controller/api/moderation/runtime",token,1));
|
||||
assert(!gsc::function::configure("server","https://controller@evil/api/moderation/runtime",token,1));
|
||||
assert(!gsc::function::configure("server",url,token+"\r\nX: leak",1));
|
||||
assert(gsc::function::configure("server",url,token,1));
|
||||
// A failed replacement config must not permanently poison the valid old config.
|
||||
assert(!gsc::function::configure("server",url,token,0));
|
||||
assert(gsc::function::configure("server",url,token,1));
|
||||
assert(!moderation::fault);
|
||||
const auto initial_session=moderation::current->session;
|
||||
const auto initial_sequence=moderation::current->request()["sessionSequence"];
|
||||
assert(gsc::function::configure("server",url,token,1));
|
||||
assert(moderation::current->session==initial_session && moderation::current->request()["sessionSequence"] == initial_sequence); // periodic configuration cannot invalidate connections
|
||||
scripting::entity player{{0,0}}, wrong{{0,1}};
|
||||
assert(!gsc::method::observe(wrong,"123","one"));
|
||||
assert(!gsc::method::observe(player,"123; kick all","one"));
|
||||
assert(gsc::method::observe(player,"123","one"));
|
||||
const auto connection=moderation::current->players.at(0).connection;
|
||||
plugin.i.c.disconnect(0); assert(gsc::method::observe(player,"123","two"));
|
||||
assert(moderation::current->players.at(0).connection!=connection);
|
||||
auto now=moderation::now();
|
||||
auto iso=[](long long seconds) {time_t time=seconds; auto tm=*gmtime(&time); std::ostringstream out; out<<std::put_time(&tm,"%Y-%m-%dT%H:%M:%SZ"); return out.str();};
|
||||
utils::moderation::json reply={{"ok",true},{"revision",1},{"serverTime",iso(now)},{"sanctions",utils::moderation::json::array()},
|
||||
{"actions",{{{"id","stale"},{"guid","123"},{"connection",connection},{"kind","kick"},{"reason","reason"},{"expiresAt",iso(now+30)}}}},{"pollAfterMs",3000}};
|
||||
if (reply["actions"].is_object()) reply["actions"] = utils::moderation::json::array({reply["actions"]});
|
||||
utils::http::response=reply.dump(); gsc::function::pump(); scheduler::thread_pool.flush(); gsc::function::pump();
|
||||
assert(command::commands.empty()); assert(moderation::current->request()["receipts"][0]["state"]=="failed");
|
||||
reply["actions"][0]["id"]="valid";reply["actions"][0]["connection"]=moderation::current->players.at(0).connection;
|
||||
reply["actions"][0]["kind"]="message";reply["actions"][0]["reason"]="a\";\\\r\nsay";
|
||||
moderation::current->accept(reply.dump(),now); gsc::function::pump();
|
||||
assert(game::messages.size()==1); assert(game::messages[0].first==0); assert(game::messages[0].second.find(';')==std::string::npos);
|
||||
assert(game::messages[0].second.find('\n')==std::string::npos);
|
||||
moderation::current->accept(reply.dump(),now); gsc::function::pump(); assert(game::messages.size()==1);
|
||||
reply["actions"][0]["id"]="shutdown"; reply["actions"][0]["kind"]="kick";
|
||||
moderation::current->accept(reply.dump(),now);
|
||||
command::on_execute=[] { scripting::shutdown(); };
|
||||
gsc::function::pump(); assert(!moderation::current); // callback cannot free the active frame
|
||||
command::on_execute={};
|
||||
assert(gsc::function::configure("server",url,token,1));
|
||||
assert(moderation::current->request()["receipts"].size()>=1);
|
||||
const auto old_session=moderation::current->session;
|
||||
assert(gsc::function::configure("server",url,"ec_moderation_"+std::string(43,'b'),1));
|
||||
assert(moderation::current->session!=old_session && moderation::current->revision()==0);
|
||||
assert(moderation::current->request()["receipts"].empty());
|
||||
scripting::shutdown();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <json.hpp>
|
||||
#include "http.hpp"
|
||||
namespace game {
|
||||
struct scr_entref_t { unsigned short entnum = 0, classnum = 0; };
|
||||
inline std::vector<std::pair<int,std::string>> messages;
|
||||
inline void SV_GameSendServerCommand(int slot, int, const char* message) { messages.emplace_back(slot,message); }
|
||||
}
|
||||
namespace scripting {
|
||||
struct entity { game::scr_entref_t ref; game::scr_entref_t get_entity_reference() const { return ref; } };
|
||||
struct array {};
|
||||
inline std::function<void()> shutdown;
|
||||
inline void on_shutdown(std::function<void()> callback) { shutdown=callback; }
|
||||
}
|
||||
namespace plugin {
|
||||
struct callbacks { std::function<void(unsigned)> connect, disconnect; void on_player_connect(std::function<void(unsigned)> fn) {connect=fn;} void on_player_disconnect(std::function<void(unsigned)> fn) {disconnect=fn;} };
|
||||
struct iface { plugin::callbacks c; plugin::callbacks* callbacks() {return &c;} };
|
||||
struct plugin { iface i; iface* get_interface() {return &i;} };
|
||||
}
|
||||
struct component_interface { virtual ~component_interface()=default; virtual void on_startup(plugin::plugin*) {} };
|
||||
#define REGISTER_COMPONENT(x)
|
||||
namespace command { inline std::function<void()> on_execute; inline std::vector<std::string> commands; inline void execute(std::string command,bool sync) {assert(sync); commands.push_back(command); if(on_execute)on_execute();} }
|
||||
namespace scheduler { struct pool { std::vector<std::function<void()>> tasks; void push(std::function<void()> fn) {tasks.push_back(fn);} void flush() {auto copy=tasks; tasks.clear(); for(auto& fn:copy)fn();} }; inline pool thread_pool; }
|
||||
namespace gsc::function {
|
||||
inline std::function<bool(std::string,std::string,std::string,int)> configure;
|
||||
inline std::function<void()> pump;
|
||||
template<typename F> void add(const char*,F fn) {
|
||||
if constexpr(std::is_invocable_r_v<bool,F,std::string,std::string,std::string,int>)configure=fn;
|
||||
else if constexpr(std::is_invocable_r_v<void,F>) { if constexpr(std::is_same_v<std::invoke_result_t<F>,void>)pump=fn; }
|
||||
}
|
||||
}
|
||||
namespace gsc::method { inline std::function<bool(scripting::entity,std::string,std::string)> observe; template<typename F> void add(const char*,F fn) {observe=fn;} }
|
||||
namespace utils::cryptography::sha256 { inline std::string compute(const std::string& input, bool hex) { assert(hex); return std::to_string(std::hash<std::string>{}(input)); } }
|
||||
namespace utils::http {
|
||||
inline std::string response, request;
|
||||
inline std::optional<result> get_data(const std::string& url,const std::string& fields,const headers& hdrs,const std::string& method,const timeouts& limits) {
|
||||
assert(url=="https://controller/api/moderation/runtime"); assert(method=="POST"); assert(hdrs.at("Authorization").starts_with("Bearer ec_moderation_")); assert(limits.max_response_bytes==1024*1024);
|
||||
request=fields; result out; out.status=200; out.code=CURLE_OK; out.buffer=response; return out;
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"tws_t5_afk.gsc": "1.0.0",
|
||||
"tws_t5_common.gsc": "1.0.0",
|
||||
"tws_t5_hud.gsc": "1.0.0",
|
||||
"tws_t5_afk.gsc": "2.1.0",
|
||||
"tws_t5_bank.gsc": "2.0.3",
|
||||
"tws_t5_common.gsc": "1.5.0",
|
||||
"tws_t5_hud.gsc": "1.4.2",
|
||||
"tws_t5_join.gsc": "1.0.0",
|
||||
"tws_t5_leaderboard.gsc": "1.0.0",
|
||||
"tws_t5_leaderboard.gsc": "1.2.0",
|
||||
"tws_t5_lock.gsc": "1.0.0",
|
||||
"tws_t5_telemetry.gsc": "1.0.0",
|
||||
"tws_t6_afk.gsc": "1.0.0",
|
||||
"tws_t6_common.gsc": "1.0.0",
|
||||
"tws_t6_hud.gsc": "1.0.0",
|
||||
"tws_t6_join.gsc": "1.0.0",
|
||||
"tws_t6_leaderboard.gsc": "1.0.0",
|
||||
"tws_t6_afk.gsc": "2.1.0",
|
||||
"tws_t6_bank.gsc": "2.0.3",
|
||||
"tws_t6_common.gsc": "1.5.0",
|
||||
"tws_t6_hud.gsc": "1.4.2",
|
||||
"tws_t6_join.gsc": "1.0.1",
|
||||
"tws_t6_leaderboard.gsc": "1.2.0",
|
||||
"tws_t6_lock.gsc": "1.0.0",
|
||||
"tws_t6_telemetry.gsc": "1.0.0",
|
||||
"t5-plugin": "1.0.0",
|
||||
"t6-plugin": "1.0.0"
|
||||
"t5-plugin": "1.4.0",
|
||||
"t6-plugin": "1.4.0",
|
||||
"tws_t5_moderation.gsc": "1.0.0",
|
||||
"tws_t6_moderation.gsc": "1.0.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user