Allow solo and last-active-player AFK

This commit is contained in:
2026-10-17 22:19:04 -04:00
parent c19bef6dc6
commit caaaa102a4
9 changed files with 115 additions and 142 deletions
+29
View File
@@ -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 |
+4 -1
View File
@@ -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.
+19 -12
View File
@@ -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
+5 -1
View File
@@ -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.
---
+1 -60
View File
@@ -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()
+4 -1
View File
@@ -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.
+1 -60
View File
@@ -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()
+50 -5
View File
@@ -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();
+2 -2
View File
@@ -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",