diff --git a/CHANGELOG.md b/CHANGELOG.md index 85176a5..0f1eca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ 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-18 + +### Add majority votes to lock and unlock the lobby + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_lock.gsc` | `1.1.0` | `t5/scripts/tws_t5_lock.gsc` | +| `tws_t6_lock.gsc` | `1.1.0` | `t6/scripts/tws_t6_lock.gsc` | + +- Add exact, case-insensitive `.lock` (round 6 onward) and `.unlock` commands + with help registration. Both need a strict majority of all connected players: + 1/1, 2/2, 2/3, 3/4, or `floor(players / 2) + 1` in larger lobbies. Solo + requests apply immediately; dead, spectating and AFK connections count. +- Use a 60-second ballot with one vote per connection, no extension on repeats, + and fresh votes after expiry or state changes. Recount joins/disconnects; + disconnected votes do not count, and reconnects do not inherit votes. +- Retain the configurable automatic lock at round 20, consumed once per match. + A successful unlock overrides it for the rest of the match, including after + an early manual lock. Players can still vote to relock. Automatic locking + clears any incomplete manual lock ballot. Generate a fresh password for each + lock and clear it and pending votes at startup and HUD/stock game over. +- Keep early locking optional: no round-6 prompt. Use ordinary message text + for vote progress, expiry and state changes, with private duplicate/unavailable + replies. Update help pagination coverage and both games' documentation. +- Compatibility: no new dependency requirement. Keep the matching common + `1.0.0+` and HUD `1.0.0+` modules installed; HUD still supplies round events. + T5 and T6 receive matching behavior using their existing namespaces and + initialization conventions. No common, HUD, native-plugin or upstream-pin + source changes are needed. +- Validation: 76 lock vote/lifecycle checks, 74 service HUD checks and 14 core + HUD checks passed with engine shims. The T6 lock script compiled and assembled + with upstream gsc-tool `8f1d6bd` libraries. T5 has no gsc-tool backend; live + Windows/Plutonium loading, message rendering and connection blocking still + need validation on both engines. No native-plugin build or deployment was + performed. See [lock voting and live checks](README.md#lobby-lock-voting-t5-and-t6). +- Deployment: copy the updated lock script for each installed game to its + existing script directory and restart the map. No full server-process restart + or additional deployment action is required for this script-only update. + ## 2026-10-17 ### Allow solo and last-active-player AFK diff --git a/README.md b/README.md index ad4b425..d8a6ea1 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,59 @@ Modules currently in each tree: | 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` | +## Lobby lock voting (T5 and T6) + +Lock scripts `1.1.0` add exact, case-insensitive `.lock` and `.unlock` chat +commands, also listed in `.help` when the lock module is installed. `.lock` +is available **after round 5 (round 6 onward)**; `.unlock` is available whenever +the server is locked. Both require a strict majority of the current connected +players: 1 of 1, 2 of 2, 2 of 3, or 3 of 4. Dead, spectating and AFK players +count equally. Larger lobbies use `floor(players / 2) + 1`. + +Typing the command starts a 60-second vote and casts your vote; teammates type +the same command to support it. Solo commands apply immediately. Each connection +gets one vote per ballot. Repeats neither add votes nor extend the deadline. +Joins and disconnects update the majority; disconnected votes are discarded, +and reconnecting does not restore a vote. A smaller roster can pass an ongoing +vote with the remaining votes. An expired vote needs fresh votes from everyone. +Typing the command for the current state only replies privately; it does not +withdraw a vote or cancel an ongoing ballot. + +Automatic locking remains at `tws_lock_round` (default `20`) and happens at +most once per match. A successful `.unlock` vote keeps the lobby open for the +rest of that match, including when players lock and unlock before round 20. +Players can still vote `.lock` again. An `.unlock` request while already open +does not disable the automatic lock. Automatic locking clears any unfinished +manual lock ballot. Each activation generates a fresh `g_password`; existing +players remain connected. Startup and game over clear the password and votes. + +There is no automatic prompt at round 6. Vote progress and state changes use +ordinary message text, with private replies for unavailable/repeated commands; +no center-screen popup or recurring reminder is added. + +Compatibility: no new dependency requirement; keep the matching common and HUD +modules installed (common `1.0.0+`, HUD `1.0.0+`). HUD supplies round notifications. +Copy each updated lock script to its existing game script directory and restart +the map. No native plugin update, full server-process restart or other deployment +action is required. + +Run `node tools/check_lock.mjs` and `node tools/check_hud.mjs` for portable vote, +lifecycle and help pagination checks. Compile the T6 script using the +[T6 compiler check](t6/scripts/README.md#validation). Before rollout, verify on +live T5 and T6 servers: + +1. Try `.lock` in rounds 5 and 6, then both commands with 1–4 players. Confirm + the exact majority, instant solo behavior, private duplicate replies and + ordinary vote text. Check both commands in `.help` and `.commands`. +2. Let a vote expire, then retry; disconnect a voter, disconnect a non-voter, + reconnect and join mid-vote. Verify the current roster and fresh votes count. +3. Allow round-20 auto-lock, unlock, advance another round and relock. Also + lock/unlock early and cross round 20. Confirm a new client is blocked when + locked and can join when unlocked; connected players stay in the game. +4. End the match with a lock or pending vote, then restart the map. Confirm + the next lobby is open and old votes cannot change it. Check a custom + `tws_lock_round` and confirm it does not change the round-6 manual gate. + ## Player bank (T5 and T6) Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and @@ -396,7 +449,8 @@ 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 +`.b`, `.d`, `.w`, plus registered commands such as `.lock` and `.unlock`. +Missing modules' commands are omitted. Bank syntax remains `.d ` and `.w `, 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 diff --git a/t5/scripts/NOTES.md b/t5/scripts/NOTES.md index 9d1ae98..ef51a19 100644 --- a/t5/scripts/NOTES.md +++ b/t5/scripts/NOTES.md @@ -136,6 +136,6 @@ hides/shows it, and after game over freezes the count. | `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` | +| `tws_t5_lock` | Automatic `g_password` lock (`tws_lock_round`, default 20) plus majority `.lock` / `.unlock` votes | `tws_lock_game_over` (internal) | `say`, `hud_round_changed`, `hud_game_over`, `end_game`, `game_ended`, `intermission` | | `tws_t5_telemetry` | Ordered JSON game start, round, 30-second heartbeat, and game end reports with full player snapshots | `telemetry_queued`, `telemetry_finished` (internal) | `end_game`, `game_ended`, `intermission` | | `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` | diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 8b918b6..57795fe 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -14,7 +14,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | [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_lock.gsc](tws_t5_lock.gsc) | Automatic lobby lock plus majority `.lock` / `.unlock` votes | | [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 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 | @@ -135,9 +135,9 @@ for cadence, interruption, cleanup and operation without the optional HUD. 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 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. +Help uses the unboxed upper-right service area with command and description +columns. 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. @@ -153,13 +153,14 @@ explicit row positions so T5 does not concatenate newline-separated text. | `.b` | Check bank balance (bank module required) | | `.d ` | Deposit points (bank module required) | | `.w ` | Withdraw points (bank module required) | +| `.lock` | Vote to lock the lobby from round 6 (lock module required) | +| `.unlock` | Vote to unlock the lobby (lock module required) | -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 ` / `.w ` syntax. Removing HUD -removes `.hud` from the list. +Each module registers its own help entries. The first nine commands follow +the order above; additional entries follow registration order. Removing a module +also removes its entries from `.help` after a map restart. Lock and extended AFK +commands appear on additional pages. Bank entries retain the full +`.d ` / `.w ` 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 pauses the record's remaining page time and resumes it after the final @@ -393,9 +394,16 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/ **Features** - Sets `g_password` to a random per-lobby value when the round crosses a - milestone (default: round 20) + milestone (default: round 20), once per match +- Adds `.lock` from round 6 and `.unlock` whenever locked, using strict-majority + votes (1/1, 2/2, 2/3, 3/4) of all connected players +- Votes expire after 60 seconds; duplicates do not count, and roster changes + update the tally without retaining disconnected votes +- A successful unlock disables automatic locking for the rest of that match; + players can vote to relock at any time from round 6 +- No automatic round-6 prompt; progress/results use ordinary message text - Existing players are unaffected; `g_password` is only checked at connect -- Clears `g_password` on `hud_game_over` so the next lobby opens unlocked +- Clears `g_password` and pending votes at startup and on HUD/stock game-over events - Trigger round is dvar-driven — retune without redeploying **Provides** @@ -403,13 +411,19 @@ and [spawn entry point](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/ |---|---| | `level.lock_round` | Round at which the lobby locks (read at init) | | `level.lock_active` | `true` after the password has been set | +| `level.lock_auto_done` | Automatic locking consumed or overridden by a successful unlock | +| `level.lock_vote` | Current ballot, or undefined when idle | **Depends on** -- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or` +- `scripts\sp\tws_t5_common::` — `ensure_config`, `int_dvar_or`, `register_help_command` - `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over` - Stock BO1: `level.round_number` - Dvar: `tws_lock_round` (int, default `20`) +Lock `1.1.0` has no new dependency requirement. Copy the updated script and +restart the map; no full server-process restart is required. See the shared +[voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6). + --- ## Dvars diff --git a/t5/scripts/tws_t5_lock.gsc b/t5/scripts/tws_t5_lock.gsc index dac21a3..2924ec0 100644 --- a/t5/scripts/tws_t5_lock.gsc +++ b/t5/scripts/tws_t5_lock.gsc @@ -1,7 +1,7 @@ /* - tws_t5_lock.gsc — lobby g_password lock at milestone round. Existing players - are unaffected; g_password is only checked at connect. Cleared on - hud_game_over so the next lobby opens unlocked. + tws_t5_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes. + Existing players are unaffected; g_password is only checked at connect. + A successful unlock overrides automatic locking for the rest of the match. */ #include maps\_utility; @@ -13,6 +13,8 @@ init() return; scripts\sp\tws_t5_common::ensure_config(); + scripts\sp\tws_t5_common::register_help_command(".lock", "Vote to lock (6+)"); + scripts\sp\tws_t5_common::register_help_command(".unlock", "Vote to unlock"); lock_init(); } @@ -21,47 +23,215 @@ lock_init() { level.lock_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20); level.lock_active = false; + level.lock_auto_done = false; + level.lock_ended = false; + level.lock_vote = undefined; + level.lock_vote_serial = 0; // recover from a prior lobby that crashed before clearing setDvar("g_password", ""); level thread lock_round_watcher(); - level thread lock_cleanup_watcher(); + level thread lock_chat_listener(); + level thread lock_vote_watcher(); + level thread lock_cleanup_watcher("hud_game_over"); + level thread lock_cleanup_watcher("end_game"); + level thread lock_cleanup_watcher("game_ended"); + level thread lock_cleanup_watcher("intermission"); } lock_round_watcher() { - level endon("hud_game_over"); + level endon("tws_lock_game_over"); for (;;) { level waittill("hud_round_changed"); - - if (level.lock_active) - continue; - - if (!isDefined(level.round_number) || level.round_number < level.lock_round) - continue; - - // random per activation so the password can't be inferred from source - password = "lock_" + getTime() + "_" + randomInt(1000000); - setDvar("g_password", password); - level.lock_active = true; - - lock_announce(); + lock_check_round(); } } -lock_cleanup_watcher() +lock_check_round() { - level waittill("hud_game_over"); - setDvar("g_password", ""); - level.lock_active = false; + if (lock_game_ended() || level.lock_auto_done) + return; + + if (!isDefined(level.round_number) || level.round_number < level.lock_round) + return; + + // Consume the milestone once, even if players already locked early. + level.lock_auto_done = true; + if (!level.lock_active) + lock_set_state(true); } -lock_announce() +lock_chat_listener() +{ + level endon("tws_lock_game_over"); + + for (;;) + { + level waittill("say", message, player); + if (!isDefined(player) || !isDefined(message)) + continue; + + command = toLower(message); + if (command == ".lock" || command == ".unlock") + lock_request(player, command); + } +} + +lock_game_ended() +{ + return level.lock_ended || is_true(level.intermission) || + is_true(level.gameended) || is_true(level.tws_ended); +} + +// This path never yields: each chat message casts at most one vote, and a +// successful vote changes the password before another request can run. +lock_request(player, command) +{ + if (lock_game_ended()) + return; + + players = getPlayers(); + present = false; + for (i = 0; i < players.size; i++) + { + if (players[i] == player) + present = true; + } + if (!present) + return; + + lock_update_vote(); + target = command == ".lock"; + if (target == level.lock_active) + { + if (target) + player iPrintLn("^3Server is already locked"); + else + player iPrintLn("^3Server is already unlocked"); + return; + } + + if (target && (!isDefined(level.round_number) || level.round_number <= 5)) + { + player iPrintLn("^3.lock is available from round 6"); + return; + } + + if (!isDefined(level.lock_vote)) + { + level.lock_vote_serial++; + vote = spawnStruct(); + vote.id = level.lock_vote_serial; + vote.command = command; + vote.target = target; + vote.deadline = getTime() + 60000; + level.lock_vote = vote; + } + + if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id) + { + player iPrintLn("^3You already voted " + command); + return; + } + + // Connection-local fields cannot carry a vote across a reconnect. + player.tws_lock_vote_id = level.lock_vote.id; + if (lock_update_vote()) + { + tally = lock_vote_tally(); + lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required + + " votes. Type " + command + " to vote (60s window)"); + } +} + +lock_vote_tally() +{ + tally = spawnStruct(); + tally.yes = 0; + players = getPlayers(); + tally.required = int(players.size / 2) + 1; + for (i = 0; i < players.size; i++) + { + if (isDefined(players[i].tws_lock_vote_id) && + players[i].tws_lock_vote_id == level.lock_vote.id) + tally.yes++; + } + return tally; +} + +// Recount the live roster so disconnected votes are dropped and newcomers +// count toward the majority. Expiry is checked before accepting any result. +lock_update_vote() +{ + if (lock_game_ended() || !isDefined(level.lock_vote)) + return false; + + if (getTime() >= level.lock_vote.deadline) + { + command = level.lock_vote.command; + level.lock_vote = undefined; + lock_announce("^3" + command + " vote expired"); + return false; + } + + tally = lock_vote_tally(); + if (tally.yes >= tally.required) + { + lock_set_state(level.lock_vote.target); + return false; + } + return true; +} + +lock_vote_watcher() +{ + level endon("tws_lock_game_over"); + for (;;) + { + wait (0.5); + lock_update_vote(); + } +} + +lock_set_state(locked) +{ + level.lock_vote = undefined; + if (locked) + { + // Random per activation so the password cannot be inferred from source. + password = "lock_" + getTime() + "_" + randomInt(1000000); + setDvar("g_password", password); + } + else + { + setDvar("g_password", ""); + level.lock_auto_done = true; + } + level.lock_active = locked; + if (locked) + lock_announce("^3Server locked"); + else + lock_announce("^3Server unlocked"); +} + +lock_cleanup_watcher(event) +{ + level endon("tws_lock_game_over"); + level waittill(event); + level.lock_ended = true; + level.lock_vote = undefined; + setDvar("g_password", ""); + level.lock_active = false; + level notify("tws_lock_game_over"); +} + +lock_announce(message) { players = getPlayers(); for (i = 0; i < players.size; i++) - players[i] iPrintLnBold("^3Server locked"); + players[i] iPrintLn(message); } diff --git a/t6/scripts/README.md b/t6/scripts/README.md index 73884e3..f474df1 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -10,7 +10,7 @@ private server token. | `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_lock.gsc` | Auto-lock at round 20 plus majority `.lock` / `.unlock` votes; clear 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 record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils | @@ -154,9 +154,9 @@ 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 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. +Help uses the unboxed upper-right service area with command and description +columns. 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. @@ -172,15 +172,17 @@ explicit row positions so T5 does not concatenate newline-separated text. | `.b` | Check bank balance (bank module required) | | `.d ` | Deposit points (bank module required) | | `.w ` | Withdraw points (bank module required) | +| `.lock` | Vote to lock the lobby from round 6 (lock module required) | +| `.unlock` | Vote to unlock the lobby (lock module required) | 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`. 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 ` / `.w ` syntax. Removing HUD -removes `.hud` from the list. +omitted, its commands are omitted from `.help`. The first nine commands follow +the order above; additional entries follow registration order. Register new +commands alongside their module's initialization so the list stays current. +Lock and extended AFK commands appear on additional pages. Bank entries retain +the full `.d ` / `.w ` 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 pauses the record's remaining page time and resumes it after the final @@ -195,8 +197,13 @@ help page. `.leaders` during help replaces the pending record replay from page o - AFK uses T5's native `level waittill("say", message, player)` listener. `.back` restores movement and weapons immediately, then ends protection after two seconds. The T6 control/weapon fix remains in place. -- Lock uses T5's HUD round/game-over notifications. Default threshold is 20. - As in T5, it clears `g_password` at startup and game over. +- Lock uses T5's HUD round notifications with a default automatic threshold of + 20. `.lock` is available from round 6; `.unlock` whenever locked. Both use a + 60-second strict-majority vote (1/1, 2/2, 2/3, 3/4). A successful unlock + overrides auto-lock for the rest of the match. There is no round-6 prompt. + As in T5, it clears `g_password` and votes at startup and HUD/stock game over. + Lock `1.1.0` adds no dependency; copy the script and restart the map, without + a full server-process restart. See [voting behavior and validation](../../README.md#lobby-lock-voting-t5-and-t6). - Telemetry is enabled by default and posts to `https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact, case-sensitive Electric Cherry registry name; missing or overlong names diff --git a/t6/scripts/tws_t6_lock.gsc b/t6/scripts/tws_t6_lock.gsc index 7a7ba0b..0b1ec4c 100644 --- a/t6/scripts/tws_t6_lock.gsc +++ b/t6/scripts/tws_t6_lock.gsc @@ -1,7 +1,7 @@ /* - tws_t6_lock.gsc — lobby g_password lock at milestone round. Existing players - are unaffected; g_password is only checked at connect. Cleared on - hud_game_over so the next lobby opens unlocked. + tws_t6_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes. + Existing players are unaffected; g_password is only checked at connect. + A successful unlock overrides automatic locking for the rest of the match. */ #include maps\mp\_utility; @@ -15,6 +15,8 @@ main() init() { scripts\zm\tws_t6_common::ensure_config(); + scripts\zm\tws_t6_common::register_help_command(".lock", "Vote to lock (6+)"); + scripts\zm\tws_t6_common::register_help_command(".unlock", "Vote to unlock"); lock_init(); } @@ -23,47 +25,215 @@ lock_init() { level.lock_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20); level.lock_active = false; + level.lock_auto_done = false; + level.lock_ended = false; + level.lock_vote = undefined; + level.lock_vote_serial = 0; // recover from a prior lobby that crashed before clearing setDvar("g_password", ""); level thread lock_round_watcher(); - level thread lock_cleanup_watcher(); + level thread lock_chat_listener(); + level thread lock_vote_watcher(); + level thread lock_cleanup_watcher("hud_game_over"); + level thread lock_cleanup_watcher("end_game"); + level thread lock_cleanup_watcher("game_ended"); + level thread lock_cleanup_watcher("intermission"); } lock_round_watcher() { - level endon("hud_game_over"); + level endon("tws_lock_game_over"); for (;;) { level waittill("hud_round_changed"); - - if (level.lock_active) - continue; - - if (!isDefined(level.round_number) || level.round_number < level.lock_round) - continue; - - // random per activation so the password can't be inferred from source - password = "lock_" + getTime() + "_" + randomInt(1000000); - setDvar("g_password", password); - level.lock_active = true; - - lock_announce(); + lock_check_round(); } } -lock_cleanup_watcher() +lock_check_round() { - level waittill("hud_game_over"); - setDvar("g_password", ""); - level.lock_active = false; + if (lock_game_ended() || level.lock_auto_done) + return; + + if (!isDefined(level.round_number) || level.round_number < level.lock_round) + return; + + // Consume the milestone once, even if players already locked early. + level.lock_auto_done = true; + if (!level.lock_active) + lock_set_state(true); } -lock_announce() +lock_chat_listener() +{ + level endon("tws_lock_game_over"); + + for (;;) + { + level waittill("say", message, player); + if (!isDefined(player) || !isDefined(message)) + continue; + + command = toLower(message); + if (command == ".lock" || command == ".unlock") + lock_request(player, command); + } +} + +lock_game_ended() +{ + return level.lock_ended || is_true(level.intermission) || + is_true(level.gameended) || is_true(level.tws_ended); +} + +// This path never yields: each chat message casts at most one vote, and a +// successful vote changes the password before another request can run. +lock_request(player, command) +{ + if (lock_game_ended()) + return; + + players = getPlayers(); + present = false; + for (i = 0; i < players.size; i++) + { + if (players[i] == player) + present = true; + } + if (!present) + return; + + lock_update_vote(); + target = command == ".lock"; + if (target == level.lock_active) + { + if (target) + player iPrintLn("^3Server is already locked"); + else + player iPrintLn("^3Server is already unlocked"); + return; + } + + if (target && (!isDefined(level.round_number) || level.round_number <= 5)) + { + player iPrintLn("^3.lock is available from round 6"); + return; + } + + if (!isDefined(level.lock_vote)) + { + level.lock_vote_serial++; + vote = spawnStruct(); + vote.id = level.lock_vote_serial; + vote.command = command; + vote.target = target; + vote.deadline = getTime() + 60000; + level.lock_vote = vote; + } + + if (isDefined(player.tws_lock_vote_id) && player.tws_lock_vote_id == level.lock_vote.id) + { + player iPrintLn("^3You already voted " + command); + return; + } + + // Connection-local fields cannot carry a vote across a reconnect. + player.tws_lock_vote_id = level.lock_vote.id; + if (lock_update_vote()) + { + tally = lock_vote_tally(); + lock_announce("^3" + command + ": " + tally.yes + "/" + tally.required + + " votes. Type " + command + " to vote (60s window)"); + } +} + +lock_vote_tally() +{ + tally = spawnStruct(); + tally.yes = 0; + players = getPlayers(); + tally.required = int(players.size / 2) + 1; + for (i = 0; i < players.size; i++) + { + if (isDefined(players[i].tws_lock_vote_id) && + players[i].tws_lock_vote_id == level.lock_vote.id) + tally.yes++; + } + return tally; +} + +// Recount the live roster so disconnected votes are dropped and newcomers +// count toward the majority. Expiry is checked before accepting any result. +lock_update_vote() +{ + if (lock_game_ended() || !isDefined(level.lock_vote)) + return false; + + if (getTime() >= level.lock_vote.deadline) + { + command = level.lock_vote.command; + level.lock_vote = undefined; + lock_announce("^3" + command + " vote expired"); + return false; + } + + tally = lock_vote_tally(); + if (tally.yes >= tally.required) + { + lock_set_state(level.lock_vote.target); + return false; + } + return true; +} + +lock_vote_watcher() +{ + level endon("tws_lock_game_over"); + for (;;) + { + wait (0.5); + lock_update_vote(); + } +} + +lock_set_state(locked) +{ + level.lock_vote = undefined; + if (locked) + { + // Random per activation so the password cannot be inferred from source. + password = "lock_" + getTime() + "_" + randomInt(1000000); + setDvar("g_password", password); + } + else + { + setDvar("g_password", ""); + level.lock_auto_done = true; + } + level.lock_active = locked; + if (locked) + lock_announce("^3Server locked"); + else + lock_announce("^3Server unlocked"); +} + +lock_cleanup_watcher(event) +{ + level endon("tws_lock_game_over"); + level waittill(event); + level.lock_ended = true; + level.lock_vote = undefined; + setDvar("g_password", ""); + level.lock_active = false; + level notify("tws_lock_game_over"); +} + +lock_announce(message) { players = getPlayers(); for (i = 0; i < players.size; i++) - players[i] iPrintLnBold("^3Server locked"); + players[i] iPrintLn(message); } diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs index 3869b00..2be6d0f 100644 --- a/tools/check_hud.mjs +++ b/tools/check_hud.mjs @@ -162,7 +162,7 @@ function assertTextOnly(e) { '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`, () => { + check(`${game} core and bank commands fit in one view without extra modules`, () => { const e = engine(game, 8, true); e.help(); assert.deepEqual(e.helpCommands, @@ -180,6 +180,20 @@ for (const game of ['t5', 't6']) { withoutHud.help(); assert(!withoutHud.state.commands.some(entry => entry.command === '.hud')); }); + check(`${game} lock module registrations appear on the second help page`, () => { + const e = engine(game, 8, true); + const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8'); + for (const [, command, description] of source.matchAll(/register_help_command\("([^"]+)", "([^"]+)"\)/g)) + e.c.register_help_command(command, description); + e.help(); + assert.equal(e.state.help.pages, 2); + e.advance(12000); + assert.deepEqual(e.helpCommands, ['.lock', '.unlock']); + assert.deepEqual(e.helpDescriptions, ['Vote to lock (6+)', 'Vote to unlock']); + e.advance(12000); + assert.equal(e.state.help, undefined); + assert.equal(e.live.length, 0); + }); 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); diff --git a/tools/check_lock.mjs b/tools/check_lock.mjs new file mode 100644 index 0000000..b7c30ea --- /dev/null +++ b/tools/check_lock.mjs @@ -0,0 +1,270 @@ +// Execute both lock scripts with chat, roster, clock and event shims. This does +// not emulate Plutonium's VM or prove that g_password prevents a real join. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const root = new URL('../', import.meta.url); +const coroutines = new Set(['lock_chat_listener', 'lock_round_watcher', 'lock_vote_watcher', 'lock_cleanup_watcher']); +function translate(source) { + const literals = []; + source = source.replace(/#include[^\n]*|"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { + if (!token.startsWith('"')) return ''; + literals.push(token); + return `__literal_${literals.length - 1}__`; + }); + const result = [], headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm; + for (let match; (match = headers.exec(source));) { + let end = headers.lastIndex, depth = 1; + while (depth) { + assert(end < source.length, `unclosed ${match[1]}`); + const ch = source[end++]; + if (ch === '{') depth++; + if (ch === '}') depth--; + } + let body = source.slice(headers.lastIndex, end - 1); + const locals = new Set([...body.matchAll(/(? m[1])); + if (match[1] === 'lock_chat_listener') { locals.add('message'); locals.add('player'); } + body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '') + .replace(/#(__literal_\d+__)/g, '$1') + .replace(/\.size\b/g, '.length') + .replace(/level thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);') + .replace(/level endon\(([^;]+)\);/g, 'endon($1);') + .replace(/level waittill\(([^,;]+), message, player\);/g, '[message, player] = yield { event: $1 };') + .replace(/level waittill\(([^;]+)\);/g, 'yield { event: $1 };') + .replace(/level notify\(([^;]+)\);/g, 'emit($1);') + .replace(/wait \(([^;]+)\);/g, 'yield { delay: ($1) * 1000 };') + .replace(/(player|players\[i\]) iPrintLn\(/g, 'print($1, ') + .replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]); + result.push(`function${coroutines.has(match[1]) ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); + } + return result.join('\n'); +} + +function engine(game, count = 4, lockRound = 20) { + const source = readFileSync(new URL(`${game}/scripts/tws_${game}_lock.gsc`, root), 'utf8'); + const level = { round_number: 6 }, players = [], tasks = [], messages = [], writes = [], help = []; + let now = 0, running, random = 0, password = 'stale-password'; + function resume(task, args) { + const prior = running; + running = task; + const next = task.generator.next(args); + task.done = task.done || next.done; + task.wait = next.value; + task.at = next.value?.delay !== undefined ? now + next.value.delay : Infinity; + running = prior; + } + function startThread(fn, args) { + const task = { generator: fn(...args), ends: [] }; + tasks.push(task); + resume(task); + } + function emit(event, ...args) { + const listeners = tasks.filter(t => !t.done); + for (const task of listeners) if (task.ends.includes(event)) task.done = true; + for (const task of listeners) if (!task.done && task.wait?.event === event) resume(task, args); + } + 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; + } + const c = vm.createContext({ + level, ensure_config() {}, register_help_command: (command, description) => help.push([command, description]), + GetDvar: () => '1', int_dvar_or: () => lockRound, + getPlayers: () => players.slice(), getTime: () => now, + isDefined: value => value !== undefined && value !== null, + is_true: value => value === true || value === 1, + spawnStruct: () => ({}), int: Math.trunc, randomInt: () => ++random, + toLower: value => value.toLowerCase(), + setDvar(name, value) { assert.equal(name, 'g_password'); password = value; writes.push(value); }, + print: (player, text) => messages.push({ player, text }), + startThread, endon: event => running.ends.push(event), emit, + }); + vm.runInContext(translate(source), c); + function connect() { const player = { sessionstate: 'playing' }; players.push(player); return player; } + for (let i = 0; i < count; i++) connect(); + c.init(); + return { + level, players, tasks, messages, writes, help, c, advance, connect, emit, + chat: (player, message) => emit('say', message, player), + round(number) { level.round_number = number; emit('hud_round_changed'); }, + disconnect(player) { const index = players.indexOf(player); assert(index >= 0); players.splice(index, 1); }, + get password() { return password; }, + // Jump without running the timer to test expiry at the exact chat boundary. + jump(milliseconds) { now += milliseconds; }, + }; +} + +let checks = 0; +function check(name, run) { + try { run(); checks++; } + catch (cause) { throw new Error(name, { cause }); } +} +for (const game of ['t5', 't6']) { + check(`${game}: startup clears a stale lock and registers both commands quietly`, () => { + const e = engine(game); + assert.equal(e.password, ''); + assert.deepEqual(e.help.map(([command]) => command), ['.lock', '.unlock']); + e.round(5); e.round(6); e.round(19); + assert.equal(e.messages.length, 0); + assert.equal(e.writes.length, 1); + }); + for (const [count, required] of [[1, 1], [2, 2], [3, 2], [4, 3], [8, 5]]) { + for (const command of ['.lock', '.unlock']) { + check(`${game}: ${command} needs ${required}/${count} distinct voters`, () => { + const e = engine(game, count); + const target = command === '.lock'; + if (!target) e.round(20); + for (let i = 0; i < required - 1; i++) { + e.chat(e.players[i], command); + e.chat(e.players[i], command); + assert.equal(e.level.lock_active, !target); + } + e.chat(e.players[required - 1], command.toUpperCase()); + assert.equal(e.level.lock_active, target); + assert.equal(Boolean(e.password), target); + assert.equal(e.level.lock_vote, undefined); + const writes = e.writes.length; + e.chat(e.players[0], command); + assert.equal(e.writes.length, writes); + }); + } + } + for (const round of [undefined, 0, 1, 4, 5]) { + check(`${game}: manual locking is unavailable at round ${round}`, () => { + const e = engine(game, 1); + e.round(round); e.chat(e.players[0], '.lock'); + assert.equal(e.password, ''); + assert.equal(e.level.lock_vote, undefined); + assert.match(e.messages.at(-1).text, /round 6/); + }); + } + check(`${game}: exact chat commands only; missing and disconnected senders are ignored`, () => { + const e = engine(game, 1); + for (const text of ['hello .lock', '.lock extra', '.unlock extra', '.locked', undefined]) e.chat(e.players[0], text); + e.chat(undefined, '.lock'); e.chat({}, '.lock'); + assert.equal(e.messages.length, 0); + assert.equal(e.password, ''); + }); + check(`${game}: round-20 auto-lock runs once; unlock persists across later rounds`, () => { + const e = engine(game, 1); + e.round(19); assert.equal(e.password, ''); + e.round(20); assert(e.password); + const writes = e.writes.length; + e.round(20); e.round(21); assert.equal(e.writes.length, writes); + e.chat(e.players[0], '.unlock'); + e.round(22); e.round(40); assert.equal(e.password, ''); + e.chat(e.players[0], '.lock'); assert(e.password); + }); + check(`${game}: early lock/unlock stays open through round 20; later relocks use a fresh password`, () => { + const e = engine(game, 1); + e.chat(e.players[0], '.lock'); const first = e.password; + e.chat(e.players[0], '.unlock'); + e.round(20); e.round(21); assert.equal(e.password, ''); + e.chat(e.players[0], '.lock'); assert(e.password); assert.notEqual(e.password, first); + }); + check(`${game}: an already-open reply cannot cancel automatic locking`, () => { + const e = engine(game, 1); + e.chat(e.players[0], '.unlock'); + e.round(20); assert(e.password); + }); + check(`${game}: early lock is retained at the automatic milestone without another announcement`, () => { + const e = engine(game, 1); + e.chat(e.players[0], '.lock'); const writes = e.writes.length, messages = e.messages.length; + e.round(20); assert.equal(e.writes.length, writes); assert.equal(e.messages.length, messages); + e.chat(e.players[0], '.unlock'); e.round(21); assert.equal(e.password, ''); + }); + check(`${game}: custom automatic threshold below round 6 still permits unlocking`, () => { + const e = engine(game, 1, 3); + e.round(2); assert.equal(e.password, ''); + e.round(3); assert(e.password); + e.chat(e.players[0], '.unlock'); assert.equal(e.password, ''); + e.round(4); assert.equal(e.password, ''); + e.chat(e.players[0], '.lock'); assert.equal(e.password, ''); + }); + check(`${game}: automatic lock clears an incomplete manual vote`, () => { + const e = engine(game); + e.round(19); e.chat(e.players[0], '.lock'); + e.round(20); assert(e.password); assert.equal(e.level.lock_vote, undefined); + e.chat(e.players[1], '.unlock'); e.chat(e.players[2], '.unlock'); assert(e.password); + e.chat(e.players[0], '.unlock'); assert.equal(e.password, ''); + }); + check(`${game}: repeated and opposite commands do not extend or cancel a pending vote`, () => { + const e = engine(game); + e.chat(e.players[0], '.lock'); const deadline = e.level.lock_vote.deadline; + e.advance(30000); e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.unlock'); + assert.equal(e.level.lock_vote.deadline, deadline); + assert.equal(e.c.lock_vote_tally().yes, 1); + e.advance(30000); assert.equal(e.level.lock_vote, undefined); assert.equal(e.password, ''); + e.chat(e.players[1], '.lock'); e.chat(e.players[2], '.lock'); assert.equal(e.password, ''); + e.chat(e.players[0], '.lock'); assert(e.password); + }); + check(`${game}: the exact deadline rejects old votes before processing chat`, () => { + const e = engine(game, 3); + e.chat(e.players[0], '.lock'); e.jump(60000); e.chat(e.players[1], '.lock'); + assert.equal(e.password, ''); assert.equal(e.c.lock_vote_tally().yes, 1); + e.chat(e.players[0], '.lock'); assert(e.password); + }); + check(`${game}: votes pass just before the deadline`, () => { + const e = engine(game, 2); + e.chat(e.players[0], '.lock'); e.advance(59999); e.chat(e.players[1], '.lock'); assert(e.password); + }); + check(`${game}: departed voters do not count and reconnects get no inherited ballot`, () => { + const e = engine(game); + const departed = e.players[0]; + e.chat(departed, '.lock'); e.disconnect(departed); + const newcomer = e.connect(); e.advance(500); + e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); assert.equal(e.password, ''); + e.chat(newcomer, '.lock'); assert(e.password); + }); + check(`${game}: roster shrink can complete a current majority`, () => { + const e = engine(game); + e.chat(e.players[0], '.lock'); e.chat(e.players[1], '.lock'); + e.disconnect(e.players[3]); e.advance(500); assert(e.password); + }); + check(`${game}: roster growth raises the majority before accepting more votes`, () => { + const e = engine(game, 3); + e.chat(e.players[0], '.lock'); const newcomer = e.connect(); + e.chat(e.players[1], '.lock'); assert.equal(e.password, ''); + e.chat(newcomer, '.lock'); assert(e.password); + }); + check(`${game}: an empty roster cannot pass a vote`, () => { + const e = engine(game, 2); + e.chat(e.players[0], '.lock'); e.players.splice(0); e.advance(500); + assert.equal(e.password, ''); + }); + check(`${game}: dead, spectator and AFK connections have equal votes`, () => { + const e = engine(game, 3); + e.players[0].sessionstate = 'spectator'; e.players[1].afk = true; + e.chat(e.players[0], '.lock'); assert.equal(e.password, ''); + e.chat(e.players[1], '.lock'); assert(e.password); + }); + for (const event of ['hud_game_over', 'end_game', 'game_ended', 'intermission']) { + check(`${game}: ${event} clears the password/vote and stops all workers`, () => { + const e = engine(game); + e.round(20); e.chat(e.players[0], '.unlock'); + e.emit(event); + assert.equal(e.password, ''); assert.equal(e.level.lock_vote, undefined); + assert(e.tasks.every(t => t.done)); + const writes = e.writes.length; + e.chat(e.players[1], '.lock'); e.c.lock_request(e.players[1], '.lock'); + e.round(21); e.advance(61000); e.emit('hud_game_over'); + assert.equal(e.writes.length, writes); + }); + } + for (const flag of ['intermission', 'gameended', 'tws_ended']) { + check(`${game}: ${flag} blocks state changes before cleanup notifications arrive`, () => { + const e = engine(game, 2); + e.chat(e.players[0], '.lock'); e.level[flag] = true; + e.chat(e.players[1], '.lock'); e.disconnect(e.players[1]); e.advance(500); e.round(20); + assert.equal(e.password, ''); + }); + } +} +console.log(`PASS ${checks} T5/T6 lock vote/lifecycle checks (engine shims; live validation required)`); diff --git a/versions.json b/versions.json index c83e7b0..480f9dc 100644 --- a/versions.json +++ b/versions.json @@ -5,7 +5,7 @@ "tws_t5_hud.gsc": "1.4.2", "tws_t5_join.gsc": "1.0.0", "tws_t5_leaderboard.gsc": "1.2.0", - "tws_t5_lock.gsc": "1.0.0", + "tws_t5_lock.gsc": "1.1.0", "tws_t5_telemetry.gsc": "1.0.0", "tws_t6_afk.gsc": "2.1.0", "tws_t6_bank.gsc": "2.0.3", @@ -13,7 +13,7 @@ "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_lock.gsc": "1.1.0", "tws_t6_telemetry.gsc": "1.0.0", "t5-plugin": "1.4.0", "t6-plugin": "1.4.0",