Add majority lock and unlock voting for T5 and T6

This commit is contained in:
2026-10-18 16:01:57 -04:00
parent caaaa102a4
commit 9ef5932d36
10 changed files with 816 additions and 78 deletions
+1 -1
View File
@@ -136,6 +136,6 @@ hides/shows it, and after game over freezes the count.
| `tws_t5_hud` | Unboxed core overlays and private `.hud` visibility toggle | `hud_round_changed`, `hud_match_started`, `hud_game_over` | `say`, internal notifications |
| `tws_t5_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
View File
@@ -14,7 +14,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
| [tws_t5_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
View File
@@ -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);
}