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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+194
-24
@@ -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)
|
|
||||||
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_cleanup_watcher()
|
lock_check_round()
|
||||||
{
|
{
|
||||||
level waittill("hud_game_over");
|
if (lock_game_ended() || level.lock_auto_done)
|
||||||
setDvar("g_password", "");
|
return;
|
||||||
level.lock_active = false;
|
|
||||||
|
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();
|
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
@@ -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
|
||||||
|
|||||||
+194
-24
@@ -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)
|
|
||||||
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_cleanup_watcher()
|
lock_check_round()
|
||||||
{
|
{
|
||||||
level waittill("hud_game_over");
|
if (lock_game_ended() || level.lock_auto_done)
|
||||||
setDvar("g_password", "");
|
return;
|
||||||
level.lock_active = false;
|
|
||||||
|
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();
|
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
@@ -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);
|
||||||
|
|||||||
@@ -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_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",
|
||||||
|
|||||||
Reference in New Issue
Block a user