Compare commits

..
2 Commits
Author SHA1 Message Date
josh eda2c7e76d Add persistent Discord URL to T5 and T6 HUDs 2026-10-12 19:42:39 -04:00
josh 664e8da81b Fix T6 newcomer join eligibility after setup spawns 2026-10-12 19:38:57 -04:00
11 changed files with 388 additions and 17 deletions
+42
View File
@@ -6,6 +6,48 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-12
### T6 newcomer join eligibility — `tws_t6_join.gsc` `1.0.1`
- Fix `t6/scripts/tws_t6_join.gsc` treating the initial T6 setup spawn as
gameplay, which rejected a new spectator's `.join` on Farm Survival round 3.
Wait for the stock delayed spectator transition, then record actual play.
Continue observing later spawns so waiting for the next round cannot grant
an extra life after death. Reject requests while a spawn is settling; a real
death during that window also permanently closes eligibility.
- T5 is unchanged: its separate SP spawn flow and `self.initialized` guard
do not use T6's delayed game-mode spectator transition.
- Compatibility: existing T6 common `1.0.0` or later; no new dependency
requirement. Deploy the updated join script to `storage/t6/scripts/zm/`
and restart the map. No full server-process restart is required.
- Portable regression fixtures: `node tools/check_join.mjs`. Compilation and
live Plutonium validation remain required; no deployment was performed.
### Persistent top-left Discord URL
| Script | Version | Affected source |
| --- | --- | --- |
| `tws_t5_hud.gsc` | `1.1.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t5_common.gsc` | `1.1.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_hud.gsc` | `1.1.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t6_common.gsc` | `1.1.0` | `t6/scripts/tws_t6_common.gsc` |
- Add `https://discord.thewrightserver.net` as a small, light-gray top-left
HUD label, created once per connection, including spectator newcomers.
It has no expiry or death/respawn reset and is cleaned up on disconnect.
Move the gameplay stack down 20 HUD units to leave room above the timers;
help and record positioning follow the shared layout automatically.
- Both engines receive the feature using their existing HUD anchors: T5
left/top safe-area alignment and T6 fullscreen alignment.
- Compatibility: deploy each game's HUD and common `1.1.0` together for the
reserved top line. No new native plugin or external dependency requirement.
Other scripts and both plugins retain their existing versions.
- Deployment: copy the updated HUD and common scripts to `storage/t5/scripts/sp/`
or `storage/t6/scripts/zm/`, respectively, and restart the map. No full
server-process restart is required. No deployment has been performed here.
- Live validation remains required on both Plutonium games: check placement
and readability, join as a spectator, die/respawn, reconnect, and open help
and records to verify spacing and that only one URL label appears.
### Initial versioning baseline
Established independent version `1.0.0` entries for each of the 14 scripts and
+10 -1
View File
@@ -11,7 +11,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| File | Role |
|---|---|
| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, `.help`, connect/spawn registries, single `connected` listener + dispatcher |
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Bottom-left HUD stack: timers, zombie count, health bar |
| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Top-left Discord URL, timers, zombie count, health bar |
| [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator |
| [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 |
@@ -31,6 +31,15 @@ that directory:
Do not leave both common scripts installed: duplicate connection listeners
break T5's connect handshake. Existing dvar names are unchanged.
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
at the top-left, including for newcomers waiting in spectator mode. The label
is created once per connection and persists across death and respawn. The
gameplay HUD starts one line lower, keeping timers, help and records clear.
Install HUD and common `1.1.0` together, then restart the map; no plugin update
or full server-process restart is required.
## Map record announcements
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
+2 -2
View File
@@ -20,9 +20,9 @@ common_config()
if (isDefined(level.hud_x))
return;
// anchor: move the whole cluster with these two
// Anchor the gameplay stack below the persistent Discord line.
level.hud_x = 10;
level.hud_y = 10;
level.hud_y = 30;
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
+16 -1
View File
@@ -1,4 +1,4 @@
/* tws_t5_hud.gsc — bottom-left HUD stack. Timer / zombie count / health bar. */
/* tws_t5_hud.gsc — top-left Discord URL, timers, zombie count and health bar. */
#include maps\_utility;
#include common_scripts\utility;
@@ -14,6 +14,21 @@ init()
level thread game_over_watcher();
scripts\sp\tws_t5_common::register_spawn_handler(::hud_start);
scripts\sp\tws_t5_common::register_connect_handler(::hud_discord);
}
// One static label per connection, including newcomers waiting to spawn.
hud_discord()
{
self endon("disconnect");
label = scripts\sp\tws_t5_common::make_text_elem(
self, 10, 10, "left", level.hud_font_small, level.hud_c_label);
label SetText("https://discord.thewrightserver.net");
elems = [];
elems[elems.size] = label;
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
}
hud_start()
+19
View File
@@ -118,6 +118,25 @@ at link time. The other four TWS modules can run without telemetry.
## Checks
### Newcomer eligibility
The stock [`_zm_gametype::onspawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_zm_gametype.gsc)
can schedule `spawnspectator` after 0.05 seconds while
[`_globallogic_spawn::spawnplayer`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_spawn.gsc)
emits `spawned_player` at frame end. That event alone does not prove gameplay.
Join `1.0.1` waits 0.1 seconds before checking session state and keeps listening
after setup spectator spawns. `killed_player` also consumes eligibility so a
death during that interval cannot restore newcomer access. The common dispatcher
still starts each registered module once per connection.
Run `node tools/check_join.mjs` for portable lifecycle and request fixtures.
These execute the join script with engine shims, not a GSC VM. Live validation:
join Farm Survival during round 3 and remain spectating, use `.join` once, then
try repeated requests, death, and subsequent rounds. On a fresh connection,
wait for a normal round-start spawn, die, and verify `.join` is rejected.
Also check a round-1 participant, reconnecting, disconnect during setup, and
game over. Repeat on another T6 map; compile with the checker below first.
Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared
references and entry points. It requires PowerShell and gsc-tool. The `.help`
change passed the static entry-point/reference checks, but compilation and live
+10 -1
View File
@@ -7,7 +7,7 @@ separate token config or database setup is required.
| File | Role | Dependencies |
| --- | --- | --- |
| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | Stock T6 |
| `tws_t6_hud.gsc` | Match/round timers, name, zombie count and health bar | Common |
| `tws_t6_hud.gsc` | Top-left Discord URL, match/round timers, name, zombie count and health bar | Common |
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common |
| `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
@@ -40,6 +40,15 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
To omit telemetry, remove its script; disabling it at runtime does not remove
its plugin dependency during linking. The same rule applies to leaderboard.
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
at the top-left, including for newcomers waiting in spectator mode. The label
is created once per connection and persists across death and respawn. The
gameplay HUD starts one line lower, keeping timers, help and records clear.
Install HUD and common `1.1.0` together, then restart the map; no plugin update
or full server-process restart is required.
## Map record announcements
`tws_t6_leaderboard.gsc` reads the public Electric Cherry API without credentials.
+2 -2
View File
@@ -22,9 +22,9 @@ common_config()
if (isDefined(level.hud_x))
return;
// Small inset from the physical screen corner (fullscreen HUD coordinates).
// Fullscreen coordinates; reserve the top line for the Discord URL.
level.hud_x = 10;
level.hud_y = 10;
level.hud_y = 30;
level.hud_row_match = level.hud_y;
level.hud_row_round = level.hud_y + 15;
+16 -1
View File
@@ -1,4 +1,4 @@
/* tws_t6_hud.gsc - configurable HUD stack: timers, zombie count, health bar. */
/* tws_t6_hud.gsc - top-left Discord URL, timers, zombie count and health bar. */
#include maps\mp\_utility;
#include common_scripts\utility;
@@ -16,6 +16,21 @@ init()
level thread game_over_watcher();
scripts\zm\tws_t6_common::register_spawn_handler(::hud_start);
scripts\zm\tws_t6_common::register_connect_handler(::hud_discord);
}
// One static label per connection, including newcomers waiting to spawn.
hud_discord()
{
self endon("disconnect");
label = scripts\zm\tws_t6_common::make_text_elem(
self, 10, 10, "left", level.hud_font_small, level.hud_c_label);
label SetText("https://discord.thewrightserver.net");
elems = [];
elems[elems.size] = label;
self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems);
}
hud_start()
+39 -4
View File
@@ -20,9 +20,44 @@ init()
level thread join_chat_listener();
}
// Never reset this on death or round change. Spectator spawns do not count.
// T6 first emits spawned_player during setup, then moves late connectors
// into spectator mode after 0.05 seconds. Observe the settled state instead.
// The common spawn dispatcher runs only once, so keep listening ourselves.
join_spawn()
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
self thread join_track_death();
for (;;)
{
self.tws_join_spawn_pending = true;
wait 0.1;
if (isDefined(self.sessionstate) && self.sessionstate == "playing")
self.tws_join_has_played = true;
self.tws_join_spawn_pending = false;
if (is_true(self.tws_join_has_played))
return;
self waittill("spawned_player");
}
}
// A real death during the settling window still consumes newcomer eligibility.
// Never clear this flag on death, a round change, or a subsequent spawn.
join_track_death()
{
self endon("disconnect");
level endon("end_game");
level endon("game_ended");
level endon("intermission");
self waittill("killed_player");
self.tws_join_has_played = true;
}
@@ -51,8 +86,7 @@ join_request()
level endon("game_ended");
level endon("intermission");
// The spawn handler flag is set on first playable spawn only; spectator
// spawns do not trigger it. Belt-and-suspenders against re-triggering.
// Setup spawns do not consume eligibility; actual play and .join do.
if (is_true(self.tws_join_has_played) || is_true(self.tws_join_used))
{
self iPrintLnBold("^3.join is only for newcomers who have not played yet");
@@ -62,7 +96,8 @@ join_request()
// Fail closed while the stock connection setup is still in progress.
// T6 stock does not populate self.initialized the way T5 SP zombiemode
// does; use sessionstate as the readiness signal.
if (!isDefined(self.sessionstate) || self.sessionstate != "spectator")
if (is_true(self.tws_join_spawn_pending) ||
!isDefined(self.sessionstate) || self.sessionstate != "spectator")
{
self iPrintLnBold("^3.join is only available while waiting to enter the game");
return;
+227
View File
@@ -0,0 +1,227 @@
// Execute the actual T6 join functions with a deterministic event/timer shim.
// This checks script decisions, not GSC compilation or live engine behavior.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../t6/scripts/tws_t6_join.gsc', import.meta.url), 'utf8');
const functions = [];
for (const name of ['join_spawn', 'join_track_death', 'join_request']) {
const match = new RegExp(`^${name}\\(\\)\\s*\\n\\{`, 'm').exec(source);
// The original implementation has no death observer; allow regression runs.
if (!match && name === 'join_track_death') continue;
assert(match, `missing ${name}`);
let end = match.index + match[0].length, depth = 1;
const start = end;
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
let body = source.slice(start, end - 1).replace(/\/\/[^\n]*/g, '');
body = body
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
.replace(/self thread (\w+)\(\);/g, 'startThread($1);')
.replace(/self waittill\(([^;]+)\);/g, 'yield { entity: self, event: $1 };')
.replace(/\bwait ([\d.]+);/g, 'yield { seconds: $1 };')
.replace(/self iPrintLnBold\(/g, 'messages.push(')
.replace(/self \[\[level.spawnplayer\]\]\(\);/g, 'spawnPlayer();')
.replace(/maps\\mp\\zombies\\_zm::spectator_respawn/g, 'stockRespawn')
.replace(/!join_has_active_player\(\)/g, '!activePlayer')
.replace(/\.size\b/g, '.length');
functions.push(`function* ${name}() { let points; ${body} }`);
}
function engine() {
let now = 0, current;
const tasks = [], messages = [];
const self = { sessionstate: 'playing', spectator_respawn: {} };
const level = { round_number: 3, spawnplayer: true };
let spawns = 0;
function resume(task) {
const previous = current;
current = task;
const result = task.generator.next();
task.done = result.done;
task.wait = result.value;
if (task.wait?.seconds !== undefined) task.at = now + task.wait.seconds;
else task.at = Infinity;
current = previous;
}
function startThread(fn) {
const task = { generator: fn(), ends: [] };
tasks.push(task);
resume(task);
}
function event(entity, event) {
const listeners = tasks.filter((task) => !task.done);
for (const task of listeners) {
if (task.ends.some(([e, name]) => e === entity && name === event)) task.done = true;
}
for (const task of listeners) {
if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task);
}
}
function advance(seconds) {
const until = now + seconds;
for (;;) {
const next = tasks.filter((task) => !task.done && task.at <= until)
.sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
resume(next);
}
now = until;
}
const c = vm.createContext({
self, level, messages, activePlayer: true, stockRespawn: () => {},
isDefined: (value) => value !== undefined && value !== null,
is_true: (value) => value === true || value === 1,
endon: (entity, event) => current.ends.push([entity, event]),
startThread,
getstructarray: () => [{}], randomInt: () => 0,
spawnPlayer: () => {
spawns++;
self.sessionstate = 'playing';
event(self, 'spawned_player');
},
});
vm.runInContext(functions.join('\n'), c);
return { self, level, c, messages, tasks, advance,
start: () => startThread(c.join_spawn),
request: () => startThread(c.join_request),
event: (name) => event(self, name),
end: (name) => event(level, name),
get spawns() { return spawns; },
};
}
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
function newcomer() {
const e = engine();
e.start(); // Common's once-per-connection callback, at spawned_player.
e.advance(0.05);
e.self.sessionstate = 'spectator'; // Stock delayed setup transition.
e.event('spawned_spectator');
e.advance(0.1);
return e;
}
check('Farm round 3 setup spawn leaves a newcomer eligible for exactly one join', () => {
const e = newcomer();
assert(!e.self.tws_join_has_played);
e.request();
assert.equal(e.spawns, 1);
assert.equal(e.self.tws_join_used, true);
e.request();
e.self.sessionstate = 'spectator';
e.event('killed_player');
e.level.round_number++;
e.request();
assert.equal(e.spawns, 1);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('setup transition blocks requests until settled', () => {
const e = engine();
e.start();
e.advance(0.05);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /waiting to enter/);
e.advance(0.1);
e.request();
assert.equal(e.spawns, 1);
});
check('a normal round-start spawn closes eligibility even after later death', () => {
const e = newcomer();
e.level.round_number++;
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /only for newcomers/);
});
check('round-1 participants cannot join again after death', () => {
const e = engine();
e.level.round_number = 1;
e.start();
e.advance(0.11);
e.self.sessionstate = 'spectator';
e.request();
assert.equal(e.spawns, 0);
assert.equal(e.self.tws_join_has_played, true);
});
for (const initial of [true, false]) {
check(`death during ${initial ? 'initial' : 'later'} spawn settling consumes eligibility`, () => {
const e = initial ? engine() : newcomer();
if (initial) e.start();
else {
e.self.sessionstate = 'playing';
e.event('spawned_player');
}
e.advance(0.02);
e.event('killed_player');
e.self.sessionstate = 'spectator';
e.advance(0.11);
e.request();
assert.equal(e.self.tws_join_has_played, true);
assert.equal(e.spawns, 0);
});
}
check('repeated spectator setup events keep listening for an actual spawn', () => {
const e = newcomer();
e.event('spawned_player');
e.advance(0.11);
assert(!e.self.tws_join_has_played);
e.self.sessionstate = 'playing';
e.event('spawned_player');
e.advance(0.11);
assert.equal(e.self.tws_join_has_played, true);
});
for (const name of ['disconnect', 'end_game', 'game_ended', 'intermission']) {
check(`${name} stops observers during setup and while waiting`, () => {
for (const setup of [true, false]) {
const e = setup ? engine() : newcomer();
if (setup) e.start();
if (name === 'disconnect') e.event(name);
else e.end(name);
e.advance(1);
e.event('killed_player');
assert(!e.self.tws_join_has_played);
assert(e.tasks.every((task) => task.done));
}
});
}
for (const [name, set] of [
['intermission', (e) => { e.level.intermission = true; }],
['game ended', (e) => { e.level.gameended = true; }],
['no active player', (e) => { e.c.activePlayer = false; }],
['missing round', (e) => { delete e.level.round_number; }],
['missing spawn callback', (e) => { delete e.level.spawnplayer; }],
]) {
check(`${name} still rejects joining`, () => {
const e = newcomer();
set(e);
e.request();
assert.equal(e.spawns, 0);
assert.match(e.messages.at(-1), /not ready/);
});
}
check('reconnecting starts independent eligibility', () => {
const first = newcomer();
first.request();
first.event('disconnect');
const second = newcomer();
second.request();
assert.equal(second.spawns, 1);
});
console.log(`PASS ${checks} T6 join lifecycle/request checks (engine shims; live validation required)`);
+5 -5
View File
@@ -1,15 +1,15 @@
{
"tws_t5_afk.gsc": "1.0.0",
"tws_t5_common.gsc": "1.0.0",
"tws_t5_hud.gsc": "1.0.0",
"tws_t5_common.gsc": "1.1.0",
"tws_t5_hud.gsc": "1.1.0",
"tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.0.0",
"tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.0.0",
"tws_t6_common.gsc": "1.0.0",
"tws_t6_hud.gsc": "1.0.0",
"tws_t6_join.gsc": "1.0.0",
"tws_t6_common.gsc": "1.1.0",
"tws_t6_hud.gsc": "1.1.0",
"tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.0.0",
"tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0",