Add persistent cross-game player bank (Refs #3)
This commit is contained in:
@@ -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.`);
|
||||
@@ -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)
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user