Add majority lock and unlock voting for T5 and T6

This commit is contained in:
2026-10-18 16:01:57 -04:00
parent caaaa102a4
commit 9ef5932d36
10 changed files with 816 additions and 78 deletions
+39
View File
@@ -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 T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning). [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 ## 2026-10-17
### Allow solo and last-active-player AFK ### Allow solo and last-active-player AFK
+55 -1
View File
@@ -173,6 +173,59 @@ Modules currently in each tree:
| bank | `tws_t5_bank.gsc` | `tws_t6_bank.gsc` — persistent shared GUID bank | | 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` | | 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) ## Player bank (T5 and T6)
Install `tws_t5_bank.gsc` or `tws_t6_bank.gsc` alongside the game's common and 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 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 `.commands` alias show installed commands in pages of up to nine rows with command
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, 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 <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer `.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
restriction. Additional registered commands use pages of nine rows; a page restriction. Additional registered commands use pages of nine rows; a page
count appears only when more than one page is needed. Each page lasts count appears only when more than one page is needed. Each page lasts
+1 -1
View File
@@ -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_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_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_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_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` | | `tws_t5_leaderboard` | Cached completed-match map record and all tied holders, privately on join | — | `end_game`, `game_ended`, `intermission` |
+27 -13
View File
@@ -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_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_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_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_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_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands | | [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
@@ -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. 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, The response is visible only to the requesting player and works while spectating,
before the first spawn. Commands are case-insensitive. before the first spawn. Commands are case-insensitive.
Help uses the unboxed upper-right service area. All nine installed commands Help uses the unboxed upper-right service area with command and description
fit in one list with command and description columns. Additional registered columns. Registered commands use pages of nine rows, with a counter only for
commands use pages of nine rows, with a counter only for multi-page lists. multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either 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 command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text. 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) | | `.b` | Check bank balance (bank module required) |
| `.d <amount\|all>` | Deposit points (bank module required) | | `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) | | `.w <amount\|all>` | 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, Each module registers its own help entries. The first nine commands follow
independent of initialization order; removing a module also removes its entries the order above; additional entries follow registration order. Removing a module
from `.help` after a map restart. also removes its entries from `.help` after a map restart. Lock and extended AFK
With all command modules installed, help shows one page of nine commands, commands appear on additional pages. Bank entries retain the full
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud` from the list.
removes `.hud` from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page; 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 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** **Features**
- Sets `g_password` to a random per-lobby value when the round crosses a - 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 - 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 - Trigger round is dvar-driven — retune without redeploying
**Provides** **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_round` | Round at which the lobby locks (read at init) |
| `level.lock_active` | `true` after the password has been set | | `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** **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` - `tws_t5_hud` — waits on `hud_round_changed` and `hud_game_over`
- Stock BO1: `level.round_number` - Stock BO1: `level.round_number`
- Dvar: `tws_lock_round` (int, default `20`) - 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 ## Dvars
+187 -17
View File
@@ -1,7 +1,7 @@
/* /*
tws_t5_lock.gsc — lobby g_password lock at milestone round. Existing players tws_t5_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
are unaffected; g_password is only checked at connect. Cleared on Existing players are unaffected; g_password is only checked at connect.
hud_game_over so the next lobby opens unlocked. A successful unlock overrides automatic locking for the rest of the match.
*/ */
#include maps\_utility; #include maps\_utility;
@@ -13,6 +13,8 @@ init()
return; return;
scripts\sp\tws_t5_common::ensure_config(); 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(); 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_round = scripts\sp\tws_t5_common::int_dvar_or("tws_lock_round", 20);
level.lock_active = false; 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 // recover from a prior lobby that crashed before clearing
setDvar("g_password", ""); setDvar("g_password", "");
level thread lock_round_watcher(); 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() lock_round_watcher()
{ {
level endon("hud_game_over"); level endon("tws_lock_game_over");
for (;;) for (;;)
{ {
level waittill("hud_round_changed"); level waittill("hud_round_changed");
lock_check_round();
}
}
if (level.lock_active) lock_check_round()
continue; {
if (lock_game_ended() || level.lock_auto_done)
return;
if (!isDefined(level.round_number) || level.round_number < level.lock_round) 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_chat_listener()
{
level endon("tws_lock_game_over");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue; continue;
// random per activation so the password can't be inferred from source 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); password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password); setDvar("g_password", password);
level.lock_active = true;
lock_announce();
} }
} else
lock_cleanup_watcher()
{ {
level waittill("hud_game_over"); 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", ""); setDvar("g_password", "");
level.lock_active = false; level.lock_active = false;
level notify("tws_lock_game_over");
} }
lock_announce() lock_announce(message)
{ {
players = getPlayers(); players = getPlayers();
for (i = 0; i < players.size; i++) for (i = 0; i < players.size; i++)
players[i] iPrintLnBold("^3Server locked"); players[i] iPrintLn(message);
} }
+19 -12
View File
@@ -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_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_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_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_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils | | `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
@@ -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 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, a short description of each. The list is shown only to the requesting player,
including spectators who have not spawned. Commands are case-insensitive. including spectators who have not spawned. Commands are case-insensitive.
Help uses the unboxed upper-right service area. All nine installed commands Help uses the unboxed upper-right service area with command and description
fit in one list with command and description columns. Additional registered columns. Registered commands use pages of nine rows, with a counter only for
commands use pages of nine rows, with a counter only for multi-page lists. multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either 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 command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text. 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) | | `.b` | Check bank balance (bank module required) |
| `.d <amount\|all>` | Deposit points (bank module required) | | `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) | | `.w <amount\|all>` | 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 Command modules register their commands during `init()` through
`tws_t6_common::register_help_command(command, description)`. If a module is `tws_t6_common::register_help_command(command, description)`. If a module is
omitted, its commands are omitted from `.help`. Commands follow the order above, omitted, its commands are omitted from `.help`. The first nine commands follow
independent of initialization order. Register new commands alongside their the order above; additional entries follow registration order. Register new
module's initialization so the list stays current. commands alongside their module's initialization so the list stays current.
With all command modules installed, help shows one page of nine commands, Lock and extended AFK commands appear on additional pages. Bank entries retain
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud`
removes `.hud` from the list. from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page; 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 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. - AFK uses T5's native `level waittill("say", message, player)` listener.
`.back` restores movement and weapons immediately, then ends protection after `.back` restores movement and weapons immediately, then ends protection after
two seconds. The T6 control/weapon fix remains in place. two seconds. The T6 control/weapon fix remains in place.
- Lock uses T5's HUD round/game-over notifications. Default threshold is 20. - Lock uses T5's HUD round notifications with a default automatic threshold of
As in T5, it clears `g_password` at startup and game over. 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 - Telemetry is enabled by default and posts to
`https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact, `https://ec.thewrightserver.net/api/telemetry`. Set `tws_server_id` to the exact,
case-sensitive Electric Cherry registry name; missing or overlong names case-sensitive Electric Cherry registry name; missing or overlong names
+187 -17
View File
@@ -1,7 +1,7 @@
/* /*
tws_t6_lock.gsc — lobby g_password lock at milestone round. Existing players tws_t6_lock.gsc — automatic lobby lock plus majority .lock / .unlock votes.
are unaffected; g_password is only checked at connect. Cleared on Existing players are unaffected; g_password is only checked at connect.
hud_game_over so the next lobby opens unlocked. A successful unlock overrides automatic locking for the rest of the match.
*/ */
#include maps\mp\_utility; #include maps\mp\_utility;
@@ -15,6 +15,8 @@ main()
init() init()
{ {
scripts\zm\tws_t6_common::ensure_config(); 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(); 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_round = scripts\zm\tws_t6_common::int_dvar_or("tws_lock_round", 20);
level.lock_active = false; 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 // recover from a prior lobby that crashed before clearing
setDvar("g_password", ""); setDvar("g_password", "");
level thread lock_round_watcher(); 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() lock_round_watcher()
{ {
level endon("hud_game_over"); level endon("tws_lock_game_over");
for (;;) for (;;)
{ {
level waittill("hud_round_changed"); level waittill("hud_round_changed");
lock_check_round();
}
}
if (level.lock_active) lock_check_round()
continue; {
if (lock_game_ended() || level.lock_auto_done)
return;
if (!isDefined(level.round_number) || level.round_number < level.lock_round) 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_chat_listener()
{
level endon("tws_lock_game_over");
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue; continue;
// random per activation so the password can't be inferred from source 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); password = "lock_" + getTime() + "_" + randomInt(1000000);
setDvar("g_password", password); setDvar("g_password", password);
level.lock_active = true;
lock_announce();
} }
} else
lock_cleanup_watcher()
{ {
level waittill("hud_game_over"); 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", ""); setDvar("g_password", "");
level.lock_active = false; level.lock_active = false;
level notify("tws_lock_game_over");
} }
lock_announce() lock_announce(message)
{ {
players = getPlayers(); players = getPlayers();
for (i = 0; i < players.size; i++) for (i = 0; i < players.size; i++)
players[i] iPrintLnBold("^3Server locked"); players[i] iPrintLn(message);
} }
+15 -1
View File
@@ -162,7 +162,7 @@ function assertTextOnly(e) {
'service elements are private, non-archived text with no background shaders'); 'service elements are private, non-archived text with no background shaders');
} }
for (const game of ['t5', 't6']) { 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); const e = engine(game, 8, true);
e.help(); e.help();
assert.deepEqual(e.helpCommands, assert.deepEqual(e.helpCommands,
@@ -180,6 +180,20 @@ for (const game of ['t5', 't6']) {
withoutHud.help(); withoutHud.help();
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud')); 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`, () => { check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
const e = engine(game); 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 track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
+270
View File
@@ -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(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+)/g)].map(m => 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)`);
+2 -2
View File
@@ -5,7 +5,7 @@
"tws_t5_hud.gsc": "1.4.2", "tws_t5_hud.gsc": "1.4.2",
"tws_t5_join.gsc": "1.0.0", "tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.2.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_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "2.1.0", "tws_t6_afk.gsc": "2.1.0",
"tws_t6_bank.gsc": "2.0.3", "tws_t6_bank.gsc": "2.0.3",
@@ -13,7 +13,7 @@
"tws_t6_hud.gsc": "1.4.2", "tws_t6_hud.gsc": "1.4.2",
"tws_t6_join.gsc": "1.0.1", "tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.2.0", "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", "tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.4.0", "t5-plugin": "1.4.0",
"t6-plugin": "1.4.0", "t6-plugin": "1.4.0",