Add persistent AFK reserves for both engines (Refs #12)
This commit is contained in:
@@ -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).`);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
Reference in New Issue
Block a user