From ab989395f7492ae18eda244f641c4ea279b20dd9 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 12 Oct 2026 23:45:44 -0400 Subject: [PATCH] Fix bank GUID serialization on T5 and T6 (Refs #15) --- CHANGELOG.md | 35 ++++++++ README.md | 41 +++++++++ t5/scripts/README.md | 7 +- t5/scripts/tws_t5_bank.gsc | 32 ++++++- t6/scripts/NOTES.md | 2 +- t6/scripts/README.md | 16 +++- t6/scripts/tws_t6_bank.gsc | 33 +++++++- tools/check_bank.mjs | 141 ++++++++++++++++++++++++++++--- tools/tests/ec_bank_contract.mjs | 57 +++++++++++++ versions.json | 4 +- 10 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 tools/tests/ec_bank_contract.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c07f0d..6092e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 | diff --git a/README.md b/README.md index 82d7dd5..ec6c222 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/t5/scripts/README.md b/t5/scripts/README.md index a03ecfd..39f03f8 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -38,13 +38,18 @@ The optional `tws_t5_bank.gsc` adds `.b`, `.d ` and `.w ` 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 diff --git a/t5/scripts/tws_t5_bank.gsc b/t5/scripts/tws_t5_bank.gsc index ca5cccf..929c020 100644 --- a/t5/scripts/tws_t5_bank.gsc +++ b/t5/scripts/tws_t5_bank.gsc @@ -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) diff --git a/t6/scripts/NOTES.md b/t6/scripts/NOTES.md index 743220a..ceaa763 100644 --- a/t6/scripts/NOTES.md +++ b/t6/scripts/NOTES.md @@ -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. diff --git a/t6/scripts/README.md b/t6/scripts/README.md index d0eec12..d6112f5 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -48,13 +48,21 @@ The optional `tws_t6_bank.gsc` adds `.b`, `.d ` and `.w ` 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 diff --git a/t6/scripts/tws_t6_bank.gsc b/t6/scripts/tws_t6_bank.gsc index e49455c..e59e065 100644 --- a/t6/scripts/tws_t6_bank.gsc +++ b/t6/scripts/tws_t6_bank.gsc @@ -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) diff --git a/tools/check_bank.mjs b/tools/check_bank.mjs index 2d71575..796bcc0 100644 --- a/tools/check_bank.mjs +++ b/tools/check_bank.mjs @@ -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(/(? 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.'); diff --git a/tools/tests/ec_bank_contract.mjs b/tools/tests/ec_bank_contract.mjs new file mode 100644 index 0000000..b1bc819 --- /dev/null +++ b/tools/tests/ec_bank_contract.mjs @@ -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() + }; + }; +} diff --git a/versions.json b/versions.json index 8094da2..302fd96 100644 --- a/versions.json +++ b/versions.json @@ -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",