// Actual GSC transfer logic under portable VM/HTTP/disk shims; no game runtime. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const root = new URL('../', import.meta.url); const publicEndpoint = 'https://ec.thewrightserver.net/api/bank'; const privateToken = 'ec_bank_' + 'a'.repeat(43); function functions(source) { const literals = []; const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { if (token.startsWith('/')) return ''; literals.push(token); return `__literal_${literals.length - 1}__`; }); const found = new Map(); const headers = /^(init|bank_\w+)\(([^\n]*)\)\s*\n\{/gm; for (let match; (match = headers.exec(protectedSource));) { let end = headers.lastIndex, depth = 1; while (depth && end < protectedSource.length) { const ch = protectedSource[end++]; if (ch === '{') depth++; if (ch === '}') depth--; } assert.equal(depth, 0); let body = protectedSource.slice(headers.lastIndex, end - 1); const locals = new Set([...body.matchAll(/(? x[1])); body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost'); body = body.replace(/player getGuid\(\)/g, 'getGuid(player)'); body = body.replace(/#(?=__literal_)/g, ''); body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::register_help_command/g, 'registerHelpCommand'); body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify('); body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];'); body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;'); body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;'); body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);'); body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]); body = body.replace(/player maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g, 'player.downed').replace(/wait ([^;]+);/g, 'yield $1;'); const generator = match[1] === 'bank_sender'; found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); } return found; } function engine(game, options = {}) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_bank.gsc`, root), 'utf8'); const excluded = new Set(['bank_chat_listener','bank_end_watcher','bank_guard','bank_say','bank_request']); const functionsByName = functions(source); const player = { guid: '18446744073709551615', sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true }; const entry = { player, active: true }; const recovered = options.recovered ?? []; const saved = recovered.map(report => JSON.parse(report.body)), sent = [], messages = [], order = [], loaded = [], dvarsRead = []; const dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars }; function assertScope(server, url) { assert.equal(server, dvars.tws_server_id); assert.equal(url, publicEndpoint); } const c = vm.createContext({ level: { telemetry_match: 'match', telemetry_started: true }, GetDvar: name => { dvarsRead.push(name); return dvars[name] ?? ''; }, getSubStr: (value, start, end) => value.substring(start, end), registerHelpCommand: () => {}, isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive, int: Math.trunc, getGuid: p => p.guid, jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))), bank_say: (_entry, message) => messages.push(message), telemetryNewMatch: () => 'request-1', bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return recovered; }, bankStore: (server, url, body) => { assertScope(server, url); const data = JSON.parse(body); order.push(data.decision); if (options.storageFailure === data.decision) return false; saved.splice(0, saved.length, data); return true; }, bankRemove: (server, url) => { assertScope(server, url); if (options.removeFailure) return false; saved.length = 0; return true; }, httpPost: (url, data) => { order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {}; }, httpComplete: () => { options.during?.(c, entry); const action = sent.at(-1).url.split('/').at(-1); const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared'; const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : JSON.parse(sent.at(-1).body).request_id, amount: options.amount ?? 2000, balance: options.balance ?? 5000, code: options.code ?? 'ok' }; return [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }]; }, bankResponse: data => options.malformed ? { valid: false } : JSON.parse(data), print: () => {}, println: () => {} }); vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c); c.init(); return { c, player, entry, saved, sent, messages, order, loaded, dvarsRead, run: args => c.bank_transfer(entry,args) }; } let checks = 0; for (const game of ['t5','t6']) { function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } } test('startup and every request ignore obsolete endpoint overrides and retain instance authentication', () => { for (const url of ['', 'https://controller.internal/api/bank', 'http://old-controller/api/bank', 'https://untrusted.example/api/bank']) { for (const command of [['.b'], ['.d','2000'], ['.w','all']]) { const e = engine(game, { dvars: { tws_bank_url: url } }); e.run(command); if (command[0] !== '.b') e.c.bank_sender().next(); assert.equal(e.loaded.length, 1); assert(!e.dvarsRead.includes('tws_bank_url')); assert.equal(e.sent[0].url, publicEndpoint + '/' + ({'.b':'balance','.d':'deposit','.w':'withdraw'}[command[0]])); if (command[0] !== '.b') assert.equal(e.sent[1].url, publicEndpoint + '/commit'); for (const request of e.sent) { assert.equal(request.headers.Authorization, 'Bearer ' + privateToken); assert.equal(JSON.parse(request.body).server, 'server'); } } } }); test('missing or invalid instance credentials and disabled banks do not load journals', () => { for (const dvars of [{tws_server_id:''}, {tws_bank_token:''}, {tws_bank_token:'wrong_' + 'a'.repeat(43)}, {tws_bank_enabled:'0'}]) { assert.equal(engine(game, {dvars}).loaded.length, 0); } }); test('strict positive integer syntax', () => { const {c} = engine(game); for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0); assert.equal(c.bank_amount('2000'),2000); assert.equal(c.bank_amount('ALL'),0); assert.equal(c.bank_amount('all'),-1); }); test('deposit journals cancellation then commit before moving points', () => { const e=engine(game); e.run(['.d','2000']); assert.equal(e.player.score,1000); assert.equal(e.player.score_total,4000); assert.equal(e.player.pers.score,1000); assert.deepEqual(e.order,['cancel','http','commit']); assert.equal(e.saved[0].decision,'commit'); assert.equal(e.saved[0].guid,e.player.guid); assert.equal(e.sent[0].headers.Authorization,'Bearer ' + privateToken); assert.equal(e.sent[0].timeout_ms,15000); }); test('all captures deposit points and resolves withdrawal amount', () => { const e=engine(game,{amount:3000}); e.run(['.d','all']); assert.equal(e.player.score,0); assert.equal(JSON.parse(e.sent[0].body).amount,3000); const w=engine(game); w.run(['.w','all']); assert.equal(w.player.score,5000); assert.equal(JSON.parse(w.sent[0].body).amount,'all'); }); test('failure responses never change points and retain cancellation', () => { for (const options of [{timeout:true},{noHandle:true},{status:503},{status:401},{status:429},{missingDiagnostics:true},{malformed:true},{wrongId:true},{state:'rejected',code:'insufficient_balance'}]) { const e=engine(game,options); e.run(['.w','2000']); assert.equal(e.player.score,3000); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.c.level.bank_queue[0].decision,'cancel'); } }); test('storage failures never send unsaved work or move points', () => { for (const storageFailure of ['cancel','commit']) { const e=engine(game,{storageFailure}); e.run(['.d','2000']); assert.equal(e.player.score,3000); if(storageFailure==='cancel') assert.equal(e.sent.length,0); else assert.equal(e.saved[0].decision,'cancel'); } }); test('spending, death, disconnect, map end and point cap changes cancel prepared work', () => { for(const during of [(_c,e)=>{e.player.score=100;},(_c,e)=>{e.player.alive=false;},(_c,e)=>{e.active=false;},(c)=>{c.level.bank_ended=true;},(_c,e)=>{e.player.downed=true;}]) { const e=engine(game,{during}); e.run(['.d','2000']); assert.equal(e.saved[0].decision,'cancel'); } const e=engine(game,{during:(_c,e)=>{e.player.score=999999;}}); e.run(['.w','2000']); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.player.score,999999); }); test('invalid requests and unavailable match fail before HTTP', () => { for(const args of [['.d'],['.w','1000001'],['.d','4000'],['.b','2000'],['.w','1000000']]) { const e=engine(game); e.run(args); assert.equal(e.sent.length,0); assert.equal(e.player.score,3000); } const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0); }); test('balance reads do not journal or alter points', () => { const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.match(e.messages[0],/5000/); }); test('empty balance displays zero and withdrawing all reports insufficient balance', () => { const e = engine(game, {amount:0, balance:0}); e.run(['.b']); assert.equal(e.messages[0], '^3Bank: ^70 ^3points; available: ^70'); assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000); const w = engine(game, {amount:0, balance:0, state:'rejected', code:'insufficient_balance'}); w.run(['.w','all']); assert.match(w.messages[0], /Not enough bank balance/); assert.equal(w.player.score, 3000); assert.equal(w.saved[0].decision, 'cancel'); w.c.bank_sender().next(); assert.equal(w.sent[1].url, publicEndpoint + '/cancel'); assert.equal(w.saved.length, 0); }); test('recovered commit and cancel decisions retry at the public endpoint without applying points', () => { for (const decision of ['commit','cancel']) { const body = JSON.stringify({server:'server', request_id:'recovered', decision}); const options = {timeout:true, dvars:{tws_bank_url:'https://controller.internal/api/bank'}, recovered:[{id:'recovered', body, decision}]}; const e = engine(game, options), sender = e.c.bank_sender(); sender.next(); assert.equal(e.saved.length, 1); assert.equal(e.player.score, 3000); options.timeout = false; sender.next(); assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000); assert.equal(e.sent.length, 2); for (const request of e.sent) { assert.equal(request.url, publicEndpoint + '/' + decision); assert.equal(request.body, body); } } }); test('recovered settlements cannot reapply points and retry cleanup safely', () => { const e=engine(game); e.run(['.w','2000']); const score=e.player.score; const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score); const failed=engine(game,{removeFailure:true}); failed.run(['.d','2000']); const gen=failed.c.bank_sender(); gen.next(); gen.next(); assert.equal(failed.player.score,1000); assert.equal(failed.saved[0].decision,'commit'); }); test('conflicting settlement states retain their journal', () => { const {c}=engine(game); assert(!c.bank_resolved('commit','cancelled')); assert(!c.bank_resolved('cancel','committed')); assert(c.bank_resolved('cancel','rejected')); assert(c.bank_resolved('commit','committed')); }); } console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`);