Use public endpoint for game bank requests (Refs #13)

This commit is contained in:
2026-10-12 22:20:37 -04:00
parent 9cce0be438
commit 3bb0b5178e
9 changed files with 169 additions and 29 deletions
+71 -9
View File
@@ -3,6 +3,8 @@ 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 => {
@@ -11,7 +13,7 @@ function functions(source) {
return `__literal_${literals.length - 1}__`;
});
const found = new Map();
const headers = /^(bank_\w+)\(([^\n]*)\)\s*\n\{/gm;
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) {
@@ -24,6 +26,8 @@ function functions(source) {
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => 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;');
@@ -43,38 +47,70 @@ function engine(game, options = {}) {
const functionsByName = functions(source);
const player = { guid: '18446744073709551615', sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true };
const entry = { player, active: true };
const saved = [], sent = [], messages = [], order = [];
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: { bank_server: 'server', bank_url: 'https://test/api/bank', bank_token: 'ec_bank_private', bank_queue: [], bank_read: 0, bank_write: 0,
bank_busy: false, bank_ended: false, telemetry_match: 'match', telemetry_started: true },
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',
bankStore: (_server, _url, body) => {
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: () => { if (options.removeFailure) return false; saved.length = 0; 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' : 'request-1', amount: options.amount ?? 2000, balance: 5000, code: options.code ?? 'ok' };
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);
return { c, player, entry, saved, sent, messages, order, run: args => c.bank_transfer(entry,args) };
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);
@@ -85,7 +121,7 @@ for (const game of ['t5','t6']) {
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 ec_bank_private');
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', () => {
@@ -121,6 +157,32 @@ for (const game of ['t5','t6']) {
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);
+8
View File
@@ -81,6 +81,14 @@ int main(int argc, char** argv)
{"code","ok"},{"amount",2000},{"balance",3000}};
const auto ack = parse_acknowledgement(valid.dump());
assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one");
auto empty = valid;
empty["state"] = "balance"; empty["request_id"] = "";
empty["amount"] = 0; empty["balance"] = 0;
const auto zero = parse_acknowledgement(empty.dump());
assert(zero.valid && zero.amount == 0 && zero.balance == 0);
empty["ok"] = false; empty["state"] = "rejected";
empty["request_id"] = "one"; empty["code"] = "insufficient_balance";
assert(parse_acknowledgement(empty.dump()).valid);
for (const auto& field : {"amount","balance"})
for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)})
{