Files
tws-zombies-scripts/tools/check_bank.mjs
T
josh 463b4bcc51 Add safe bank failure diagnostics for T5 and T6
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.
2026-10-13 10:02:36 -04:00

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.');