diff --git a/CHANGELOG.md b/CHANGELOG.md index bb98670..df68b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ 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. + ### Initial versioning baseline Established independent version `1.0.0` entries for each of the 14 scripts and diff --git a/t6/scripts/NOTES.md b/t6/scripts/NOTES.md index 323334c..743220a 100644 --- a/t6/scripts/NOTES.md +++ b/t6/scripts/NOTES.md @@ -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 diff --git a/t6/scripts/tws_t6_join.gsc b/t6/scripts/tws_t6_join.gsc index d164db1..d3522f7 100644 --- a/t6/scripts/tws_t6_join.gsc +++ b/t6/scripts/tws_t6_join.gsc @@ -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; diff --git a/tools/check_join.mjs b/tools/check_join.mjs new file mode 100644 index 0000000..0c40d9c --- /dev/null +++ b/tools/check_join.mjs @@ -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)`); diff --git a/versions.json b/versions.json index a0baf8c..374d19f 100644 --- a/versions.json +++ b/versions.json @@ -9,7 +9,7 @@ "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_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",