Add persistent cross-game player bank (Refs #3)

This commit is contained in:
2026-10-12 20:45:41 -04:00
parent eda2c7e76d
commit 9cce0be438
19 changed files with 1646 additions and 15 deletions
+136
View File
@@ -0,0 +1,136 @@
// Actual GSC transfer logic under portable VM/HTTP/disk shims; no game runtime.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
function functions(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const found = new Map();
const headers = /^(bank_\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(protectedSource));) {
let end = headers.lastIndex, depth = 1;
while (depth && end < protectedSource.length) {
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
assert.equal(depth, 0);
let body = protectedSource.slice(headers.lastIndex, end - 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(/player getGuid\(\)/g, 'getGuid(player)');
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\(([^;]+)\);/g, 'yield $1;');
body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;');
body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
body = body.replace(/player maps\\(?:mp\\zombies\\_zm_laststand|_laststand)::player_is_in_laststand\(\)/g, 'player.downed').replace(/wait ([^;]+);/g, 'yield $1;');
const generator = match[1] === 'bank_sender';
found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
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 };
const entry = { player, active: true };
const saved = [], sent = [], messages = [], order = [];
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,
bank_busy: false, bank_ended: false, telemetry_match: 'match', telemetry_started: true },
isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive,
int: Math.trunc, getGuid: p => p.guid,
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
bank_say: (_entry, message) => messages.push(message),
telemetryNewMatch: () => 'request-1',
bankStore: (_server, _url, body) => {
const data = JSON.parse(body); order.push(data.decision);
if (options.storageFailure === data.decision) return false;
saved.splice(0, saved.length, data); return true;
},
bankRemove: () => { if (options.removeFailure) return false; saved.length = 0; return true; },
httpPost: (url, data) => { 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);
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' };
return [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }];
},
bankResponse: data => options.malformed ? { valid: false } : JSON.parse(data),
print: () => {}, println: () => {}
});
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) };
}
let checks = 0;
for (const game of ['t5','t6']) {
function test(name, fn) { try { fn(); checks++; } catch (cause) { throw new Error(`${game}: ${name}`, {cause}); } }
test('strict positive integer syntax', () => {
const {c} = engine(game);
for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0);
assert.equal(c.bank_amount('2000'),2000); assert.equal(c.bank_amount('ALL'),0); assert.equal(c.bank_amount('all'),-1);
});
test('deposit journals cancellation then commit before moving points', () => {
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.sent[0].headers.Authorization,'Bearer ec_bank_private');
assert.equal(e.sent[0].timeout_ms,15000);
});
test('all captures deposit points and resolves withdrawal amount', () => {
const e=engine(game,{amount:3000}); e.run(['.d','all']); assert.equal(e.player.score,0);
assert.equal(JSON.parse(e.sent[0].body).amount,3000);
const w=engine(game); w.run(['.w','all']); assert.equal(w.player.score,5000);
assert.equal(JSON.parse(w.sent[0].body).amount,'all');
});
test('failure responses never change points and retain cancellation', () => {
for (const options of [{timeout:true},{noHandle:true},{status:503},{status:401},{status:429},{missingDiagnostics:true},{malformed:true},{wrongId:true},{state:'rejected',code:'insufficient_balance'}]) {
const e=engine(game,options); e.run(['.w','2000']); assert.equal(e.player.score,3000);
assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.c.level.bank_queue[0].decision,'cancel');
}
});
test('storage failures never send unsaved work or move points', () => {
for (const storageFailure of ['cancel','commit']) {
const e=engine(game,{storageFailure}); e.run(['.d','2000']); assert.equal(e.player.score,3000);
if(storageFailure==='cancel') assert.equal(e.sent.length,0); else assert.equal(e.saved[0].decision,'cancel');
}
});
test('spending, death, disconnect, map end and point cap changes cancel prepared work', () => {
for(const during of [(_c,e)=>{e.player.score=100;},(_c,e)=>{e.player.alive=false;},(_c,e)=>{e.active=false;},(c)=>{c.level.bank_ended=true;},(_c,e)=>{e.player.downed=true;}]) {
const e=engine(game,{during}); e.run(['.d','2000']); assert.equal(e.saved[0].decision,'cancel');
}
const e=engine(game,{during:(_c,e)=>{e.player.score=999999;}}); e.run(['.w','2000']); assert.equal(e.saved[0].decision,'cancel'); assert.equal(e.player.score,999999);
});
test('invalid requests and unavailable match fail before HTTP', () => {
for(const args of [['.d'],['.w','1000001'],['.d','4000'],['.b','2000'],['.w','1000000']]) {
const e=engine(game); e.run(args); assert.equal(e.sent.length,0); assert.equal(e.player.score,3000);
}
const e=engine(game); e.c.level.telemetry_match=undefined; e.run(['.d','all']); assert.equal(e.sent.length,0);
});
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/);
});
test('recovered settlements cannot reapply points and retry cleanup safely', () => {
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 failed=engine(game,{removeFailure:true}); failed.run(['.d','2000']); const gen=failed.c.bank_sender(); gen.next(); gen.next();
assert.equal(failed.player.score,1000); assert.equal(failed.saved[0].decision,'commit');
});
test('conflicting settlement states retain their journal', () => {
const {c}=engine(game);
assert(!c.bank_resolved('commit','cancelled')); assert(!c.bank_resolved('cancel','committed'));
assert(c.bank_resolved('cancel','rejected')); assert(c.bank_resolved('commit','committed'));
});
}
console.log(`PASS ${checks} bank script fixtures (T5 and T6); live VM checks still required.`);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Test native bank storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-bank-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/bank.cpp"), str(ROOT / "tools/tests/bank_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/bank.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n' + component)
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsyntax-only",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source)], check=True)
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+105
View File
@@ -0,0 +1,105 @@
#include "bank.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
namespace fs = std::filesystem;
using utils::bank::outbox;
using utils::bank::parse_acknowledgement;
const std::string server = "test-server", endpoint = "https://example.test/api/bank";
std::string body(const std::string& id, const std::string& decision)
{
return nlohmann::json{{"server",server},{"guid","18446744073709551615"},
{"match","match"},{"request_id",id},{"operation","deposit"},
{"amount",2000},{"capacity",1000000},{"decision",decision}}.dump();
}
template <typename F> void must_fail(F action)
{
bool failed = false;
try { action(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const outbox queue(root,server,endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
queue.store(body("uncertain-prepare","cancel"));
queue.store(body("applied-points","cancel"));
queue.store(body("applied-points","commit"));
}
else if (mode == "recover")
{
const auto entries = queue.load();
assert(entries.size() == 2);
assert(entries[0].decision == "cancel" && entries[1].decision == "commit");
for (const auto& entry : entries) queue.erase(entry.id);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
assert(queue.load().empty());
queue.store(body("one","cancel"));
queue.store(body("one","cancel"));
assert(queue.load().size() == 1);
queue.store(body("one","commit"));
queue.store(body("one","commit"));
assert(queue.load()[0].decision == "commit");
must_fail([&]{queue.store(body("one","cancel"));});
auto changed = nlohmann::json::parse(body("one","commit"));
changed["amount"] = 100;
must_fail([&]{queue.store(changed.dump());});
assert(queue.load()[0].body == body("one","commit"));
const outbox isolated(root,"another-server",endpoint);
assert(isolated.load().empty());
const outbox another_endpoint(root,server,endpoint+"/other");
assert(another_endpoint.load().empty());
// An interrupted temp write cannot replace the committed decision.
const auto file = fs::directory_iterator(root)->path();
{ std::ofstream stream(file.string()+".tmp"); stream << "incomplete"; }
assert(queue.load()[0].decision == "commit");
fs::remove(file.string()+".tmp");
// A failed replacement preserves cancellation and must stop points moving.
queue.erase("one");
queue.store(body("two","cancel"));
fs::create_directory(file.string()+".tmp");
must_fail([&]{queue.store(body("two","commit"));});
assert(queue.load()[0].decision == "cancel");
fs::remove(file.string()+".tmp");
queue.erase("two");
queue.erase("two");
assert(queue.load().empty());
auto valid = nlohmann::json{{"ok",true},{"state","prepared"},{"request_id","one"},
{"code","ok"},{"amount",2000},{"balance",3000}};
const auto ack = parse_acknowledgement(valid.dump());
assert(ack.valid && ack.amount == 2000 && ack.balance == 3000 && ack.id == "one");
for (const auto& field : {"amount","balance"})
for (const auto& bad : {nlohmann::json(-1),nlohmann::json(1.5),nlohmann::json("100"),nlohmann::json(1000001)})
{
auto value = valid; value[field] = bad;
assert(!parse_acknowledgement(value.dump()).valid);
}
auto bad = valid; bad["ok"] = false;
assert(!parse_acknowledgement(bad.dump()).valid);
bad = valid; bad["request_id"] = "";
assert(!parse_acknowledgement(bad.dump()).valid);
bad = valid; bad["state"] = "unknown";
assert(!parse_acknowledgement(bad.dump()).valid);
for (const auto& text : {"", "<html>bad gateway</html>", "{", "null", "{\"ok\":true}"})
assert(!parse_acknowledgement(text).valid);
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
{ std::ofstream stream(file); stream << "corrupt"; }
must_fail([&]{queue.load();});
must_fail([&]{queue.store(body("three","cancel"));});
must_fail([&]{queue.erase("three");});
fs::remove(file);
std::cout << "bank decisions, corruption, cancellation, response guards passed\n";
}