Use public endpoint for game bank requests (Refs #13)

This commit is contained in:
2026-10-12 22:20:37 -04:00
parent 9cce0be438
commit 3bb0b5178e
9 changed files with 169 additions and 29 deletions
+30
View File
@@ -6,6 +6,36 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-12 ## 2026-10-12
### Public game-bank endpoint — TWS #13
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_bank.gsc` | `2.0.0` | `t5/scripts/tws_t5_bank.gsc` |
| `tws_t6_bank.gsc` | `2.0.0` | `t6/scripts/tws_t6_bank.gsc` |
- Use `https://ec.thewrightserver.net/api/bank` for all balance, transfer and
settlement requests on both engines. Remove the `tws_bank_url` override;
stale settings are ignored. This configuration removal requires a major bump.
Keep EC's `tws_server_id` and private per-instance `tws_bank_token` authentication.
- Both T5 and T6 receive the same behavior with their existing namespaces and
startup guards. Native plugins and other scripts need no source changes.
- Compatibility: requires companion EC agent `1.9.1+` to recognize public and
legacy controller-derived journals during authenticated teardown settlement.
Existing plugin `1.1.0`, common `1.0.0+` and telemetry `1.0.0+` remain sufficient;
no new native API or third-party dependency requirement.
- Deployment: update agents first. When changing endpoints, **rebuild through
EC** so the old process stops and its pending decisions settle before a new
process starts. Do not switch scripts with just a map restart in that case;
journals are endpoint-scoped. If the old URL already matches exactly, copy the
scripts and restart the map; no full server-process restart is required.
See [endpoint upgrade and recovery](README.md#upgrading-the-bank-endpoint).
No deployment was performed; agent release signing, Windows/GSC compilation
and live T5/T6 validation remain required.
- Checks: portable script fixtures exercise real initialization with stale
overrides, every request type, authentication, empty balances and retries.
Native fixtures cover zero-balance and insufficient-balance replies alongside
existing durable storage and process-restart checks.
### Persistent cross-game player bank — TWS #3 / Electric Cherry #146 ### Persistent cross-game player bank — TWS #3 / Electric Cherry #146
| Component | Version | Affected source | | Component | Version | Affected source |
+38 -4
View File
@@ -190,11 +190,15 @@ requirements; disabling it by dvar does not avoid linking.
Electric Cherry generates a unique bank token when it prepares a native server Electric Cherry generates a unique bank token when it prepares a native server
instance and injects it through that instance's private instance and injects it through that instance's private
`electric_cherry_bank.cfg`. It sets `tws_server_id`, `tws_bank_token` and `electric_cherry_bank.cfg`, with `tws_server_id` supplied by the launch configuration.
`tws_bank_url` automatically. Rebuilds and host moves keep the instance token; Both bank scripts `2.0.0` always use `https://ec.thewrightserver.net/api/bank`;
`tws_bank_url` is no longer read, even if an old configuration still sets it.
The host agent's enrolled controller address is independent and continues to
serve agent traffic. Agent `1.9.1+` writes only the private `tws_bank_token` in
the bank configuration. Rebuilds and host moves keep the instance token;
destroying the instance revokes it. Reusing a server name gets a new token. destroying the instance revokes it. Reusing a server name gets a new token.
There is no `EC_BANK_TOKENS` map or per-server secret to maintain. Keep secrets There is no `EC_BANK_TOKENS` map or per-server secret to maintain. Keep secrets
outside shared scripts and promoted configs. The URL must use HTTPS. outside shared scripts and promoted configs.
Set `tws_bank_enabled 0` to disable the module; restart maps after script/config changes. Set `tws_bank_enabled 0` to disable the module; restart maps after script/config changes.
Electric Cherry's `EC_BANK_MAX_BALANCE` defaults to 250000 (range 1–1000000). Electric Cherry's `EC_BANK_MAX_BALANCE` defaults to 250000 (range 1–1000000).
Each transfer and bank-funded spendable balance is capped at 1000000 points. Each transfer and bank-funded spendable balance is capped at 1000000 points.
@@ -209,7 +213,7 @@ have only one prepared transfer across the fleet.
Preserve `tws-bank/t5/` and `tws-bank/t6/`, relative to the game process working Preserve `tws-bank/t5/` and `tws-bank/t6/`, relative to the game process working
directory, across process/container restarts, with one process per server/endpoint directory, across process/container restarts, with one process per server/endpoint
directory. EC host agents **1.9.0+** settle directory. EC host agents **1.9.1+** settle
pending journals before deleting runtime storage on removal, rebuild, migration pending journals before deleting runtime storage on removal, rebuild, migration
or launch cancellation. Update the agents before enabling the bank. An outage or launch cancellation. Update the agents before enabling the bank. An outage
or invalid journal blocks cleanup and preserves the files. Decisions are scoped or invalid journal blocks cleanup and preserves the files. Decisions are scoped
@@ -222,11 +226,41 @@ 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 score, the journal cannot make game memory and the remote database atomic
across a machine/storage failure. across a machine/storage failure.
### Upgrading the bank endpoint
Deploy the Electric Cherry agent `1.9.1+` companion to
[TWS #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13)
before installing bank scripts `2.0.0`. It accepts journals for the public game
endpoint and the legacy bank URL derived from that host's enrolled controller.
It settles through the existing authenticated, instance-scoped agent API at the
controller; it never follows journal URLs and rejects other endpoint identities.
The instance token and server identity must stay the same through the upgrade.
**If the old `tws_bank_url` differs from the public endpoint, rebuild the server
through EC with agent `1.9.1+` and the new scripts.** The agent stops the old
process and settles its original commit/cancel decisions before starting the
replacement and deleting old storage. A lost reply replays the same request ID;
EC deduplicates it. An outage, corrupt journal or failed acknowledgment retains
storage and blocks the replacement. Resolve the failure and retry the rebuild.
Keep the enrolled controller address unchanged until legacy decisions drain.
Do not merely replace scripts and restart the map: native journals are keyed by
server and endpoint, so the new scripts cannot see a legacy endpoint's pending
decisions. Do not rename, rewrite or delete those journals to force migration.
If the previous endpoint already was the exact public URL, install the scripts
and restart the map; saved decisions replay under the same journal identity.
No new plugin build or full process restart is required for that case. Other
legacy endpoints are refused by the agent and require recovery with their
original configuration before switching. No deployment has been performed here.
Run `node tools/check_bank.mjs` and `python3 tools/test_plugin_bank.py`. Run `node tools/check_bank.mjs` and `python3 tools/test_plugin_bank.py`.
Before rollout, build/link Windows DLLs, compile GSC, and verify both games: Before rollout, build/link Windows DLLs, compile GSC, and verify both games:
help, balance and `all` commands, score HUD/persistence, max/overdraw errors, help, balance and `all` commands, score HUD/persistence, max/overdraw errors,
death/respawn/disconnect during HTTP, late telemetry, lost prepare/commit death/respawn/disconnect during HTTP, late telemetry, lost prepare/commit
responses, disk failures and map/process restart with pending decisions. responses, disk failures and map/process restart with pending decisions.
For this upgrade also verify stale URL settings are ignored, an empty `.b`
shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal
settles legacy and public journals without duplicate ledger entries or lost points.
## Map record announcements ## Map record announcements
+6 -1
View File
@@ -38,7 +38,12 @@ 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 `.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 `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), 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 and a map restart for scripts. 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.
## Persistent Discord text ## Persistent Discord text
+3 -5
View File
@@ -6,17 +6,15 @@ init()
{ {
if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0") if (GetDvar(#"zombiemode") != "1" || GetDvar("tws_bank_enabled") == "0")
return; return;
level.bank_url = GetDvar("tws_bank_url"); // Game containers use the public API, independent of the host controller URL.
if (level.bank_url == "")
level.bank_url = "https://ec.thewrightserver.net/api/bank"; level.bank_url = "https://ec.thewrightserver.net/api/bank";
level.bank_server = GetDvar("tws_server_id"); level.bank_server = GetDvar("tws_server_id");
level.bank_token = GetDvar("tws_bank_token"); level.bank_token = GetDvar("tws_bank_token");
// A private per-server cfg supplies this; never embed bank credentials. // A private per-server cfg supplies this; never embed bank credentials.
if (level.bank_server == "" || level.bank_token.size < 40 || if (level.bank_server == "" || level.bank_token.size < 40 ||
getSubStr(level.bank_token, 0, 8) != "ec_bank_" || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
getSubStr(level.bank_url, 0, 8) != "https://")
{ {
print("^1[tws_bank] Disabled: configure HTTPS, tws_server_id and a private tws_bank_token"); print("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token");
return; return;
} }
level.bank_queue = bankLoad(level.bank_server, level.bank_url); level.bank_queue = bankLoad(level.bank_server, level.bank_url);
+6 -1
View File
@@ -48,7 +48,12 @@ 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 `.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 `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), 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 and a map restart for scripts. 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.
## Persistent Discord text ## Persistent Discord text
+3 -5
View File
@@ -10,17 +10,15 @@ init()
{ {
if (GetDvar("tws_bank_enabled") == "0") if (GetDvar("tws_bank_enabled") == "0")
return; return;
level.bank_url = GetDvar("tws_bank_url"); // Game containers use the public API, independent of the host controller URL.
if (level.bank_url == "")
level.bank_url = "https://ec.thewrightserver.net/api/bank"; level.bank_url = "https://ec.thewrightserver.net/api/bank";
level.bank_server = GetDvar("tws_server_id"); level.bank_server = GetDvar("tws_server_id");
level.bank_token = GetDvar("tws_bank_token"); level.bank_token = GetDvar("tws_bank_token");
// A private per-server cfg supplies this; never embed bank credentials. // A private per-server cfg supplies this; never embed bank credentials.
if (level.bank_server == "" || level.bank_token.size < 40 || if (level.bank_server == "" || level.bank_token.size < 40 ||
getSubStr(level.bank_token, 0, 8) != "ec_bank_" || getSubStr(level.bank_token, 0, 8) != "ec_bank_")
getSubStr(level.bank_url, 0, 8) != "https://")
{ {
println("^1[tws_bank] Disabled: configure HTTPS, tws_server_id and a private tws_bank_token"); println("^1[tws_bank] Disabled: configure tws_server_id and a private tws_bank_token");
return; return;
} }
level.bank_queue = bankLoad(level.bank_server, level.bank_url); level.bank_queue = bankLoad(level.bank_server, level.bank_url);
+71 -9
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import vm from 'node:vm'; import vm from 'node:vm';
const root = new URL('../', import.meta.url); const root = new URL('../', import.meta.url);
const publicEndpoint = 'https://ec.thewrightserver.net/api/bank';
const privateToken = 'ec_bank_' + 'a'.repeat(43);
function functions(source) { function functions(source) {
const literals = []; const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
@@ -11,7 +13,7 @@ function functions(source) {
return `__literal_${literals.length - 1}__`; return `__literal_${literals.length - 1}__`;
}); });
const found = new Map(); const found = new Map();
const headers = /^(bank_\w+)\(([^\n]*)\)\s*\n\{/gm; const headers = /^(init|bank_\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(protectedSource));) { for (let match; (match = headers.exec(protectedSource));) {
let end = headers.lastIndex, depth = 1; let end = headers.lastIndex, depth = 1;
while (depth && end < protectedSource.length) { while (depth && end < protectedSource.length) {
@@ -24,6 +26,8 @@ function functions(source) {
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1])); const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost'); body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost');
body = body.replace(/player getGuid\(\)/g, 'getGuid(player)'); body = body.replace(/player getGuid\(\)/g, 'getGuid(player)');
body = body.replace(/#(?=__literal_)/g, '');
body = body.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::register_help_command/g, 'registerHelpCommand');
body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify('); body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify(');
body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];'); body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];');
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;'); body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;');
@@ -43,38 +47,70 @@ function engine(game, options = {}) {
const functionsByName = functions(source); const functionsByName = functions(source);
const player = { guid: '18446744073709551615', sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true }; const player = { guid: '18446744073709551615', sessionstate: 'playing', score: 3000, score_total: 4000, pers: {}, alive: true };
const entry = { player, active: true }; const entry = { player, active: true };
const saved = [], sent = [], messages = [], order = []; const recovered = options.recovered ?? [];
const saved = recovered.map(report => JSON.parse(report.body)), sent = [], messages = [], order = [], loaded = [], dvarsRead = [];
const dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars };
function assertScope(server, url) {
assert.equal(server, dvars.tws_server_id);
assert.equal(url, publicEndpoint);
}
const c = vm.createContext({ const c = vm.createContext({
level: { bank_server: 'server', bank_url: 'https://test/api/bank', bank_token: 'ec_bank_private', bank_queue: [], bank_read: 0, bank_write: 0, level: { telemetry_match: 'match', telemetry_started: true },
bank_busy: false, bank_ended: false, telemetry_match: 'match', telemetry_started: true }, GetDvar: name => { dvarsRead.push(name); return dvars[name] ?? ''; },
getSubStr: (value, start, end) => value.substring(start, end), registerHelpCommand: () => {},
isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive, isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive,
int: Math.trunc, getGuid: p => p.guid, int: Math.trunc, getGuid: p => p.guid,
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))), jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
bank_say: (_entry, message) => messages.push(message), bank_say: (_entry, message) => messages.push(message),
telemetryNewMatch: () => 'request-1', telemetryNewMatch: () => 'request-1',
bankStore: (_server, _url, body) => { 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); order.push(data.decision);
if (options.storageFailure === data.decision) return false; if (options.storageFailure === data.decision) return false;
saved.splice(0, saved.length, data); return true; saved.splice(0, saved.length, data); return true;
}, },
bankRemove: () => { if (options.removeFailure) return false; saved.length = 0; 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) => { order.push('http'); sent.push({ url, ...data }); return options.noHandle ? undefined : {}; },
httpComplete: () => { httpComplete: () => {
options.during?.(c, entry); options.during?.(c, entry);
const action = sent.at(-1).url.split('/').at(-1); const action = sent.at(-1).url.split('/').at(-1);
const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared'; 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' : 'request-1', amount: options.amount ?? 2000, balance: 5000, code: options.code ?? 'ok' }; const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : JSON.parse(sent.at(-1).body).request_id, amount: options.amount ?? 2000, balance: options.balance ?? 5000, code: options.code ?? 'ok' };
return [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }]; return [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 } : JSON.parse(data),
print: () => {}, println: () => {} print: () => {}, println: () => {}
}); });
vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c); vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
return { c, player, entry, saved, sent, messages, order, run: args => c.bank_transfer(entry,args) }; c.init();
return { c, player, entry, saved, sent, messages, order, loaded, dvarsRead, run: args => c.bank_transfer(entry,args) };
} }
let checks = 0; let checks = 0;
for (const game of ['t5','t6']) { for (const game of ['t5','t6']) {
function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } } function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } }
test('startup and every request ignore obsolete endpoint overrides and retain instance authentication', () => {
for (const url of ['', 'https://controller.internal/api/bank', 'http://old-controller/api/bank', 'https://untrusted.example/api/bank']) {
for (const command of [['.b'], ['.d','2000'], ['.w','all']]) {
const e = engine(game, { dvars: { tws_bank_url: url } });
e.run(command);
if (command[0] !== '.b') e.c.bank_sender().next();
assert.equal(e.loaded.length, 1);
assert(!e.dvarsRead.includes('tws_bank_url'));
assert.equal(e.sent[0].url, publicEndpoint + '/' + ({'.b':'balance','.d':'deposit','.w':'withdraw'}[command[0]]));
if (command[0] !== '.b') assert.equal(e.sent[1].url, publicEndpoint + '/commit');
for (const request of e.sent) {
assert.equal(request.headers.Authorization, 'Bearer ' + privateToken);
assert.equal(JSON.parse(request.body).server, 'server');
}
}
}
});
test('missing or invalid instance credentials and disabled banks do not load journals', () => {
for (const dvars of [{tws_server_id:''}, {tws_bank_token:''}, {tws_bank_token:'wrong_' + 'a'.repeat(43)}, {tws_bank_enabled:'0'}]) {
assert.equal(engine(game, {dvars}).loaded.length, 0);
}
});
test('strict positive integer syntax', () => { test('strict positive integer syntax', () => {
const {c} = engine(game); const {c} = engine(game);
for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0); for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0);
@@ -85,7 +121,7 @@ for (const game of ['t5','t6']) {
assert.equal(e.player.score,1000); assert.equal(e.player.score_total,4000); assert.equal(e.player.pers.score,1000); 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.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,e.player.guid);
assert.equal(e.sent[0].headers.Authorization,'Bearer ec_bank_private'); assert.equal(e.sent[0].headers.Authorization,'Bearer ' + privateToken);
assert.equal(e.sent[0].timeout_ms,15000); assert.equal(e.sent[0].timeout_ms,15000);
}); });
test('all captures deposit points and resolves withdrawal amount', () => { test('all captures deposit points and resolves withdrawal amount', () => {
@@ -121,6 +157,32 @@ for (const game of ['t5','t6']) {
test('balance reads do not journal or alter points', () => { test('balance reads do not journal or alter points', () => {
const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.match(e.messages[0],/5000/); const e=engine(game); e.run(['.b']); assert.equal(e.saved.length,0); assert.equal(e.player.score,3000); assert.match(e.messages[0],/5000/);
}); });
test('empty balance displays zero and withdrawing all reports insufficient balance', () => {
const e = engine(game, {amount:0, balance:0}); e.run(['.b']);
assert.equal(e.messages[0], '^3Bank: ^70 ^3points; available: ^70');
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
const w = engine(game, {amount:0, balance:0, state:'rejected', code:'insufficient_balance'}); w.run(['.w','all']);
assert.match(w.messages[0], /Not enough bank balance/);
assert.equal(w.player.score, 3000); assert.equal(w.saved[0].decision, 'cancel');
w.c.bank_sender().next();
assert.equal(w.sent[1].url, publicEndpoint + '/cancel'); assert.equal(w.saved.length, 0);
});
test('recovered commit and cancel decisions retry at the public endpoint without applying points', () => {
for (const decision of ['commit','cancel']) {
const body = JSON.stringify({server:'server', request_id:'recovered', decision});
const options = {timeout:true, dvars:{tws_bank_url:'https://controller.internal/api/bank'}, recovered:[{id:'recovered', body, decision}]};
const e = engine(game, options), sender = e.c.bank_sender();
sender.next();
assert.equal(e.saved.length, 1); assert.equal(e.player.score, 3000);
options.timeout = false; sender.next();
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 3000);
assert.equal(e.sent.length, 2);
for (const request of e.sent) {
assert.equal(request.url, publicEndpoint + '/' + decision);
assert.equal(request.body, body);
}
}
});
test('recovered settlements cannot reapply points and retry cleanup safely', () => { test('recovered settlements cannot reapply points and retry cleanup safely', () => {
const e=engine(game); e.run(['.w','2000']); const score=e.player.score; const e=engine(game); e.run(['.w','2000']); const score=e.player.score;
const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score); const sender=e.c.bank_sender(); sender.next(); assert.equal(e.saved.length,0); assert.equal(e.player.score,score);
+8
View File
@@ -81,6 +81,14 @@ int main(int argc, char** argv)
{"code","ok"},{"amount",2000},{"balance",3000}}; {"code","ok"},{"amount",2000},{"balance",3000}};
const auto ack = parse_acknowledgement(valid.dump()); const auto ack = parse_acknowledgement(valid.dump());
assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one"); assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one");
auto empty = valid;
empty["state"] = "balance"; empty["request_id"] = "";
empty["amount"] = 0; empty["balance"] = 0;
const auto zero = parse_acknowledgement(empty.dump());
assert(zero.valid && zero.amount == 0 && zero.balance == 0);
empty["ok"] = false; empty["state"] = "rejected";
empty["request_id"] = "one"; empty["code"] = "insufficient_balance";
assert(parse_acknowledgement(empty.dump()).valid);
for (const auto& field : {"amount","balance"}) for (const auto& field : {"amount","balance"})
for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)}) for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)})
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"tws_t5_afk.gsc": "1.0.0", "tws_t5_afk.gsc": "1.0.0",
"tws_t5_bank.gsc": "1.0.0", "tws_t5_bank.gsc": "2.0.0",
"tws_t5_common.gsc": "1.1.0", "tws_t5_common.gsc": "1.1.0",
"tws_t5_hud.gsc": "1.1.0", "tws_t5_hud.gsc": "1.1.0",
"tws_t5_join.gsc": "1.0.0", "tws_t5_join.gsc": "1.0.0",
@@ -8,7 +8,7 @@
"tws_t5_lock.gsc": "1.0.0", "tws_t5_lock.gsc": "1.0.0",
"tws_t5_telemetry.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "1.0.0", "tws_t6_afk.gsc": "1.0.0",
"tws_t6_bank.gsc": "1.0.0", "tws_t6_bank.gsc": "2.0.0",
"tws_t6_common.gsc": "1.1.0", "tws_t6_common.gsc": "1.1.0",
"tws_t6_hud.gsc": "1.1.0", "tws_t6_hud.gsc": "1.1.0",
"tws_t6_join.gsc": "1.0.1", "tws_t6_join.gsc": "1.0.1",