Fix bank GUID serialization on T5 and T6 (Refs #15)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user