Add majority lock and unlock voting for T5 and T6
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
|
||||
restriction. Additional registered commands use pages of nine rows; a page
|
||||
count appears only when more than one page is needed. Each page lasts
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
+27
-13
@@ -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 <amount\|all>` | Deposit 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,
|
||||
independent of initialization order; removing a module also removes its entries
|
||||
from `.help` after a map restart.
|
||||
With all command modules installed, help shows one page of nine commands,
|
||||
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
|
||||
removes `.hud` from the list.
|
||||
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 <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud` from the list.
|
||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||
cached map record privately. Repeating it restarts the record from the first page;
|
||||
help 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
|
||||
|
||||
+194
-24
@@ -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);
|
||||
}
|
||||
|
||||
+19
-12
@@ -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 <amount\|all>` | Deposit 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
|
||||
`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 <amount|all>` / `.w <amount|all>` 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 <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud`
|
||||
from the list.
|
||||
The enabled leaderboard module registers `.leaders`, which replays the latest
|
||||
cached map record privately. Repeating it restarts the record from the first page;
|
||||
help 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
|
||||
|
||||
+194
-24
@@ -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);
|
||||
}
|
||||
|
||||
+15
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user