Allow solo and last-active-player AFK
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user