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.
This commit is contained in:
2026-10-13 10:02:36 -04:00
parent ab989395f7
commit 463b4bcc51
19 changed files with 804 additions and 78 deletions
+84 -9
View File
@@ -72,8 +72,8 @@ function engine(game, options = {}) {
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 = [];
let serial = 0;
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);
@@ -84,13 +84,14 @@ function engine(game, options = {}) {
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,
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' : typeof value,
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 recovered; },
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);
@@ -112,14 +113,14 @@ function engine(game, options = {}) {
}
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 }];
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 } : {valid:true, ...JSON.parse(data)},
print: () => {}, println: () => {}
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, run: args => c.bank_transfer(entry,args) };
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) {
@@ -185,6 +186,80 @@ for (const game of ['t5','t6']) {
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);