Add persistent AFK reserves for both engines (Refs #12)

This commit is contained in:
2026-10-17 16:56:06 -04:00
parent 7b95f604b4
commit 6781a4ed5a
22 changed files with 3840 additions and 248 deletions
+226
View File
@@ -0,0 +1,226 @@
// 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).`);
+6 -43
View File
@@ -1,4 +1,5 @@
// Execute selected core HUD/AFK functions with engine-value shims.
// Execute selected core HUD functions with engine-value shims.
// Persistent AFK ownership/countdown behavior is covered by check_afk.mjs.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
@@ -99,8 +100,6 @@ function engine(game, source, afk, hidden = false) {
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'hud_zombie_counter', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
@@ -126,9 +125,8 @@ for (const game of ['t5', 't6']) {
const bind = body(source, 'match_timer_bind')[1];
if (game === 't5') assert(bind.includes('elem SetTimerUp(0)'));
else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0'));
assert(afk.includes('make_core_overlay_text'));
assert(afk.includes('level.hud_overlay_afk_y'));
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
assert(afk.includes('NewClientHudElem(self)'), 'protection countdown is an independent critical HUD');
assert(!afk.includes('set_core_alpha'), 'hiding the optional HUD must not hide the protection deadline');
});
check(`${game}: connection places 10 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
@@ -266,41 +264,6 @@ for (const game of ['t5', 't6']) {
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
});
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
const e = make(); e.context.hud_connect(); e.spawnAfk();
const indicator = e.self.hud_afk_elem;
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
e.context.hud_toggle(); e.context.afk_enter();
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
const actions = e.actions.length;
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
e.context.hud_toggle(); const exit = e.context.afk_exit();
assert.equal(exit.next().value, 2);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
exit.next(); assert.equal(e.self.ignoreme, false);
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
assert.equal(e.elems.length, 11);
});
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
watcher.next();
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true);
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
e.self.active = true; watcher.next();
assert.equal(e.self.tws_afk_controls_locked, false);
assert(e.actions.some(([name]) => name === 'enableWeapons'));
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
});
}
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
+10 -10
View File
@@ -24,7 +24,7 @@ function translate(source) {
const result = [];
for (let match; (match = headers.exec(protectedSource));) {
const name = match[1];
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', 'destroy_on_disconnect'].includes(name)) continue;
if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_hud_create', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
@@ -129,7 +129,7 @@ function engine(game, count = 8, withHud = false) {
assert.equal(game, 't5', 'combined fixture currently covers T5 construction');
for (const module of ['hud', 'afk'])
vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c);
c.hud_connect(); c.afk_spawn();
c.hud_connect(); c.afk_hud_create();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
@@ -523,8 +523,8 @@ check('T5 service transitions preserve actual core element identities, styles, d
const e = engine('t5', 8, true);
e.createCore();
const core = Array.from(e.self.tws_core_hud_elems);
assert.equal(core.length, 11, 'ten core elements plus one optional AFK indicator');
assert.equal(e.live.length, 11);
assert.equal(core.length, 10, 'optional core elements exclude the critical AFK countdown');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetTextUnlimited('6 ZOMBIES');
e.self.tws_hud_health.SetValue(100);
e.self.tws_hud_maxhealth.SetValue(100);
@@ -537,17 +537,17 @@ check('T5 service transitions preserve actual core element identities, styles, d
e.advance(3000);
e.record(snapshot(['Jeff', 'Lokester', 'Moniialx']));
const controller = e.state, tasks = e.tasks.length;
assert.equal(e.live.length, 15);
assert.equal(e.live.length, 16);
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.state, controller);
assert.equal(e.state.closed, false, 'idle releases the view, not its controller');
assert.equal(e.state.elems.length, 0);
assert.equal(e.live.length, 11);
assert.equal(e.live.length, 12);
assert.deepEqual(state(), initial);
for (let i = 0; i < 4; i++) {
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
assert.equal(e.live.length, 30, '11 core/AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
e.c.toggle_core_hud();
assert(core.every(elem => elem.alpha === 0));
@@ -557,17 +557,17 @@ check('T5 service transitions preserve actual core element identities, styles, d
e.advance(12000);
assert.equal(e.state.rows[0].text, 'Latest');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.live.length, 15);
assert.equal(e.live.length, 16);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.live.length, 11);
assert.equal(e.live.length, 12);
assert.equal(e.state.elems.length, 0);
assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers');
assert.deepEqual(state(), initial);
}
assert.equal(e.peakLive, 30);
assert.equal(e.peakLive, 31);
assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core);
e.event('disconnect');
assert.equal(e.live.length, 0);
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Test native afk storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-afk-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/afk.cpp"), str(ROOT / "tools/tests/afk_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
template <typename F> void add(const char* name, F callback)
{
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
{
if constexpr (std::is_invocable_v<F>) return callback();
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string>) return callback(args.at(0), args.at(1), args.at(2));
else if constexpr (std::is_invocable_v<F, std::string, std::string, std::string, int>) return callback(args.at(0), args.at(1), args.at(2), std::stoi(args.at(3)));
else return callback(args.at(0), args.at(1), args.at(2), args.at(3));
};
}
""")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/afk.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "afk.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/afk_component_cases.cpp').read_text())
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
str(plugin / "src/utils/afk.cpp"), "-o", str(directory / "component")], check=True)
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
lines = result.stdout.splitlines()
for operation in ("load", "write", "remove"):
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+158
View File
@@ -0,0 +1,158 @@
#include "afk.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
using utils::afk::journal;
using utils::afk::parse_acknowledgement;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
const std::string server = "test-server", endpoint = "https://example.test/api/afk";
std::string reserve(const std::string& id)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id+"-reserve"},{"action","reserve"},{"session_id",id},{"seconds",600}}.dump();
}
std::string request(const std::string& id, const std::string& action)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},{"match","match"},
{"request_id",id},{"action",action},{"session_id","pending"},{"confirmed",true},{"packs",1},
{"seq",1},{"active_seconds",0},{"kills",0},{"headshots",0},{"rounds",0},{"revives",0},{"activity","playing"}}.dump();
}
template <typename F> void must_fail(F fn)
{
bool failed = false;
try { fn(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const journal queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.prepare(reserve("pending"));
queue.store(request("pending-start","start"));
queue.prepare(reserve("authorized")); queue.authorize("authorized",120);
queue.prepare(reserve("active")); queue.authorize("active",120);
const auto now = journal::clock::now();
queue.begin("active",now); assert(queue.checkpoint("active",now+31s) == 31);
queue.store(request("purchase","purchase")); queue.store(request("event","event"));
}
else if (mode == "recover")
{
// Separate process: the same match ID cannot recreate a live clock.
assert(queue.remaining("active") == -1);
must_fail([&]{queue.begin("active");});
queue.recover("match");
const auto values = queue.load();
assert(values.size() == 5);
int recovered = 0;
for (const auto& value : values)
{
if (value.action != "settle") continue;
auto body = nlohmann::json::parse(value.body);
assert(value.id.size() == 25);
if (body.at("session_id") == "active")
{ assert(body.at("consumed_seconds").is_null()); assert(body.at("end_reason") == "crash"); assert(body.at("confirmed_seconds") == 31); }
else { assert(body.at("consumed_seconds") == 0); assert(body.at("end_reason") == "never_started"); }
++recovered;
}
assert(recovered == 3);
queue.recover("new-match"); // Stable requests are unchanged across restarts.
assert(queue.load().size() == values.size());
for (size_t i=0; i<values.size(); ++i) assert(queue.load()[i].body == values[i].body);
for (const auto& value : values) queue.erase(value.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
queue.store(request("one","purchase")); queue.store(request("one","purchase"));
assert(queue.load().size() == 1);
must_fail([&]{queue.store(request("one","event"));});
auto changed = nlohmann::json::parse(request("one","purchase")); changed["packs"] = 2;
must_fail([&]{queue.store(changed.dump());});
const journal other_server(root,"another-server",endpoint), other_endpoint(root,server,endpoint+"/another");
assert(other_server.load().empty() && other_endpoint.load().empty());
queue.erase("one");
queue.store(request("first-event","event"));
auto snapshot = nlohmann::json::parse(request("second-event","event"));
snapshot["seq"] = 2; snapshot["revives"] = 2;
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].id == "second-event");
queue.erase("first-event"); // An in-flight older ACK cannot delete the new snapshot.
assert(queue.load().size() == 1);
auto regressed = snapshot; regressed["request_id"]="third-event"; regressed["seq"]=3; regressed["revives"]=1;
must_fail([&]{queue.store(regressed.dump());});
queue.store(snapshot.dump());
assert(queue.load().size() == 1 && queue.load()[0].body == snapshot.dump());
queue.erase("second-event");
queue.prepare(reserve("normal")); queue.prepare(reserve("normal"));
assert(queue.load().size() == 1);
must_fail([&]{queue.begin("normal");});
must_fail([&]{queue.authorize("normal",601);});
queue.authorize("normal",60); queue.authorize("normal",60);
must_fail([&]{queue.authorize("normal",120);});
const auto now = journal::clock::now();
queue.begin("normal",now); queue.begin("normal",now+20s);
assert(queue.remaining("normal",now+1250ms) == 58750);
assert(queue.remaining("normal",now+59999ms) == 1);
assert(queue.remaining("normal",now+60s) == 0);
assert(queue.remaining("normal",now+61s) == 0);
assert(queue.checkpoint("normal",now+1250ms) == 1); // Does not expire a second early.
assert(queue.checkpoint("normal",now+59001ms) == 59);
must_fail([&]{queue.recover("match");}); // Cannot refund still-consumable authority.
queue.stop("normal","manual",now+59500ms); queue.stop("normal","manual",now+120s);
assert(queue.load().size() == 1 && queue.load()[0].action == "settle");
auto settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 60 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
queue.prepare(reserve("expire")); queue.authorize("expire",60); queue.begin("expire",now);
assert(queue.checkpoint("expire",now+60s) == 60);
queue.stop("expire","expired",now+61s);
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 60);
queue.erase(queue.load()[0].id);
queue.prepare(reserve("cancel")); queue.stop("cancel","never_started");
assert(nlohmann::json::parse(queue.load()[0].body).at("consumed_seconds") == 0);
queue.erase(queue.load()[0].id);
// Interrupted temp writes preserve the prior durable snapshot.
const auto file = fs::directory_iterator(root)->path();
queue.prepare(reserve("failure")); queue.authorize("failure",60); queue.begin("failure",now);
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.stop("failure","manual",now+1500ms);});
assert(queue.remaining("failure",now+30s) == 0);
must_fail([&]{queue.checkpoint("failure",now+30s);});
must_fail([&]{queue.begin("failure",now+30s);});
fs::remove(file.string()+".tmp");
queue.stop("failure","disconnect",now+30s); // A write retry never extends billing.
settlement = nlohmann::json::parse(queue.load()[0].body);
assert(settlement.at("consumed_seconds") == 2 && settlement.at("end_reason") == "manual");
queue.erase(queue.load()[0].id);
auto valid = nlohmann::json{{"ok",true},{"state","reserved"},{"request_id","one"},{"code","ok"},
{"session_id","session"},{"free_seconds",1200},{"saved_seconds",0},{"available_seconds",1200},
{"reserved_seconds",600},{"capacity_seconds",21600},{"authorized_seconds",600},
{"bank_balance",10000},{"purchase_points",10000},{"purchase_seconds",600},{"max_purchase_packs",36}};
assert(parse_acknowledgement(valid.dump()).valid);
for (const auto& field : {"free_seconds","saved_seconds","available_seconds","reserved_seconds","capacity_seconds",
"authorized_seconds","bank_balance","purchase_points","purchase_seconds","max_purchase_packs"})
for (const auto& invalid : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("10"),nlohmann::json(1000000001)})
{ auto value=valid; value[field]=invalid; assert(!parse_acknowledgement(value.dump()).valid); }
for (const auto& text : {"", "<html>private-token</html>", "{", "null", "{}"})
{ const auto bad=parse_acknowledgement(text); assert(!bad.valid && bad.code.empty() && bad.id.empty()); }
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
auto invalid = valid; invalid["ok"] = false; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["session_id"]=""; assert(!parse_acknowledgement(invalid.dump()).valid);
invalid=valid; invalid["authorized_seconds"]=604801; assert(!parse_acknowledgement(invalid.dump()).valid);
valid["state"]="unresolved"; valid["consumed_seconds"]=nullptr; assert(parse_acknowledgement(valid.dump()).valid);
valid["state"]="corrected"; valid["consumed_seconds"]=5; assert(parse_acknowledgement(valid.dump()).valid);
{ std::ofstream stream(file); stream << "private-corrupt-journal"; }
must_fail([&]{queue.load();}); must_fail([&]{queue.store(request("blocked","event"));});
must_fail([&]{queue.recover("new-match");}); must_fail([&]{queue.erase("absent");});
fs::remove(file);
std::cout << "AFK immutable requests, authorization, elapsed clocks, stop retries and guarded recovery passed\n";
}
+35
View File
@@ -0,0 +1,35 @@
// Compile the actual GSC registration adapter against portable VM interfaces.
#include <fstream>
int main()
{
afk::component component; component.on_startup(nullptr);
const auto call = [](const std::string& name, std::vector<std::string> args = {})
{ return gsc::function::functions.at(name)(args); };
assert(call("afkversion").as<std::string>() == "1.0.0");
auto ack=call("afkresponse",{"private-secret-invalid-json"}).as<scripting::array>();
assert(!ack["valid"].as<bool>() && ack["reason"].as<std::string>() == "invalid_json");
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
for (int i=0;i<2;++i) assert(!call("afkstore",{"server","endpoint","private-secret Authorization"}).as<bool>());
const std::string body=R"({"server":"server","guid":"1234","match":"match","request_id":"one","action":"reserve","session_id":"session","seconds":600})";
assert(call("afkstore",{"server","endpoint",body}).as<bool>());
assert(call("afkprepare",{"server","endpoint",body}).as<bool>());
assert(call("afkauthorize",{"server","endpoint","session","600"}).as<bool>());
assert(call("afkbegin",{"server","endpoint","session"}).as<bool>());
assert(call("afkremaining",{"server","endpoint","session"}).as<int>() > 0);
assert(call("afkcheckpoint",{"server","endpoint","session"}).as<int>() >= 0);
assert(call("afkstop",{"server","endpoint","session","manual"}).as<bool>());
assert(call("afkrecover",{"server","endpoint","new-match"}).as<bool>());
std::filesystem::path file;
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-afk"))
if (entry.path().extension() == ".json") file=entry.path();
assert(!file.empty());
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
for (int i=0;i<2;++i)
{
assert(call("afkload",{"server","endpoint"}).get_raw().type == game::SCRIPT_NONE);
assert(!call("afkremove",{"server","endpoint","one"}).as<bool>());
}
std::filesystem::remove(file);
assert(call("afkload",{"server","endpoint"}).is<scripting::array>());
assert(call("afkremove",{"server","endpoint","one"}).as<bool>());
}