Files
tws-zombies-scripts/tools/check_afk.mjs
T

227 lines
20 KiB
JavaScript

// Execute the actual T5/T6 AFK functions under portable engine/HTTP/journal shims.
// This checks command, accounting and lifecycle logic; it is not a GSC game VM.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
function compile(source) {
const strings=[];
source=source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token=>{
if(token.startsWith('/')) return '';
strings.push(token); return `__str_${strings.length-1}__`;
});
const compiled = [];
const headers=/^(\w+)\(([^\n]*)\)\s*\n\{/gm;
for(let match; (match=headers.exec(source));) {
let end=headers.lastIndex, depth=1;
while(depth && end<source.length) {
const ch=source[end++]; if(ch==='{')depth++; if(ch==='}')depth--;
}
assert.equal(depth,0);
let body=source.slice(headers.lastIndex,end-1);
const locals=[...new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(m=>m[1]))];
body=body.replace(/#.?(?=__str_)/g,'');
body=body.replace(/\.size\b/g,'.length').replace(/typeof\(/g,'gscType(');
body=body.replace(/self endon\([^;]+\);|level endon\([^;]+\);/g,'');
body=body.replace(/(?:level|self|player) thread [^;]+;/g,';');
body=body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::\w+\([^;]+;/g,'');
body=body.replace(/(?:player|self|players\[i\]) maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g,m=>m.split(' ')[0]+'.downed');
body=body.replace(/self maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::is_reviving\(players\[i\]\)/g,'stockReviving(self,players[i])');
body=body.replace(/maps\\mp\\zombies\\_zm_utility::is_headshot/g,'stockHeadshot');
body=body.replace(/player getGuid\(\)/g,'player.guid');
body=body.replace(/(self|player|players\[i\]) (afk_\w+)\(/g,'$1.$2(');
body=body.replace(/(self|state\.player|player|timer|warning|self\.hud_afk_elem) (\w+)\(/g,'$1.$2(');
body=body.replace(/level notify\(/g,'level.notify(');
body=body.replace(/::(afk_\w+)/g,'$1');
body=body.replace(/http::request/g,'httpPost');
body=body.replace(/request waittill\([^;]+\);/g,'var [response,success,error,diagnostics]=httpComplete(request);');
body=body.replace(/self\.waittill\((__str_\d+__),\s*(\w+)\);/g,'var $2 = yield $1;');
body=body.replace(/(?:self\.|level )waittill(?:_any)?\(([^;]+)\);/g,'yield [$1];');
body=body.replace(/\bwait (\d+(?:\.\d+)?);/g,'yield $1;');
body=body.replace(/\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)/g,'[$1,$2,$3]');
body=body.replace(/__str_(\d+)__/g,(_,n)=>strings[Number(n)]);
if(match[1]==='afk_register_dogs')continue;
compiled.push(`function${body.includes('yield ') ? '*' : ''} ${match[1]}(${match[2]}) { const self=this; var ${locals.join(',')||'unused'}; ${body} }`);
}
return compiled.join('\n');
}
function engine(game, options={}) {
const source=readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`,root),'utf8');
let now=100000, wall=100000, serial=0;
const log=[], sent=[], writes=[], stops=[], hud=[], saved=new Map(), live=new Map();
const level={afk_url:'https://ec.thewrightserver.net/api/afk',afk_server:'server',afk_token:'ec_bank_'+'x'.repeat(43),afk_ready:true,afk_ended:false,afk_players:[],afk_requests:[],afk_stops:[],afk_grace_seconds:2,afk_cooldown_seconds:15,afk_match:'match',round_number:1,zombie_team:'axis',afk_warning_sound:'zmb_perks_packa_ready',notify:name=>log.push(['notify',name])};
const players=[],zombies=[];
const c=vm.createContext({level,
isDefined:v=>v!==undefined&&v!==null, is_true:v=>!!v, isAlive:p=>!!p?.alive,
isGodMode:p=>!!p.god, gscType:v=>typeof v==='number' ? Number.isInteger(v)?'int':'float' : typeof v,
getTime:()=>now, getPlayers:()=>players, getArrayKeys:Object.keys, GetAiSpeciesArray:()=>zombies,
min:Math.min,max:Math.max,int:Math.trunc, getSubStr:(s,a,b)=>s.substring(a,b),
toLower:s=>s.toLowerCase(),strTok:(s,d)=>s.split(d),spawnStruct:()=>({}),
distanceSquared:(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),
jsonSerialize:data=>JSON.stringify(Object.fromEntries(Object.entries(data))),
telemetryNewMatch:()=>`id-${++serial}`,
stockReviving:(player,target)=>!!(player.buttons&1)&&target.downed,
stockHeadshot:(_w,loc,mod)=>['head','helmet'].includes(loc)&&!['MOD_MELEE','MOD_BAYONET','MOD_IMPACT','MOD_UNKNOWN'].includes(mod),
NewClientHudElem:()=>{const e={SetText(v){this.text=v;},SetTextUnlimited(v){this.text=v;}};hud.push(e);return e;},
afkVersion:()=> '1.0.0',GetDvar:()=>'',GetDvarInt:()=>0,println:m=>log.push(['print',m]),
afkStore:(_s,_u,body)=>{const d=JSON.parse(body);writes.push(d);if(options.storeFail)return false;saved.set(d.request_id,{id:d.request_id,action:d.action,body});return true;},
afkLoad:()=>[...saved.values()],afkRecover:()=>true,
afkRemove:(_s,_u,id)=>saved.delete(id),
afkPrepare:(_s,_u,body)=>{const d=JSON.parse(body);assert(d.seconds>0&&d.seconds<=604800);if(options.prepareFail)return false;live.set(d.session_id,{data:d});log.push(['prepare',d.session_id]);return true;},
afkAuthorize:(_s,_u,id,seconds)=>{assert(live.has(id));live.get(id).seconds=seconds;log.push(['authorize',id]);return true;},
afkBegin:(_s,_u,id)=>{if(options.beginFail)return false;assert(live.has(id));live.get(id).start=wall;log.push(['begin',id]);return true;},
afkRemaining:(_s,_u,id)=>{const r=live.get(id);return r?.start===undefined?-1:Math.max(0,r.seconds*1000-(wall-r.start));},
afkCheckpoint:(_s,_u,id)=>options.checkpointFail?-1:Math.floor((wall-live.get(id).start)/1000),
afkStop:(_s,_u,id,reason)=>{stops.push({id,reason});log.push(['stop',reason]);return true;},
httpPost:(url,req)=>{const d=JSON.parse(req.body);sent.push({action:url.split('/').at(-1),data:d,req});return {data:d,action:d.action};},
httpComplete:request=>{options.during?.(request,c);const d=request.data;
const response={valid:true,ok:true,state:({balance:'balance',reserve:'reserved',start:'started',event:'rewarded',purchase:'purchased',settle:'settled'})[request.action],code:'ok',request_id:d.request_id??'',session_id:d.session_id,free_seconds:1800,saved_seconds:0,reserved_seconds:0,available_seconds:1800,capacity_seconds:21600,authorized_seconds:options.authorized??1800,purchase_points:10000,purchase_seconds:600,max_purchase_packs:36,bank_balance:100000,...(options.reply?.(request)??{})};
return [JSON.stringify(response),!options.outage,'', {http_status:options.status??200}];},
afkResponse:JSON.parse,
});
try{vm.runInContext(compile(source),c);}catch(error){throw new Error(`${game} compile: ${error.message}`,{cause:error});}
function player(guid='18446744073709551615') {
const p={guid,sessionstate:'playing',alive:true,origin:[0,0,0],angles:[0,0,0],buttons:0,ignoreme:false};
for(const key of Object.keys(c)) if(/^afk_/.test(key)&&typeof c[key]==='function')p[key]=c[key].bind(p);
for(const name of ['freezeControls','disableWeapons','disableOffhandWeapons','enableWeapons','enableOffhandWeapons','iPrintLnBold','playLocalSound'])p[name]=(...args)=>log.push([name,...args]);
p.getPlayerAngles=()=>p.angles;
p.useButtonPressed=()=>!!(p.buttons&1);p.attackButtonPressed=()=>!!(p.buttons&2);p.adsButtonPressed=()=>!!(p.buttons&4);p.meleeButtonPressed=()=>!!(p.buttons&8);
players.push(p);p.afk_spawn();return p;
}
const p=player(), other=player('other');
return {c,p,other,player,players,level,log,sent,writes,stops,saved,live,hud,zombies,source,
advance(ms,gameMs=ms){wall+=ms;now+=gameMs;},enter(){c.afk_enter(p,p.tws_afk_state);},
command(args){c.afk_account_command(p,p.tws_afk_state,args);},tick(){p.afk_tick().next();},
};
}
let checks=0;
for(const game of ['t5','t6']) {
const test=(label,fn)=>{try{fn();checks++;}catch(cause){throw new Error(`${game}: ${label}`,{cause});}};
test('no balance side effects; quote confirms exact price and idempotent journal before sending',()=>{
const e=engine(game);e.command(['.afk','balance']);assert.equal(e.writes.length,0);
e.command(['.afk','buy','10']);assert.equal(e.writes.length,0);
assert(e.log.some(row=>row[0]==='iPrintLnBold'&&row[1].includes('10000 bank points')));
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1);
assert.equal(e.writes[0].action,'purchase');assert.equal(e.writes[0].confirmed,true);
assert.equal(e.writes[0].expected_purchase_points,10000);assert.equal(e.writes[0].expected_purchase_seconds,600);
assert.equal(e.sent.length,2,'purchase must wait for durable sender');
e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,1,'repeat confirmation cannot buy twice');
});
test('buy/confirmation during protected AFK cannot spend points or misrepresent the authorized timer',()=>{
const e=engine(game);e.command(['.afk','buy','10']);e.enter();e.command(['.afk','buy','10','confirm']);assert.equal(e.p.tws_afk_state.quote,undefined);assert(!e.writes.some(x=>x.action==='purchase'));assert.equal(e.p.hud_afk_elem.text,'AFK 30:00');assert(e.log.some(x=>x[0]==='iPrintLnBold'&&x[1].includes('Use .back before buying')));
});
test('capacity and stale confirmations never debit bank or write purchase intent',()=>{
const e=engine(game);e.command(['.afk','buy','370']);assert.equal(e.p.tws_afk_state.quote,undefined);
e.command(['.afk','buy','10']);e.advance(30001);e.command(['.afk','buy','10','confirm']);assert.equal(e.writes.length,0);
});
test('reserve/start durable order; time and protection cannot reset on repeated entry',()=>{
const e=engine(game);e.enter();assert.equal(e.p.hud_afk_on,true);
const first=e.p.tws_afk_state.session;assert.deepEqual(e.log.filter(x=>['prepare','authorize','begin'].includes(x[0])).map(x=>x[0]),['prepare','authorize','begin']);
e.advance(1000);e.enter();assert.equal(e.p.tws_afk_state.session,first);assert.equal(e.sent.length,2);
assert.equal(e.p.afk_remaining(),1799000);
});
test('central countdown stays visible with hidden HUD and expires at actual wall deadline with no grace',()=>{
const e=engine(game,{authorized:3});e.p.tws_hud_hidden=true;e.enter();
assert.equal(e.p.hud_afk_elem.alpha,1);assert.equal(e.p.hud_afk_elem.text,'AFK 0:03');
e.advance(2999,0);assert.equal(e.p.afk_damage(null,null,10),0);
e.advance(1,0);assert.equal(e.p.afk_damage(null,null,10),-1,'damage rejected only while native time >0');
e.tick();assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.ignoreme,false);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.tws_afk_grace_until,0);
assert.equal(e.stops.at(-1).reason,'expired');assert(e.log.some(x=>x[0]==='enableWeapons'));
assert(e.log.some(x=>x[0]==='enableOffhandWeapons'));assert(e.log.some(x=>x[0]==='playLocalSound'));
assert.equal(e.p.sessionstate,'playing');
});
test('manual return stops consumption immediately and preserves bounded manual grace independently',()=>{
const e=engine(game);e.enter();e.advance(1500);e.p.afk_exit();
assert.equal(e.stops.at(-1).reason,'manual');assert.equal(e.p.hud_afk_on,false);assert.equal(e.p.tws_afk_protected,true);
assert.equal(e.p.tws_afk_controls_locked,false);assert.equal(e.p.afk_damage(null,null,1),0);
e.enter();assert.equal(e.sent.length,2);e.advance(2000);e.tick();assert.equal(e.p.tws_afk_protected,false);
assert.equal(e.p.ignoreme,false);assert.equal(e.p.afk_damage(null,null,1),-1);
});
test('accepted manual return rewards legitimate revives during its separate protection grace',()=>{
const e=engine(game),revives=e.other.afk_revive_watcher();revives.next();e.enter();
revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,0,'protected AFK cannot earn');
e.p.afk_exit();assert.equal(e.p.tws_afk_protected,true);assert.equal(e.p.hud_afk_on,false);
e.advance(1500);revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
assert.equal(e.writes.at(-1).revives,1,'successful post-return revive is durably reported');
e.p.sessionstate='spectator';revives.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,1);
});
test('manual command at zero cannot create a grace period',()=>{
const e=engine(game,{authorized:1});e.enter();e.advance(1000);e.p.afk_exit();assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.tws_afk_grace_until,0);assert.equal(e.stops.at(-1).reason,'expired');
});
test('outage keeps existing authorization; unavailable entry never enables protection',()=>{
const e=engine(game,{outage:true});e.enter();assert.equal(e.p.hud_afk_on,false);assert.equal(e.stops.at(-1).reason,'never_started');
const active=engine(game,{authorized:10});active.enter();active.c.httpPost=()=>undefined;active.advance(5000);active.tick();assert(active.p.hud_afk_on);
active.advance(5000);active.tick();assert(!active.p.hud_afk_on);
});
test('T5/T6 downed, dead, spectator, afterlife, godmode and stock control ownership reject entry',()=>{
for(const flags of [{downed:true},{alive:false},{sessionstate:'spectator'},{afterlife:true},{god:true},{ignoreme:true},{disabledweapon:1},{hostmigrationcontrolsfrozen:true}]){
const e=engine(game);Object.assign(e.p,flags);e.enter();assert.equal(e.sent.length,0);assert(!e.p.hud_afk_on);
}
});
test('expiry keeps stock protection/control ownership and restores AFK controls once gameplay resumes',()=>{
const e=engine(game,{authorized:1});e.enter();e.p.downed=true;e.p.god=true;e.advance(1000);e.tick();
assert.equal(e.p.tws_afk_protected,false);assert.equal(e.p.god,true);assert.equal(e.p.ignoreme,true);
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('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();
for(let i=0;i<40;i++)watcher.next(e.p);
assert.equal(e.p.tws_afk_state.progress.revives,40);assert.equal(e.writes.at(-1).revives,40);
watcher.next(e.other);assert.equal(e.other.tws_afk_state.progress.revives,0);
e.p.hud_afk_on=true;watcher.next(e.p);e.p.hud_afk_on=false;e.p.sessionstate='spectator';watcher.next(e.p);assert.equal(e.p.tws_afk_state.progress.revives,40);
});
test('actual kill notifications stack headshot credit once per zombie and ignore AFK kills',()=>{
const e=engine(game),watcher=e.p.afk_kill_watcher();watcher.next();
const zombie={attacker:e.p,damagelocation:'head',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};
watcher.next(zombie);watcher.next(zombie);assert.equal(e.p.tws_afk_state.progress.kills,1);assert.equal(e.p.tws_afk_state.progress.headshots,1);
e.p.hud_afk_on=true;watcher.next({...zombie,tws_afk_rewarded:false});assert.equal(e.p.tws_afk_state.progress.kills,1);
assert.equal(e.writes.length,0,'kills batch into bounded periodic cumulative snapshots');
assert(!e.source.includes('.score'));
});
if(game==='t5')test('T5 hellhound death hook awards real credited kills without score polling',()=>{
const e=engine(game),dog={attacker:e.p,damagelocation:'torso',damagemod:'MOD_RIFLE_BULLET',damageweapon:'rifle'};const watcher=e.c.afk_dog_death.call(dog);watcher.next();watcher.next();assert.equal(e.p.tws_afk_state.progress.kills,1);
});
test('passive connection earns nothing, view/input and legitimate support/holding earn time',()=>{
const e=engine(game),watcher=e.p.afk_activity_watcher();watcher.next();
for(let i=0;i<31;i++){e.advance(1000);watcher.next();}assert.equal(e.p.tws_afk_state.progress.active_ms,0);
e.p.angles=[0,10,0];e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,1000);
e.other.downed=true;e.other.revivetrigger={};e.p.buttons=1;e.advance(16000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,2000);
e.other.downed=false;e.p.buttons=0;e.zombies.push({alive:true,enemy:e.p,origin:[100,0,0]});e.advance(1000);watcher.next();
e.advance(20000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
e.p.hud_afk_on=true;e.advance(1000);watcher.next();assert.equal(e.p.tws_afk_state.progress.active_ms,4000);
});
test('rounds require contribution; old disconnected progress resets at round boundary',()=>{
const e=engine(game);e.p.tws_afk_state.progress.round_contribution=true;
const watcher=e.c.afk_round_watcher();watcher.next();watcher.next();
assert.equal(e.p.tws_afk_state.progress.rounds,1);assert.equal(e.other.tws_afk_state.progress.rounds,0);
watcher.next();assert.equal(e.p.tws_afk_state.progress.rounds,1);
});
test('disconnect settles and reconnect gets independent async state with cumulative progress',()=>{
const e=engine(game);e.p.tws_afk_state.progress.revives=4;e.enter();const old=e.p.tws_afk_state;
const watcher=e.p.afk_disconnect_watcher(old);watcher.next();watcher.next();assert.equal(e.stops.at(-1).reason,'disconnect');
e.players.splice(0,1);const next=e.player(e.p.guid);assert.notEqual(next.tws_afk_state,old);assert.equal(old.connected,false);assert.equal(next.tws_afk_state.progress.revives,4);
});
test('disconnect or manual cancellation during reserve/start cannot activate; malformed session ack fails',()=>{
for(const at of ['reserve','start']){
const e=engine(game,{during:(request)=>{if(request.action===at)e.p.tws_afk_state.cancelled=true;}});e.enter();assert(!e.p.hud_afk_on);assert.equal(e.stops.at(-1).reason,'never_started');
}
const e=engine(game,{reply:r=>r.action==='start'?{session_id:'wrong'}:{}});e.enter();assert(!e.p.hud_afk_on);
});
test('sender never replays reserve/start and retains transient missing telemetry membership',()=>{
const e=engine(game,{status:409,reply:()=>({code:'not_in_match',state:'rejected'})});
for(const action of ['reserve','start','event']){const d={action,request_id:action,server:'server',guid:e.p.guid,match:'match'};e.saved.set(action,{id:action,action,body:JSON.stringify(d)});}
e.c.afk_sender().next();assert.equal(e.sent.length,1);assert.equal(e.sent[0].action,'event');assert.equal(e.saved.size,3);
});
}
console.log(`${checks} AFK script behavior checks passed (transpiled actual functions; live T5/T6 validation still required).`);