diff --git a/CHANGELOG.md b/CHANGELOG.md index da1daa1..85176a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ T5/T6 script and each native plugin. Current versions are recorded in ## 2026-10-17 +### Allow solo and last-active-player AFK + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_afk.gsc` | `2.1.0` | `t5/scripts/tws_t5_afk.gsc` | +| `tws_t6_afk.gsc` | `2.1.0` | `t6/scripts/tws_t6_afk.gsc` | + +- Remove the teammate requirement before reservation and after start + authorization on both engines. Solo players, the last active player and + players whose teammates are already AFK or awaiting authorization can enter. +- Remove the AFK team watcher that ended matches when no non-AFK survivor + remained. All players may be AFK together, and teammate death/disconnect + does not cancel protection. Keep reserve accounting, the local deadline, + cooldown, player-state checks and normal stock match-end settlement. +- Compatibility: no new dependency requirement. Existing common `1.4.0+`, + telemetry `1.0.0+`, matching plugin `1.4.0+`, Plutonium r5334+, EC #151 and + host agent `1.12.0+` requirements remain unchanged. T5 and T6 receive the same + policy change using their existing engine-specific state checks. +- Validation: 47 AFK script behavior checks passed, including solo entry, + unavailable/pending teammates, teammate loss during authorization, all-AFK + play, solo return/expiration and stock match-end cleanup. The changed T6 + script compiled and assembled with upstream gsc-tool `8f1d6bd` libraries. + Portable checks use engine shims; live T5/T6 validation remains required. + No Windows plugin build, live server test or deployment was performed. +- Deployment: copy the updated AFK script for each installed game and restart + the map. No full server-process restart or additional deployment action is + required for this script-only update. See the [AFK guide](docs/afk.md) for + the updated live validation cases. + ### Increase map-record reminders as players approach the target | Component | Version | Affected source | diff --git a/README.md b/README.md index 316a55a..ad4b425 100644 --- a/README.md +++ b/README.md @@ -661,7 +661,10 @@ never reused. Native plugin updates require a full game-server process restart. ## Persistent AFK reserves (T5 and T6) See [AFK reserves](docs/afk.md) for commands, gameplay rewards, countdown behavior, -recovery, coordinated EC deployment and the required live validation. AFK `2.0.0` +recovery, coordinated EC deployment and the required live validation. AFK `2.1.0` requires matching TWS plugin `1.4.0+`, common `1.4.0+`, telemetry `1.0.0+`, Plutonium r5334+, EC #151 and host agent `1.12.0+`. Full server-process restart is required when installing the updated plugins. +Solo and last-active-player AFK are allowed, including all players being AFK +together. Upgrading only the AFK scripts from `2.0.0` requires a map restart, +with no new dependencies or full server-process restart. diff --git a/docs/afk.md b/docs/afk.md index 7a153f9..280c706 100644 --- a/docs/afk.md +++ b/docs/afk.md @@ -30,18 +30,19 @@ immediately, including in the damage callback; the script restores its own contr weapons and zombie-targeting state. Expiration has no spectator transition, purchase or extra grace. Repeating `.afk` does not restart the timer or reserve twice. -Entry rejects downed/dead/spectating players, stock protection/control restrictions, -and the last active player. A default 15-second reentry cooldown prevents repeated +AFK `2.1.0` allows solo players and the last active player to enter; all players +may be AFK at the same time. Entry still rejects downed/dead/spectating players +and stock protection/control restrictions. A default 15-second reentry cooldown prevents repeated combat toggles. Manual `.back` retains a separate two-second grace. The AFK damage callback passes through when it does not own protection, preserving stock game invulnerability. Other mods that restrict controls should set `player.tws_afk_external_controls` while their restriction applies. AFK leaves player session state unchanged, so stock player-count zombie scaling -continues to include AFK players. If the last non-AFK active player dies or leaves, -the scripts release AFK sessions and send the stock end-game signal. Stock afterlife, -`no_end_game_check` and Grief reset recovery defer this behavior. A lobby with no -AFK player is unaffected. Verify these rules on each deployed map/mode. +continues to include AFK players. Losing the last non-AFK teammate does not end +protected breaks or force a match end. Authorized time continues to count down; +normal expiration and stock match-end cleanup still apply. Verify these rules +on each deployed map/mode. ## Rewards @@ -90,13 +91,17 @@ Recheck callback contracts if the stock runtime changes. 2. Update all EC host agents to **1.12.0+** before enabling these scripts. This adds AFK settlement before deleting/rebuilding stopped runtime storage. 3. Build and install TWS `t5-plugin` and `t6-plugin` **1.4.0+** and matching AFK - **2.0.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and + **2.1.0** scripts. Require common **1.4.0+**, telemetry **1.0.0+**, and Plutonium **r5334+** for `SetTextUnlimited`. Fully restart game-server processes. 4. Retain EC's private `tws_bank_token` and exact `tws_server_id`; telemetry must establish the current match before AFK commands can be authorized. 5. Keep `tws-afk/t5/` and `tws-afk/t6/`, relative to the process working directory, on persistent writable storage with one process per server/endpoint scope. +Upgrading AFK scripts from `2.0.0` to `2.1.0` adds no dependency requirement: +copy the matching script for each game and restart the map. No full server-process +restart is required for this script-only update. + | Game dvar | Default | Meaning | | --- | --- | --- | | `tws_afk_enabled` | enabled | `0` disables new AFK service startup | @@ -127,7 +132,7 @@ process crash can lose progress that had not yet reached the local journal. Already accepted rewards and journaled snapshots persist. Protection starts only after reservation and start acknowledgments, a final -player/lobby recheck, and a durable native clock start. Once authorized, an EC +player-state recheck, and a durable native clock start. Once authorized, an EC outage cannot shorten protection; a local monotonic deadline still ends it at zero. Manual return, disconnect, death and match end stop accounting immediately. A failed stop-journal write freezes the original stop time for retry rather than @@ -182,14 +187,16 @@ Before production activation, validate **both engines**: Drop reserve/start/stop replies, reorder delivery, fill/fail journal storage, and restart. Verify never-started refunds, durable exact stops and unconfirmed crash holds. Correct only a proven stopped session and confirm the ledger audit. -- Reject the last active player's entry. Kill/disconnect the last active teammate - while another is AFK; validate normal end reporting and all AFK settlement. - Exercise stock recovery exceptions and confirm expected zombie scaling. +- Enter as the only connected player and as the last active player with downed, + dead, spectating or AFK teammates. Put every player in AFK. Kill/disconnect the + last active teammate, including while reserve/start replies are pending; verify + protection continues without a forced match end. Check solo return/expiration, + normal stock match-end settlement, afterlife/Grief recovery and zombie scaling. - Rebuild/remove with journals from both games, retire the instance credential, interrupt the agent after a lost reply, and retry. Runtime deletion must wait for acknowledged recovery and cannot authorize a new purchase. -Validation performed for this change: 39 AFK behavior checks, 14 core HUD and +Validation performed for the initial `2.0.0` change: 39 AFK behavior checks, 14 core HUD and 56 service HUD checks passed. All nine T6 scripts compiled and assembled with the upstream [gsc-tool compiler 8f1d6bd](https://github.com/xensik/gsc-tool/tree/8f1d6bd4d51efdc619629b9254fb439427a6d306) using its T6 PC compiler/assembler library in a temporary local driver. No Windows diff --git a/t5/scripts/README.md b/t5/scripts/README.md index f9bbe99..8b918b6 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -340,7 +340,11 @@ Chat uses `level waittill("say")`; the independent central countdown stays visib with `.hud` hidden. Activation rejects laststand before changing state. A stock damage callback owns only AFK protection; expiration immediately relinquishes it and AFK-owned controls/targeting, while manual `.back` keeps its separate two-second -grace. Spawn handler `::afk_spawn` binds contribution and lifecycle watchers. +grace. AFK `2.1.0` permits solo and last-active-player entry, including all players +being AFK together; teammate loss does not force a match end. Upgrade from `2.0.0` +by copying the script and restarting the map; no new dependencies or full +server-process restart are required. Spawn handler `::afk_spawn` binds contribution +and lifecycle watchers. --- diff --git a/t5/scripts/tws_t5_afk.gsc b/t5/scripts/tws_t5_afk.gsc index d1bfbed..9e5eddc 100644 --- a/t5/scripts/tws_t5_afk.gsc +++ b/t5/scripts/tws_t5_afk.gsc @@ -45,7 +45,6 @@ init() level thread afk_startup(); level thread afk_end_watcher(); level thread afk_round_watcher(); - level thread afk_team_watcher(); } afk_startup() @@ -408,11 +407,6 @@ afk_activate(player, state) afk_say(state, "Wait briefly after returning before entering AFK again."); return; } - if (afk_other_active(player) == 0) - { - afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight."); - return; - } data = afk_base(state, "reserve"); data["session_id"] = telemetryNewMatch(); data["seconds"] = 604800; @@ -459,7 +453,7 @@ afk_activate(player, state) reply = afk_http("start", start_body, start["request_id"]); if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || reply["authorized_seconds"] != authorized || !state.connected || - state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) + state.cancelled || !afk_can_enter(player)) { afk_stop_state(state, "never_started"); afk_say(state, "AFK entry cancelled. No protected time used."); @@ -710,59 +704,6 @@ afk_end_watcher() level.afk_ended = true; } -afk_other_active(excluded) -{ - count = 0; - players = getPlayers(); - for (i = 0; i < players.size; i++) - { - player = players[i]; - if (isDefined(excluded) && player == excluded) - continue; - if (!afk_playing(player) || is_true(player.hud_afk_on)) - continue; - if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending) - continue; - count++; - } - return count; -} - -afk_team_watcher() -{ - level endon("end_game"); - level endon("game_ended"); - level endon("intermission"); - for (;;) - { - players = getPlayers(); - protected = 0; - survivors = 0; - stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief); - for (i = 0; i < players.size; i++) - { - player = players[i]; - if (is_true(player.afterlife)) - stock_recovery = true; - if (is_true(player.hud_afk_on)) - protected++; - else if (afk_playing(player)) - survivors++; - } - if (protected > 0 && survivors == 0 && !stock_recovery) - { - for (i = 0; i < players.size; i++) - if (is_true(players[i].hud_afk_on)) - players[i] afk_finish("match_end", false); - // AFK does not change sessionstate/player count or zombie scaling. - // The verified stock end signal prevents AFK bodies prolonging play. - level notify("end_game"); - return; - } - wait 0.05; - } -} - // Both stock engines notify the credited attacker, with the actual dead zombie. // Never infer contributions from spendable score, score_total, or bank actions. afk_kill_watcher() diff --git a/t6/scripts/README.md b/t6/scripts/README.md index d46bac2..73884e3 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -300,7 +300,10 @@ configuration, persistence, action results and deployment checks. ## Persistent AFK reserve -AFK `2.0.0` uses EC authorization and a local central countdown. See the +AFK `2.1.0` uses EC authorization and a local central countdown. See the [shared AFK guide](../../docs/afk.md) for exact commands, dependencies, recovery and live checks. Install plugin `1.4.0+` and fully restart the server process. The countdown remains visible with `.hud` hidden; expiration has no grace period. +Solo and last-active-player entry are allowed, including all players being AFK +together; teammate loss does not force a match end. Upgrading from `2.0.0` adds +no dependencies and requires only copying the script and restarting the map. diff --git a/t6/scripts/tws_t6_afk.gsc b/t6/scripts/tws_t6_afk.gsc index 93b5bb6..fe1feb1 100644 --- a/t6/scripts/tws_t6_afk.gsc +++ b/t6/scripts/tws_t6_afk.gsc @@ -43,7 +43,6 @@ init() level thread afk_startup(); level thread afk_end_watcher(); level thread afk_round_watcher(); - level thread afk_team_watcher(); } afk_startup() @@ -405,11 +404,6 @@ afk_activate(player, state) afk_say(state, "Wait briefly after returning before entering AFK again."); return; } - if (afk_other_active(player) == 0) - { - afk_say(state, "The last active player cannot enter AFK. Another player must remain in the fight."); - return; - } data = afk_base(state, "reserve"); data["session_id"] = telemetryNewMatch(); data["seconds"] = 604800; @@ -456,7 +450,7 @@ afk_activate(player, state) reply = afk_http("start", start_body, start["request_id"]); if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session || reply["authorized_seconds"] != authorized || !state.connected || - state.cancelled || !afk_can_enter(player) || afk_other_active(player) == 0) + state.cancelled || !afk_can_enter(player)) { afk_stop_state(state, "never_started"); afk_say(state, "AFK entry cancelled. No protected time used."); @@ -707,59 +701,6 @@ afk_end_watcher() level.afk_ended = true; } -afk_other_active(excluded) -{ - count = 0; - players = getPlayers(); - for (i = 0; i < players.size; i++) - { - player = players[i]; - if (isDefined(excluded) && player == excluded) - continue; - if (!afk_playing(player) || is_true(player.hud_afk_on)) - continue; - if (isDefined(player.tws_afk_state) && player.tws_afk_state.pending) - continue; - count++; - } - return count; -} - -afk_team_watcher() -{ - level endon("end_game"); - level endon("game_ended"); - level endon("intermission"); - for (;;) - { - players = getPlayers(); - protected = 0; - survivors = 0; - stock_recovery = is_true(level.no_end_game_check) || is_true(level.isresetting_grief); - for (i = 0; i < players.size; i++) - { - player = players[i]; - if (is_true(player.afterlife)) - stock_recovery = true; - if (is_true(player.hud_afk_on)) - protected++; - else if (afk_playing(player)) - survivors++; - } - if (protected > 0 && survivors == 0 && !stock_recovery) - { - for (i = 0; i < players.size; i++) - if (is_true(players[i].hud_afk_on)) - players[i] afk_finish("match_end", false); - // AFK does not change sessionstate/player count or zombie scaling. - // The verified stock end signal prevents AFK bodies prolonging play. - level notify("end_game"); - return; - } - wait 0.05; - } -} - // Both stock engines notify the credited attacker, with the actual dead zombie. // Never infer contributions from spendable score, score_total, or bank actions. afk_kill_watcher() diff --git a/tools/check_afk.mjs b/tools/check_afk.mjs index d9af6f0..87e3a70 100644 --- a/tools/check_afk.mjs +++ b/tools/check_afk.mjs @@ -167,11 +167,56 @@ for(const game of ['t5','t6']) { assert.equal(e.p.tws_afk_controls_locked,true);e.p.downed=false;e.p.ignoreme=false;e.tick();assert.equal(e.p.tws_afk_controls_locked,false); assert(!e.source.includes('disableInvulnerability('));assert(!e.source.includes('enableInvulnerability(')); }); - test('last active cannot enter; last active death ends only AFK-only matches, respecting afterlife',()=>{ - const alone=engine(game);alone.other.downed=true;alone.enter();assert(!alone.p.hud_afk_on);assert.equal(alone.sent.length,0); - const e=engine(game);e.enter();e.other.alive=false;e.c.afk_team_watcher().next();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'match_end');assert(e.log.some(x=>x[0]==='notify'&&x[1]==='end_game')); - const recovering=engine(game);recovering.enter();recovering.other.afterlife=true;recovering.c.afk_team_watcher().next();assert(recovering.p.hud_afk_on); - const noAfk=engine(game);noAfk.p.alive=false;noAfk.other.alive=false;noAfk.c.afk_team_watcher().next();assert(!noAfk.log.some(x=>x[0]==='notify')); + test('solo and last active players can enter with unavailable or pending teammates',()=>{ + for(const flags of [null,{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{hud_afk_on:true},{pending:true}]){ + const e=engine(game); + if(flags===null)e.players.splice(1,1); + else if(flags.pending)e.other.tws_afk_state.pending=true; + else Object.assign(e.other,flags); + e.enter();e.advance(1000);e.tick(); + assert(e.p.hud_afk_on);assert.equal(e.p.afk_remaining(),1799000); + assert.equal(e.stops.length,0);assert.equal(e.p.sessionstate,'playing'); + assert(!e.log.some(x=>x[0]==='notify')); + } + }); + test('teammate death or disconnect during authorization does not cancel entry',()=>{ + for(const at of ['reserve','start'])for(const change of ['death','disconnect']){ + const e=engine(game,{during:request=>{ + if(request.action!==at)return; + if(change==='death')e.other.alive=false; + else e.players.splice(1,1); + }}); + e.enter();e.tick();assert(e.p.hud_afk_on);assert.equal(e.stops.length,0); + } + }); + test('all players can be AFK and teammate loss does not force a match end',()=>{ + const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state); + e.advance(1000);e.tick();e.other.afk_tick().next(); + assert(e.p.hud_afk_on);assert(e.other.hud_afk_on);assert.equal(e.stops.length,0); + e.other.alive=false;e.other.afk_tick().next();e.tick(); + assert(e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'death'); + e.players.splice(1,1);e.advance(1000);e.tick();assert(e.p.hud_afk_on); + assert(!e.log.some(x=>x[0]==='notify')); + assert(!e.source.includes('afk_team_watcher')); + assert(!/level\s+notify\("end_game"\)/.test(e.source)); + }); + test('solo protection expires normally and manual return preserves bounded grace',()=>{ + for(const manual of [false,true]){ + const e=engine(game,{authorized:3});e.players.splice(1,1);e.enter(); + e.advance(1000);assert.equal(e.p.afk_damage(null,null,10),0); + if(manual){e.p.afk_exit();assert.equal(e.stops.at(-1).reason,'manual');} + e.advance(2000);e.tick(); + assert(!e.p.hud_afk_on);assert(!e.p.tws_afk_protected);assert(!e.p.ignoreme); + assert(!e.p.tws_afk_controls_locked);assert.equal(e.p.afk_damage(null,null,10),-1); + if(!manual)assert.equal(e.stops.at(-1).reason,'expired'); + } + }); + test('stock match end still releases and settles every AFK player',()=>{ + const e=engine(game);e.enter();e.c.afk_enter(e.other,e.other.tws_afk_state); + const watcher=e.c.afk_end_watcher();watcher.next();watcher.next(); + assert(e.level.afk_ended);assert.equal(e.stops.length,2); + for(const p of e.players){assert(!p.hud_afk_on);assert(!p.tws_afk_protected);assert(p.tws_afk_state.cancelled);} + assert(e.stops.every(s=>s.reason==='match_end')); }); test('repeat legitimate teammate revives all count, self/AFK/spectator revives do not',()=>{ const e=engine(game);const watcher=e.other.afk_revive_watcher();watcher.next(); diff --git a/versions.json b/versions.json index eea65a2..c83e7b0 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "tws_t5_afk.gsc": "2.0.0", + "tws_t5_afk.gsc": "2.1.0", "tws_t5_bank.gsc": "2.0.3", "tws_t5_common.gsc": "1.5.0", "tws_t5_hud.gsc": "1.4.2", @@ -7,7 +7,7 @@ "tws_t5_leaderboard.gsc": "1.2.0", "tws_t5_lock.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0", - "tws_t6_afk.gsc": "2.0.0", + "tws_t6_afk.gsc": "2.1.0", "tws_t6_bank.gsc": "2.0.3", "tws_t6_common.gsc": "1.5.0", "tws_t6_hud.gsc": "1.4.2",