Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
393 lines
28 KiB
JavaScript
393 lines
28 KiB
JavaScript
// 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';
|
|
import { loadEcBank } from './tests/ec_bank_contract.mjs';
|
|
const root = new URL('../', import.meta.url);
|
|
const publicEndpoint = 'https://ec.thewrightserver.net/api/bank';
|
|
const privateToken = 'ec_bank_' + 'a'.repeat(43);
|
|
const ecIndex = process.argv.indexOf('--ec-root');
|
|
const realEc = ecIndex < 0 ? undefined : loadEcBank(process.argv[ecIndex + 1]);
|
|
|
|
// EC src/lib/server/fleet/bank.ts token/transfer contract, also checked against
|
|
// that actual implementation with --ec-root (no HTTP or production database).
|
|
function assertContract(action, data) {
|
|
const token = value => typeof value === 'string' && value.length > 0 && value.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value);
|
|
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min && value <= 1000000;
|
|
assert.equal(data.server, 'server');
|
|
assert(token(data.guid), 'EC requires a nonempty string GUID token');
|
|
if (action === 'balance') return;
|
|
assert(token(data.request_id)); assert(token(data.match));
|
|
assert(['deposit','withdraw'].includes(data.operation));
|
|
assert(integer(data.amount, 1) || (data.operation === 'withdraw' && data.amount === 'all'));
|
|
assert(integer(data.capacity));
|
|
assert(['commit','cancel'].includes(data.decision));
|
|
if (action === 'deposit' || action === 'withdraw') assert.equal(action, data.operation);
|
|
else assert.equal(action, data.decision);
|
|
}
|
|
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(/(?<![\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(/typeof\(/g, 'gscType(');
|
|
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);
|
|
if (game === 't6') assert(!/\bgetGuid\s*\(/i.test(source), 'T6 bank must use the persistent field, as telemetry does');
|
|
const identity = Object.hasOwn(options, 'guid') ? options.guid : '18446744073709551615';
|
|
const player = { sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true };
|
|
if (game === 't6') player.guid = identity;
|
|
const entry = { player, active: true };
|
|
const recovered = (options.recovered ?? []).map(report => ({...report}));
|
|
const saved = recovered.map(report => JSON.parse(report.body)), writes = [], sent = [], messages = [], order = [], loaded = [], dvarsRead = [], logs = [];
|
|
let serial = 0, now = 1000;
|
|
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, getTime: () => now, strTok: (text, delimiter) => text.split(delimiter),
|
|
bankVersion: () => "1.2.0",
|
|
...(game === 't5' ? {getGuid: p => { assert.equal(p, player); return identity; }} : {}),
|
|
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : value && typeof value === 'object' ? 'array' : typeof value,
|
|
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
|
|
bank_say: (_entry, message) => messages.push(message),
|
|
telemetryNewMatch: () => `${options.idPrefix ?? 'request'}-${++serial}`,
|
|
bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return options.loadFailure ? undefined : recovered; },
|
|
bankStore: (server, url, body) => {
|
|
assertScope(server, url);
|
|
const data = JSON.parse(body); assertContract(data.decision, data);
|
|
writes.push(data); 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) => {
|
|
assertContract(url.split('/').at(-1), JSON.parse(data.body));
|
|
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);
|
|
if (options.receiver) {
|
|
const response = options.receiver(action, JSON.parse(sent.at(-1).body));
|
|
return [JSON.stringify(response.body), !options.timeout, '', {http_status: response.status}];
|
|
}
|
|
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 [options.missingResponse ? undefined : JSON.stringify(reply), !options.timeout, privateToken, options.missingDiagnostics ? undefined : options.diagnostics ?? { http_status: options.status ?? 200, curl_code: options.timeout ? 28 : 0, dns_ms:1, connect_ms:2, tls_ms:3, total_ms:options.timeout ? 15000 : 4 }];
|
|
},
|
|
bankResponse: data => options.malformed ? { valid: false, reason: options.reason ?? "invalid_json" } : {valid:true, ...JSON.parse(data)},
|
|
print: message => logs.push(message), println: message => logs.push(message)
|
|
});
|
|
vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
|
|
c.init();
|
|
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, logs, advance: ms => { now += ms; }, run: args => c.bank_transfer(entry,args) };
|
|
}
|
|
let checks = 0;
|
|
function journalEntries(engine) {
|
|
// GSC associative arrays do not serialize as JS arrays. Copy the native
|
|
// bankLoad shape while retaining the exact serialized decision body.
|
|
return Array.from(engine.c.level.bank_queue, entry => ({id:entry.id, body:entry.body, decision:entry.decision}));
|
|
}
|
|
for (const game of ['t5','t6']) {
|
|
function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } }
|
|
test('engine identities remain exact strings in balance, prepare, both decisions and restart replay', () => {
|
|
for (const guid of [2741626, 0, '2741626', '0002741626', '18446744073709551615']) {
|
|
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
|
|
for (const timeout of [false, true]) {
|
|
const e = engine(game, {guid, timeout}); e.run(command);
|
|
assert.equal(e.sent.length, 1);
|
|
for (const data of [...e.writes, ...e.sent.map(request => JSON.parse(request.body))]) assert.equal(data.guid, String(guid));
|
|
if (command[0] === '.b') { assert.equal(e.writes.length, 0); continue; }
|
|
assert.equal(e.writes[0].decision, 'cancel');
|
|
assert.equal(e.saved[0].decision, timeout ? 'cancel' : 'commit');
|
|
const recovered = journalEntries(e);
|
|
const restarted = engine(game, {guid:undefined, recovered});
|
|
restarted.c.bank_sender().next();
|
|
assert.equal(restarted.sent[0].body, recovered[0].body);
|
|
assert.equal(JSON.parse(restarted.sent[0].body).guid, String(guid));
|
|
assert.equal(restarted.saved.length, 0);
|
|
assert.equal(restarted.player.score, 3000);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
test('missing or invalid identities reject before HTTP, journal writes or point changes', () => {
|
|
for (const guid of [undefined, null, '', ' ', 'bad guid', 'x'.repeat(65), 'x\n', true, {}, [], 1.5]) {
|
|
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
|
|
const e = engine(game, {guid}); e.run(command);
|
|
assert.match(e.messages[0], /player identity unavailable/);
|
|
assert.equal(e.sent.length, 0); assert.equal(e.writes.length, 0);
|
|
assert.equal(e.c.level.bank_write, 0); assert.equal(e.player.score, 3000);
|
|
assert.equal(e.player.score_total, 4000);
|
|
}
|
|
}
|
|
const e = engine(game); e.entry.player = undefined; e.run(['.b']);
|
|
assert.match(e.messages[0], /player identity unavailable/); assert.equal(e.sent.length, 0);
|
|
});
|
|
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('startup reports versions, scope, credential validation and journal count safely', () => {
|
|
const e = engine(game);
|
|
assert.match(e.logs.join('\n'), /script=2\.0\.2 plugin=1\.2\.0/);
|
|
assert(e.logs.some(line => line.includes(`endpoint=${publicEndpoint} server=server credentials=valid`)));
|
|
assert(e.logs.some(line => line.includes('journal_load=success pending=0')));
|
|
const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}});
|
|
assert(invalid.logs.some(line => line.includes('credentials=invalid')));
|
|
assert(!invalid.logs.join('').includes('secret-invalid-token'));
|
|
const load = engine(game, {loadFailure:true});
|
|
assert.match(load.logs.join('\n'), /journal_load=failure pending=unknown bank=disabled/);
|
|
assert.equal(load.c.level.bank_write, undefined);
|
|
});
|
|
test('requests classify failures and preserve the API identity contract', () => {
|
|
for (const [options, outcome] of [
|
|
[{}, 'ok'], [{noHandle:true}, 'request_creation'], [{timeout:true}, 'transport_failure'],
|
|
[{status:401}, 'http_rejection'], [{missingDiagnostics:true}, 'missing_diagnostics'],
|
|
[{missingResponse:true}, 'missing_response'], [{malformed:true}, 'invalid_ack_invalid_json'],
|
|
[{malformed:true,reason:'schema'}, 'invalid_ack_schema'], [{wrongId:true}, 'request_id_mismatch'],
|
|
[{state:'rejected', code:'pending'}, 'application_rejection'], [{state:'committed'}, 'unexpected_state']
|
|
]) {
|
|
const e = engine(game, options); e.run(['.d','2000']);
|
|
const line = e.logs.find(line => line.includes('action=deposit'));
|
|
assert(line, JSON.stringify(options)); assert(line.includes(`outcome=${outcome} `), line);
|
|
assert.match(line, /correlation=bank-1000-1 action=deposit request_id=request-1/);
|
|
assert.match(line, /http_status=\w+ curl_code=\w+ dns_ms=\w+ connect_ms=\w+ tls_ms=\w+ total_ms=\w+/);
|
|
}
|
|
const e = engine(game); e.run(['.b']); e.run(['.b']);
|
|
const lines = e.logs.filter(line => line.includes('action=balance'));
|
|
assert.equal(lines.length, 2);
|
|
assert.match(lines[0], /correlation=bank-1000-1/); assert.match(lines[1], /correlation=bank-1000-2/);
|
|
for (const request of e.sent) assert(!Object.hasOwn(JSON.parse(request.body), 'request_id'));
|
|
assert.equal(e.saved.length, 0);
|
|
});
|
|
test('diagnostics exclude secrets, arbitrary codes, bodies and malformed timing fields', () => {
|
|
for (const diagnostics of [undefined, {}, 'secret', {http_status:200, curl_code:privateToken, dns_ms:-1, connect_ms:{}, tls_ms:1e30, total_ms:'raw-body'}]) {
|
|
const e = engine(game, {diagnostics, state:'rejected', code:privateToken}); e.run(['.d','2000']);
|
|
const text = e.logs.join('\n');
|
|
for (const secret of [privateToken, 'Authorization', 'Bearer ', 'raw-body', '18446744073709551615']) assert(!text.includes(secret));
|
|
}
|
|
const e = engine(game, {malformed:true, reason:privateToken}); e.run(['.b']);
|
|
assert.match(e.logs.at(-1), /invalid_ack_unknown/); assert(!e.logs.join('').includes(privateToken));
|
|
for (const code of [privateToken, 'oops\ninjected', '^1red', 'x'.repeat(1000), 'custom_code']) {
|
|
assert.equal(e.c.bank_response_code(code), 'unknown');
|
|
}
|
|
for (const diagnostics of [undefined, {}, [], false, 'secret', {http_status:'200'}, {http_status:-1}])
|
|
assert.equal(e.c.bank_diagnostic_number(diagnostics, 'http_status'), 'unavailable');
|
|
});
|
|
test('failures are limited by reason, with summaries, changed status and immediate recovery', () => {
|
|
const options = {status:401}, e = engine(game, options);
|
|
e.run(['.b']); e.run(['.b']);
|
|
assert.equal(e.logs.filter(line => line.includes('action=balance')).length, 1);
|
|
e.advance(30000); e.run(['.b']); assert.match(e.logs.at(-1), /suppressed=1/);
|
|
options.status = 503; e.run(['.b']); assert.match(e.logs.at(-1), /http_status=503/);
|
|
options.status = 200; e.run(['.b']); assert.match(e.logs.at(-1), /outcome=ok.*recovery=true/);
|
|
});
|
|
test('settlement reports retry progress, reason changes, storage failures and eventual recovery', () => {
|
|
const options = {}, e = engine(game, options); e.run(['.d','2000']);
|
|
options.timeout = true;
|
|
const sender = e.c.bank_sender();
|
|
assert.equal(sender.next().value, 1);
|
|
assert.match(e.logs.at(-1), /decision=commit attempt=1 pending_seconds=0 settlement=pending next_retry_seconds=1/);
|
|
const count = e.logs.length;
|
|
e.advance(1000); assert.equal(sender.next().value, 2); assert.equal(e.logs.length, count);
|
|
options.timeout = false; options.removeFailure = true;
|
|
e.advance(2000); assert.equal(sender.next().value, 4);
|
|
assert.match(e.logs.at(-1), /attempt=3 pending_seconds=3 settlement=journal_remove_failure next_retry_seconds=4.*suppressed=1/);
|
|
options.removeFailure = false; e.advance(4000); sender.next();
|
|
assert.match(e.logs.at(-1), /attempt=4 pending_seconds=7 journal_remove=success settlement=resolved next_retry_seconds=0 recovery=true/);
|
|
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 1000);
|
|
for (const decision of ['cancel','commit']) {
|
|
const failed = engine(game, {storageFailure:decision}); failed.run(['.d','2000']);
|
|
assert(failed.logs.some(line => line.includes(`journal_write decision=${decision} request_id=request-1 outcome=storage_failure`)));
|
|
}
|
|
});
|
|
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,'18446744073709551615');
|
|
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', guid:'18446744073709551615', request_id:'recovered', match:'old-match', operation:'withdraw', amount:'all', capacity:999370, 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'));
|
|
});
|
|
if (realEc) test('actual EC rejects numeric GUIDs and settles normalized script payloads exactly once', () => {
|
|
for (const guid of [2741626, '2741626', '0002741626', '18446744073709551615']) {
|
|
const ec = realEc(String(guid));
|
|
try {
|
|
// Reproduce the live rejection before any EC transaction exists.
|
|
const numeric = {server:'server', guid:2741626, match:'match', request_id:'old-cancel', operation:'withdraw', amount:'all', capacity:999370, decision:'cancel'};
|
|
for (const action of ['balance','withdraw','cancel','commit']) {
|
|
const rejected = ec.request(action, numeric);
|
|
assert.equal(rejected.status, 400); assert.equal(rejected.body.code, 'invalid_request');
|
|
assert.equal(rejected.body.request_id, '');
|
|
}
|
|
assert.equal(ec.requests().length, 0);
|
|
const e = engine(game, {guid, receiver:ec.request});
|
|
e.run(['.b']); assert.equal(e.messages[0], '^3Bank: ^70 ^3points; available: ^70');
|
|
e.run(['.w','all']); assert.match(e.messages.at(-1), /Not enough bank balance/);
|
|
assert.equal(e.player.score, 3000); assert.equal(e.saved[0].decision, 'cancel');
|
|
e.c.bank_sender().next(); assert.equal(e.saved.length, 0);
|
|
|
|
e.run(['.d','2000']); assert.equal(e.player.score, 1000);
|
|
assert.equal(e.saved[0].decision, 'commit');
|
|
const recovered = journalEntries(e);
|
|
const options = {guid:undefined, receiver:ec.request, recovered, removeFailure:true};
|
|
const replay = engine(game, options), sender = replay.c.bank_sender();
|
|
sender.next(); sender.next(); // EC commits once even when cleanup fails.
|
|
assert.equal(replay.saved.length, 1); assert.equal(replay.player.score, 3000);
|
|
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}]);
|
|
options.removeFailure = false; sender.next(); assert.equal(replay.saved.length, 0);
|
|
for (const request of replay.sent) assert.equal(request.body, recovered[0].body);
|
|
|
|
const withdrawal = engine(game, {guid, receiver:ec.request, idPrefix:'withdraw'});
|
|
withdrawal.run(['.w','all']); assert.equal(withdrawal.player.score, 5000);
|
|
withdrawal.c.bank_sender().next(); assert.equal(withdrawal.saved.length, 0);
|
|
assert.deepEqual(ec.ledger(), [{guid:String(guid), amount:2000}, {guid:String(guid), amount:-2000}]);
|
|
|
|
const cancel = engine(game, {guid, receiver:ec.request, idPrefix:'cancel', during:(_c, entry) => {entry.player.alive = false;}});
|
|
cancel.run(['.d','2000']); assert.equal(cancel.saved[0].decision, 'cancel');
|
|
const restart = engine(game, {guid:undefined, receiver:ec.request, recovered:journalEntries(cancel)});
|
|
restart.c.bank_sender().next(); assert.equal(restart.saved.length, 0);
|
|
assert.equal(cancel.player.score, 3000); assert.equal(ec.ledger().length, 2);
|
|
assert.equal(ec.requests().find(row => row.request_id === 'cancel-1').state, 'cancelled');
|
|
assert(ec.requests().every(row => row.guid === String(guid)));
|
|
} finally { ec.close(); }
|
|
}
|
|
});
|
|
}
|
|
console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`);
|
|
console.log(realEc ? 'PASS actual EC bank service and SQLite integration.' : 'Actual EC integration skipped; pass --ec-root /path/to/electric-cherry to include it.');
|