Fix bank GUID serialization on T5 and T6 (Refs #15)

This commit is contained in:
2026-10-12 23:45:44 -04:00
parent 3bb0b5178e
commit ab989395f7
10 changed files with 347 additions and 21 deletions
+35
View File
@@ -6,6 +6,41 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-12
### String bank identities — TWS #15
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.1` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.1` | `t6/scripts/tws_t6_bank.gsc` |
- Normalize T5's `player getGuid()` and T6's persistent `player.guid` to
strings before balance/prepare requests or durable cancel/commit decisions.
Preserve large and zero-prefixed string GUIDs without numeric conversion.
Reject missing, unsupported or invalid identities privately before HTTP,
journaling or score changes, using EC's 1–64 character token contract.
- Both engines receive the fix. T6 uses the same field as telemetry, populated
by stock connection initialization; fixtures no longer give T6 a getter shim.
Other scripts and native plugins are unchanged.
- Checks: `node tools/check_bank.mjs` covers numeric/string/missing identities,
balance, prepare, both decisions and exact-body replay after restart.
Its optional `--ec-root` mode executes the actual EC bank service and bank
migrations against isolated SQLite, covering the original numeric rejection,
deposits, withdrawals, zero-balance `.w all`, cancellation and idempotent
commit recovery. All 34 fixture groups passed with EC checkout `d614707`;
`python3 tools/test_plugin_bank.py` also passed both engines' native
storage/response, process-restart and component checks with sanitizers.
- Compatibility: no new dependency requirement or EC source change. Existing
plugins `1.1.0`, common/telemetry `1.0.0+`, and EC agent `1.9.1+` remain
sufficient. Deployment: copy each updated bank script to its game's script
directory and restart the map; no full server-process restart is required
for this fix. Continue following the endpoint migration procedure if also
upgrading from a legacy URL.
- This fix prevents new invalid journals; it does not rewrite old pending
decisions. No production recovery or deployment was performed as part of
this change.
GSC compilation, deployed-artifact verification and live T5/T6 command checks
remain required; see [bank identity validation](README.md#bank-identity-validation).
### Public game-bank endpoint — TWS #13
| Component | Version | Affected source |
+41
View File
@@ -226,6 +226,47 @@ are not refunded when a match ends or its process crashes. As with any live
score, the journal cannot make game memory and the remote database atomic
across a machine/storage failure.
### Bank identity validation
Bank scripts `2.0.1` use T5's `player getGuid()` and T6's persistent
`player.guid`, matching each game's telemetry identity source. Both normalize
integer identities to strings and preserve existing strings exactly, including
large or zero-prefixed GUIDs. Missing or invalid identities show a private
"Bank player identity unavailable" message before any request, journal write
or score change. GUIDs must meet EC's existing 1–64 character token contract
(`A–Z`, `a–z`, `0–9`, `_`, `.`, `:`, `-`). Persisted decisions and their retries
retain that original string identity, even after the player disconnects.
No new plugin or EC update is required for this fix. Install the updated bank
script in each game's script directory and restart the map. This does not
repair an older numeric-GUID journal: preserve any such pending decisions and
their request IDs for scoped recovery; never delete them to unblock a rebuild.
The endpoint upgrade procedure below still applies to legacy URLs.
Run `node tools/check_bank.mjs` for both scripts' portable fixtures. To also
exercise the actual EC bank service and bank migrations with isolated SQLite:
```sh
node tools/check_bank.mjs --ec-root /path/to/electric-cherry
python3 tools/test_plugin_bank.py
```
The optional EC check requires that checkout's installed `typescript` and
`better-sqlite3` dependencies. It uses no EC configuration, credentials, network
or production database. It covers numeric rejection, normalized balances and
transfers, zero-balance `.w all`, cancellation, and idempotent commit replay.
Native checks require the pinned JSON headers as described above. These tests
do not validate game VM linking, HTTP route authentication or deployed artifacts.
After deployment, compare the SHA-256 hashes of both installed bank scripts
with the source files from this release, and confirm the map restarted. On T5
and T6, check `.b`, a deposit, a withdrawal, and `.w all` with zero bank balance
(an insufficient-balance message with no point change). Confirm EC records
string GUIDs matching telemetry, one ledger entry per completed transfer, and
unchanged `score_total`. On a test server, interrupt settlement and restart the
map/process: the original request ID and decision must settle once without
reapplying game points. GSC compilation and these live checks remain required.
### Upgrading the bank endpoint
Deploy the Electric Cherry agent `1.9.1+` companion to
+6 -1
View File
@@ -38,13 +38,18 @@ The optional `tws_t5_bank.gsc` adds `.b`, `.d <amount|all>` and
`.w <amount|all>` and registers them in help. It requires common and telemetry
`1.0.0` or newer, the TWS t5 plugin `1.1.0`, Electric Cherry's bank API and
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
including a full process restart for the plugin. Bank `2.0.0` uses only
including a full process restart for the plugin. Bank `2.0.0+` uses only
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
an EC rebuild must settle legacy journals before the new scripts start.
When the endpoint already matches, only a map restart is required.
Bank `2.0.1` converts T5's `player getGuid()` result to a string before
requests or journal writes, preserving existing strings exactly. Missing or
invalid identities fail privately without changing points. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
+31 -1
View File
@@ -115,12 +115,42 @@ bank_request(player, args)
level.bank_busy = false;
}
bank_guid(player)
{
if (!isDefined(player))
return undefined;
guid = player getGuid();
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return undefined;
// T5 can return a number. Preserve string identities without numeric casts.
guid = "" + guid;
if (guid.size == 0 || guid.size > 64)
return undefined;
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < guid.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (guid[i] == allowed[j])
found = true;
if (!found)
return undefined;
}
return guid;
}
bank_transfer(entry, args)
{
player = entry.player;
guid = bank_guid(player);
if (!isDefined(guid))
{
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
return;
}
data = [];
data["server"] = level.bank_server;
data["guid"] = player getGuid();
data["guid"] = guid;
if (args[0] == ".b")
{
if (args.size != 1)
+1 -1
View File
@@ -14,7 +14,7 @@ telemetry and leaderboard modules; neither includes bank or database modules.
| Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters |
| Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen |
| AFK | Maintain control freeze, disable guns/grenades, guard player state and stale grace threads | Requested movement/shooting fix; retain `.afk` / `.back` and two-second grace |
| Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` sources from Plutonium's `player.guid` engine property (T5 uses `player getGuid()` method); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | T6 has no `getGuid` method but exposes the persistent identity as `.guid` on the player entity; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
| Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` comes from the persistent `player.guid` field (T5 uses `player getGuid()` directly); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | Stock T6 connection initialization populates `.guid` before notifying `connected`; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity |
| Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way |
Common retains the T5 HUD constants, helpers and first-spawn dispatcher.
+12 -4
View File
@@ -48,13 +48,21 @@ The optional `tws_t6_bank.gsc` adds `.b`, `.d <amount|all>` and
`.w <amount|all>` and registers them in help. It requires common and telemetry
`1.0.0` or newer, the TWS t6 plugin `1.1.0`, Electric Cherry's bank API and
a private per-server credential. Follow [bank setup and recovery](../../README.md#player-bank-t5-and-t6),
including a full process restart for the plugin. Bank `2.0.0` uses only
including a full process restart for the plugin. Bank `2.0.0+` uses only
`https://ec.thewrightserver.net/api/bank` and ignores `tws_bank_url`.
Update EC agents to `1.9.1+` first. If the old endpoint differs, follow the
[endpoint upgrade procedure](../../README.md#upgrading-the-bank-endpoint):
an EC rebuild must settle legacy journals before the new scripts start.
When the endpoint already matches, only a map restart is required.
Bank `2.0.1` reads the persistent `player.guid` field used by telemetry and
converts it to a string before requests or journal writes. Existing strings
are preserved exactly; missing or invalid identities fail privately without
changing points. Stock T6
[`callback_playerconnect()`](https://github.com/plutoniummod/t6-scripts/blob/2d8440321602f22f63f5335817110d60ddf223d5/ZM/Core/maps/mp/gametypes_zm/_globallogic_player.gsc)
populates that field before the `connected` notification. Follow the shared
[identity and deployment checks](../../README.md#bank-identity-validation).
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
@@ -156,9 +164,9 @@ It sends four event types over the life of a match, mirroring the T5 script:
Each event payload includes `event`, `server`, `map`, `match`, `seq`, `round` (except `game_start`)
and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives`
and `score` plus `score_total` per player in stat reports. `game_start` includes
only player identity. `guid` reads Plutonium's `player.guid` engine property
(T5 uses the `player getGuid()` method — T6 has no such method, but the field is
equivalent for identity purposes). `kills`/`headshots`/`downs`/`revives` read
only player identity. `guid` reads the persistent `player.guid` field populated
by T6's stock connection initialization (T5 reads `player getGuid()` directly).
`kills`/`headshots`/`downs`/`revives` read
from T6's stock match counters in `player.pers[]`; missing or negative values
are reported as `0`. `score` is the player's current spendable `player.score`.
`score_total` reads `player.score_total`, the stock cumulative match score used
+32 -1
View File
@@ -119,12 +119,43 @@ bank_request(player, args)
level.bank_busy = false;
}
bank_guid(player)
{
if (!isDefined(player))
return undefined;
// Use the persistent field populated at connection, as telemetry does.
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return undefined;
// Preserve string identities without numeric casts, including large GUIDs.
guid = "" + guid;
if (guid.size == 0 || guid.size > 64)
return undefined;
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
for (i = 0; i < guid.size; i++)
{
found = false;
for (j = 0; j < allowed.size; j++)
if (guid[i] == allowed[j])
found = true;
if (!found)
return undefined;
}
return guid;
}
bank_transfer(entry, args)
{
player = entry.player;
guid = bank_guid(player);
if (!isDefined(guid))
{
bank_say(entry, "^3Bank player identity unavailable. Try again shortly; points unchanged.");
return;
}
data = [];
data["server"] = level.bank_server;
data["guid"] = player getGuid();
data["guid"] = guid;
if (args[0] == ".b")
{
if (args.size != 1)
+130 -11
View File
@@ -2,9 +2,29 @@
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 => {
@@ -26,6 +46,7 @@ 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(/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(');
@@ -45,10 +66,14 @@ 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 };
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 ?? [];
const saved = recovered.map(report => JSON.parse(report.body)), sent = [], messages = [], order = [], loaded = [], dvarsRead = [];
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 dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars };
function assertScope(server, url) {
assert.equal(server, dvars.tws_server_id);
@@ -59,36 +84,85 @@ 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, getGuid: p => p.guid,
int: Math.trunc,
...(game === 't5' ? {getGuid: p => { assert.equal(p, player); return identity; }} : {}),
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : typeof value,
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
bank_say: (_entry, message) => messages.push(message),
telemetryNewMatch: () => 'request-1',
telemetryNewMatch: () => `${options.idPrefix ?? 'request'}-${++serial}`,
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);
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) => { order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {}; },
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 [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }];
},
bankResponse: data => options.malformed ? { valid: false } : JSON.parse(data),
bankResponse: data => options.malformed ? { valid: false } : {valid:true, ...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) };
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, 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']]) {
@@ -120,7 +194,7 @@ for (const game of ['t5','t6']) {
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.saved[0].guid,'18446744073709551615');
assert.equal(e.sent[0].headers.Authorization,'Bearer ' + privateToken);
assert.equal(e.sent[0].timeout_ms,15000);
});
@@ -169,7 +243,7 @@ for (const game of ['t5','t6']) {
});
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 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();
@@ -194,5 +268,50 @@ for (const game of ['t5','t6']) {
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.');
+57
View File
@@ -0,0 +1,57 @@
// Optional integration with a local EC checkout. Execute its real bank service
// and bank migrations against disposable SQLite, with minimal fleet fixtures.
// No EC configuration, credentials, HTTP routes or production DB are loaded.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import vm from 'node:vm';
export function loadEcBank(directory) {
assert(directory, '--ec-root requires an EC checkout with installed dependencies');
const requireEc = createRequire(resolve(directory, 'package.json'));
const ts = requireEc('typescript');
const Database = requireEc('better-sqlite3');
function compile(file) {
const source = readFileSync(resolve(directory, 'src/lib/server', file), 'utf8');
return new vm.Script(ts.transpileModule(source, {
compilerOptions: {module:ts.ModuleKind.CommonJS, target:ts.ScriptTarget.ES2022}
}).outputText, {filename:file});
}
const service = compile('fleet/bank.ts');
const bankMigration = compile('db/bank.ts');
const credentialMigration = compile('db/bank-credentials.ts');
return guid => {
const database = new Database(':memory:');
function execute(script) {
const context = vm.createContext({
exports: {}, process: {env:{}},
require: name => {
assert.equal(name, '../db/index.js', 'unexpected EC runtime dependency');
return {db:() => database};
}
});
script.runInContext(context);
return context.exports;
}
database.exec(`
CREATE TABLE native_instances (id TEXT PRIMARY KEY, server TEXT, retired_at TEXT);
INSERT INTO native_instances VALUES ('instance', 'server', NULL);
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
INSERT INTO servers VALUES ('server', NULL);
CREATE TABLE leaderboard_matches (id INTEGER PRIMARY KEY, server TEXT, reporter_match_key TEXT, status TEXT);
INSERT INTO leaderboard_matches VALUES (1, 'server', 'match', 'active');
CREATE TABLE leaderboard_match_players (match_id INTEGER, guid TEXT, present INTEGER);
`);
database.prepare('INSERT INTO leaderboard_match_players VALUES (1, ?, 1)').run(guid);
execute(bankMigration).migrateBank(database);
execute(credentialMigration).migrateBankCredentials(database);
const {bankRequest} = execute(service);
return {
request: (action, body) => bankRequest(action, body, 'server', 'instance'),
ledger: () => database.prepare('SELECT guid, amount FROM bank_ledger ORDER BY id').all(),
requests: () => database.prepare('SELECT request_id, guid, state FROM bank_requests ORDER BY request_id').all(),
close: () => database.close()
};
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"tws_t5_afk.gsc": "1.0.0",
"tws_t5_bank.gsc": "2.0.0",
"tws_t5_bank.gsc": "2.0.1",
"tws_t5_common.gsc": "1.1.0",
"tws_t5_hud.gsc": "1.1.0",
"tws_t5_join.gsc": "1.0.0",
@@ -8,7 +8,7 @@
"tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.0.0",
"tws_t6_bank.gsc": "2.0.0",
"tws_t6_bank.gsc": "2.0.1",
"tws_t6_common.gsc": "1.1.0",
"tws_t6_hud.gsc": "1.1.0",
"tws_t6_join.gsc": "1.0.1",