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
+18 -8
View File
@@ -2,7 +2,8 @@
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
just like T5. The telemetry token is embedded in the reporter script; no
separate token config or database setup is required.
separate telemetry token config is required. The optional bank needs its own
private server token.
| File | Role | Dependencies |
| --- | --- | --- |
@@ -13,12 +14,13 @@ separate token config or database setup is required.
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.1.0 |
## Install
1. Copy all seven `tws_t6_*.gsc` files into
1. Copy all eight `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard.
2. For telemetry or leaderboard, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. The other five scripts
do not require that plugin.
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
@@ -29,10 +31,10 @@ If upgrading from the earlier leaderboard filename, **remove
`tws_leaderboard.gsc` from the T6 server script directory** before installing
`tws_t6_leaderboard.gsc`, so both copies do not load.
If updating an existing deployment, **remove `tws_t6_db.gsc` and
`tws_t6_bank.gsc` from the server script directory**. Copying the remaining files
will not remove those old scripts. Remove any `exec server.example.cfg` added for
the earlier version. Neither a database nor that config file is used anymore.
If upgrading from the old direct-database bank, remove `tws_t6_db.gsc` and
replace the old `tws_t6_bank.gsc` with this release's file. Remove any
`exec server.example.cfg` added for that legacy version; configure the new
Electric Cherry bank using the private per-server token described below.
Source `.gsc` files load directly under Plutonium's
[documented T6 script layout](https://plutonium.pw/docs/modding/loading-mods/).
@@ -40,6 +42,14 @@ Keep the filenames and `scripts/zm` location so shared references resolve.
To omit telemetry, remove its script; disabling it at runtime does not remove
its plugin dependency during linking. The same rule applies to leaderboard.
## Player bank
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 and a map restart for scripts.
## Persistent Discord text
The HUD shows `https://discord.thewrightserver.net` in small light-gray text
@@ -184,7 +194,7 @@ another mod installed one first.
## Validation
To compile all seven scripts for T6 PC with gsc-tool:
To compile all eight scripts for T6 PC with gsc-tool:
```powershell
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
+3 -3
View File
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the seven TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard.' }
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' }
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
foreach ($file in $files) {
@@ -32,4 +32,4 @@ foreach ($file in $files) {
}
Write-Output "PASS $($file.Name)"
}
Write-Output 'All seven T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
Write-Output 'All eight T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
+328
View File
@@ -0,0 +1,328 @@
/* tws_t6_bank.gsc - shared GUID bank, with durable transfer decisions. */
#include maps\mp\_utility;
#include common_scripts\utility;
main()
{
}
init()
{
if (GetDvar("tws_bank_enabled") == "0")
return;
level.bank_url = GetDvar("tws_bank_url");
if (level.bank_url == "")
level.bank_url = "https://ec.thewrightserver.net/api/bank";
level.bank_server = GetDvar("tws_server_id");
level.bank_token = GetDvar("tws_bank_token");
// A private per-server cfg supplies this; never embed bank credentials.
if (level.bank_server == "" || level.bank_token.size < 40 ||
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");
return;
}
level.bank_queue = bankLoad(level.bank_server, level.bank_url);
if (!isDefined(level.bank_queue))
return;
level.bank_read = 0;
level.bank_write = level.bank_queue.size;
level.bank_busy = false;
level.bank_ended = false;
scripts\zm\tws_t6_common::register_help_command(".b", "Show bank balance");
scripts\zm\tws_t6_common::register_help_command(".d <amount|all>", "Deposit points");
scripts\zm\tws_t6_common::register_help_command(".w <amount|all>", "Withdraw points");
level thread bank_end_watcher();
level thread bank_sender();
level thread bank_chat_listener();
}
bank_end_watcher()
{
level waittill_any("end_game", "game_ended", "intermission");
level.bank_ended = true;
}
bank_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
args = strTok(toLower(message), " ");
if (args.size == 0 || (args[0] != ".b" && args[0] != ".d" && args[0] != ".w"))
continue;
if (level.bank_busy || level.bank_read < level.bank_write)
{
player iPrintLnBold("^3Bank is finishing a transfer. Please try again shortly.");
continue;
}
level.bank_busy = true;
level thread bank_request(player, args);
}
}
bank_amount(text)
{
if (text == "all")
return -1;
if (text.size == 0 || text.size > 7)
return 0;
digits = "0123456789";
for (i = 0; i < text.size; i++)
{
found = false;
for (j = 0; j < digits.size; j++)
if (text[i] == digits[j])
found = true;
if (!found)
return 0;
}
amount = int(text);
if (amount < 1 || amount > 1000000)
return 0;
return amount;
}
bank_playing(player)
{
return !level.bank_ended && !is_true(level.intermission) &&
isDefined(player) && isDefined(player.sessionstate) &&
player.sessionstate == "playing" && isAlive(player) &&
!(player maps\mp\zombies\_zm_laststand::player_is_in_laststand()) &&
isDefined(player.score) && player.score >= 0;
}
// Keep the worker alive through disconnect/end so an uncertain prepare cancels.
bank_guard(entry)
{
self endon("tws_bank_finished");
self waittill_any("disconnect", "killed_player", "spawned_player");
entry.active = false;
}
bank_say(entry, text)
{
if (entry.active && isDefined(entry.player))
entry.player iPrintLnBold(text);
}
bank_request(player, args)
{
entry = spawnStruct();
entry.player = player;
entry.active = true;
player thread bank_guard(entry);
bank_transfer(entry, args);
if (entry.active && isDefined(player))
player notify("tws_bank_finished");
level.bank_busy = false;
}
bank_transfer(entry, args)
{
player = entry.player;
data = [];
data["server"] = level.bank_server;
data["guid"] = player getGuid();
if (args[0] == ".b")
{
if (args.size != 1)
{
bank_say(entry, "^3Usage: .b");
return;
}
reply = bank_http("balance", jsonSerialize(data), "");
if (!isDefined(reply) || reply["state"] != "balance")
bank_say(entry, "^3Bank unavailable. Your points are unchanged.");
else
bank_say(entry, "^3Bank: ^7" + reply["balance"] + " ^3points; available: ^7" + reply["amount"]);
return;
}
if (args.size != 2)
{
bank_say(entry, "^3Usage: .d <amount|all> or .w <amount|all>");
return;
}
amount = bank_amount(args[1]);
if (amount == 0)
{
bank_say(entry, "^3Use a whole number from 1 to 1000000, or all.");
return;
}
if (!bank_playing(player) || !isDefined(level.telemetry_match) ||
!is_true(level.telemetry_started) || is_true(level.telemetry_ended))
{
bank_say(entry, "^3Bank transfers require a living player in a reported match.");
return;
}
operation = "withdraw";
if (args[0] == ".d")
{
operation = "deposit";
if (amount == -1)
amount = int(player.score);
if (amount <= 0 || amount > 1000000 || player.score < amount)
{
bank_say(entry, "^3Not enough spendable points, or deposit exceeds 1000000.");
return;
}
}
capacity = 1000000 - int(player.score);
if (capacity < 0)
capacity = 0;
if (operation == "withdraw" && amount != -1 && amount > capacity)
{
bank_say(entry, "^3Withdrawal would exceed the 1000000-point limit.");
return;
}
id = telemetryNewMatch();
if (!isDefined(id))
{
bank_say(entry, "^3Bank identity unavailable. Your points are unchanged.");
return;
}
data["request_id"] = id;
data["match"] = level.telemetry_match;
data["operation"] = operation;
data["amount"] = amount;
if (amount == -1)
data["amount"] = "all";
data["capacity"] = capacity;
data["decision"] = "cancel";
body = jsonSerialize(data);
// Persist cancellation BEFORE prepare can reach EC, including lost replies.
if (!bankStore(level.bank_server, level.bank_url, body))
{
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
return;
}
reply = bank_http(operation, body, id);
if (isDefined(reply) && reply["state"] == "prepared")
{
transfer = reply["amount"];
if (entry.active && bank_playing(player) &&
(amount == -1 || amount == transfer) &&
((operation == "deposit" && player.score >= transfer) ||
(operation == "withdraw" && player.score <= 1000000 - transfer)))
{
data["decision"] = "commit";
commit_body = jsonSerialize(data);
// No yield between recording the decision and updating the balance.
// Do not change score_total: bank funds are not earned match points.
if (bankStore(level.bank_server, level.bank_url, commit_body))
{
if (operation == "deposit")
player.score -= transfer;
else
player.score += transfer;
player.pers["score"] = player.score;
body = commit_body;
bank_say(entry, "^3Bank " + operation + ": ^7" + transfer + " ^3points. Saving transfer.");
}
else
{
data["decision"] = "cancel";
bank_say(entry, "^3Bank storage unavailable. Your points are unchanged.");
}
}
else
bank_say(entry, "^3Transfer cancelled: player state or points changed.");
}
else if (isDefined(reply) && reply["state"] == "rejected")
bank_say(entry, bank_error(reply["code"]));
else
bank_say(entry, "^3Bank unavailable. Points unchanged; cancelling the pending transfer.");
pending = [];
pending["id"] = id;
pending["body"] = body;
pending["decision"] = data["decision"];
level.bank_queue[level.bank_write] = pending;
level.bank_write++;
}
bank_error(code)
{
if (code == "insufficient_balance" || code == "empty")
return "^3Not enough bank balance. Your points are unchanged.";
if (code == "bank_limit")
return "^3Deposit exceeds the bank balance limit. Your points are unchanged.";
if (code == "points_limit")
return "^3Withdrawal exceeds the points limit. Your points are unchanged.";
if (code == "not_in_match")
return "^3Waiting for your match telemetry. Try again shortly; points unchanged.";
if (code == "pending")
return "^3A bank transfer is pending on another server. Try again shortly.";
return "^3Bank rejected the transfer. Your points are unchanged.";
}
bank_http(action, body, id)
{
headers = [];
headers["Content-Type"] = "application/json";
headers["Authorization"] = "Bearer " + level.bank_token;
options = [];
options["method"] = "POST";
options["headers"] = headers;
options["body"] = body;
options["connect_timeout_ms"] = 5000;
options["timeout_ms"] = 15000;
request = http::request(level.bank_url + "/" + action, options);
if (!isDefined(request))
return undefined;
request waittill("done", response, success, error, diagnostics);
if (!isDefined(success) || !success || !isDefined(response) ||
!isDefined(diagnostics) || !isDefined(diagnostics["http_status"]) ||
diagnostics["http_status"] != 200)
return undefined;
reply = bankResponse(response);
if (!reply["valid"] || reply["request_id"] != id)
return undefined;
return reply;
}
bank_resolved(decision, state)
{
if (decision == "commit")
return state == "committed";
return state == "cancelled" || state == "rejected";
}
bank_sender()
{
delay = 1;
for (;;)
{
if (level.bank_busy || level.bank_read >= level.bank_write)
{
wait 0.1;
continue;
}
entry = level.bank_queue[level.bank_read];
reply = bank_http(entry["decision"], entry["body"], entry["id"]);
if (isDefined(reply) && bank_resolved(entry["decision"], reply["state"]))
{
// Cleanup failures never reapply points; settlement is idempotent.
if (bankRemove(level.bank_server, level.bank_url, entry["id"]))
{
level.bank_queue[level.bank_read] = undefined;
level.bank_read++;
if (level.bank_read == level.bank_write)
{
level.bank_queue = [];
level.bank_read = 0;
level.bank_write = 0;
}
delay = 1;
continue;
}
}
println("^1[tws_bank] Transfer settlement pending; retaining durable decision and retrying");
wait delay;
delay *= 2;
if (delay > 30)
delay = 30;
}
}