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

This commit is contained in:
2026-10-12 20:21:58 -04:00
parent bcd3b99ee4
commit e071e5b574
14 changed files with 866 additions and 1 deletions
+4
View File
@@ -7,6 +7,10 @@ EC_SETUP_TOKEN=
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token. # Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
# Telemetry ingestion is disabled until a token of at least 32 chars is set. # Telemetry ingestion is disabled until a token of at least 32 chars is set.
EC_TELEMETRY_TOKEN= EC_TELEMETRY_TOKEN=
# Unique per-server bank secrets: JSON object mapping exact server names to
# ec_bank_<32+ random URL-safe characters>. Never reuse the telemetry secret.
EC_BANK_TOKENS={}
EC_BANK_MAX_BALANCE=250000
# Persistent SQLite metadata, game-file storage, and beta archives. # Persistent SQLite metadata, game-file storage, and beta archives.
# For an existing deployment, retain its original data directory. # For an existing deployment, retain its original data directory.
EC_DATA_DIR=/root/zombies/.electric-cherry EC_DATA_DIR=/root/zombies/.electric-cherry
+2
View File
@@ -15,6 +15,8 @@ services:
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000} ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-} EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_BANK_TOKENS: ${EC_BANK_TOKENS:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Legacy host imports stream whole game files; buffering routes enforce their own caps. # Legacy host imports stream whole game files; buffering routes enforce their own caps.
BODY_SIZE_LIMIT: 'Infinity' BODY_SIZE_LIMIT: 'Infinity'
+1
View File
@@ -25,6 +25,7 @@ and launch your first server.
- [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes. - [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes.
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
## Development ## Development
+145
View File
@@ -0,0 +1,145 @@
# Player bank
The bank stores one points balance per exact GUID across T5 and T6. Players use
`.b`, `.d <amount|all>` and `.w <amount|all>` with the TWS bank scripts. Deposits
move spendable match points into the bank; withdrawals return bank points to
the current match. Neither changes earned `score_total`. Bank-assisted games
still count toward the existing leaderboards; the ledger records their match
IDs. This release does not introduce separate bank-assisted rankings.
## Setup and rollout
1. Back up the controller database. Deploy Electric Cherry first and restart
the controller. Migration 20 creates accounts, transfer requests and a ledger.
2. Generate a **different** secret for each registered game server, for example
`ec_bank_` followed by `openssl rand -hex 32`. Configure `EC_BANK_TOKENS` as a
JSON object mapping exact server names to these secrets. Docker Compose
forwards this environment variable. An example shape with placeholders is
`{"t5_zombie_theater_1":"ec_bank_<random-secret>"}`.
3. Set `EC_BANK_MAX_BALANCE` (default 250000, range 1–1000000). Invalid configuration
refuses new deposits. A limit reduction does not confiscate existing balances
or invalidate already prepared deposits; withdrawals remain available.
4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart
the **whole game-server process**. Install `tws_t5_bank.gsc` or
`tws_t6_bank.gsc` 1.0.0, the game's common module (1.0.0+) and telemetry
reporter (1.0.0+). Restart the map after installing scripts/configuration.
5. In each server's private configuration, set `tws_server_id` to its registered
name and `tws_bank_token` to that server's secret. The optional `tws_bank_url`
defaults to `https://ec.thewrightserver.net/api/bank`; overrides must use
HTTPS. Keep these settings out of shared scripts and public config downloads.
Bank tokens must match `ec_bank_` followed by 32–128 URL-safe letters, digits,
underscores or hyphens. Missing, malformed, duplicate, or telemetry-reused tokens
disable authentication. `EC_TELEMETRY_TOKEN`, host tokens and browser sessions
cannot authorize bank requests. The credential determines the server, and a
different `server` in the body is refused. Rotate a token by updating both the
controller mapping and that server's private configuration, then restart them.
Keep the same server name and bank URL so existing decisions can finish.
## Machine API
All routes below are `POST /api/bank/<action>`, with `Content-Type:
application/json` and `Authorization: Bearer <server-bank-token>`. Responses
are never cached. Bodies are capped at 4096 bytes. GUIDs and request IDs stay
strings; never convert them to numbers.
| Action | Body | Meaning |
| ---------- | ------------------------- | -------------------------------------------------------------------- |
| `balance` | `server`, `guid` | Read the shared balance and currently available points |
| `deposit` | Transfer fields below | Reserve deposit capacity without crediting the account yet |
| `withdraw` | Transfer fields below | Reserve bank funds without debiting the account yet |
| `commit` | Identical transfer fields | Apply a prepared transfer once and append its ledger entry |
| `cancel` | Identical transfer fields | Release a reservation; also prevents a delayed prepare from applying |
Transfer example:
```json
{
"server": "t5_zombie_theater_1",
"guid": "18446744073709551615",
"match": "reporter-match-key",
"request_id": "unique-process-token-17",
"operation": "deposit",
"amount": 2000,
"capacity": 995000
}
```
`match` is the reporter's telemetry match key, **not** Electric Cherry's numeric
match ID. `request_id` is unique for the server and at most 64 characters.
`amount` is a positive integer up to 1000000; withdrawal also accepts `"all"`.
The script resolves deposit `all` to its captured spendable points. `capacity`
is the current room below the game's bank withdrawal cap of 1000000 points.
An `all` withdrawal that exceeds this room is refused, rather than truncated.
Preparation requires the GUID to be present in the server's latest active match,
with the exact telemetry match key. Late joiners may need to wait for the next
30-second heartbeat. Unknown/destroyed servers, absent players and old matches
cannot prepare transfers. Settlement of an existing decision remains possible
after match completion, retention or server deletion.
Successful application responses include `ok`, `state`, `code`, `request_id`,
`amount` and `balance`. States are `balance`, `prepared`, `committed`, `cancelled`
or `rejected`. For balance reads, `amount` is the available balance. A business
rejection uses HTTP 200 with `ok: false`, `state: "rejected"` and a code such as
`insufficient_balance`, `empty`, `bank_limit`, `points_limit`, `not_in_match` or
`pending`. It never changes points. Authentication failures use 401/403,
malformed input 400/413/415, identity conflicts 409, and unavailable service 503.
Repeated requests return the saved result/state without applying a second
change. Settled IDs never reopen; a committed result retains its original
resulting balance even if later transfers change the account. Reusing an ID
with different transfer fields returns 409. A GUID has at most one prepared
transfer across all servers. SQLite transactions serialize checks, account
updates and ledger insertion, so concurrent requests cannot overspend funds or
deposit past the configured maximum.
## Lost responses and recovery
The game saves a cancellation decision **before** sending a prepare request.
Only a validated prepared reply lets it recheck the player's life, current
spendable points and withdrawal room. It then records a commit decision and
updates spendable points without yielding. The sender retries settlement with
the original identity, at delays of 1, 2, 4, 8, 16, then 30 seconds. Invalid,
truncated or uncorrelated replies never authorize points changes.
A failed/uncertain prepare leaves game points unchanged and queues cancellation.
Cancellation can arrive first: its tombstone blocks the delayed prepare. Death,
respawn, disconnect or match end before application also cancels. After a commit
decision, retries only finish the ledger; they never change game points again.
These points now belong to the current match, so an applied withdrawal is not
automatically refunded if that match ends or its server crashes.
Keep `fs_homepath/tws-bank/t5/` or `t6/` on persistent writable storage with a
single process per server/endpoint. Journals contain transfer data, not secrets.
On a map/process restart, saved decisions settle before new bank commands.
Unreadable storage disables the bank; failed writes prevent points changes.
Pending decisions remain until acknowledged, even on credential errors. Bank
holds do not expire automatically, because expiring a saved commit can lose a
deposit or create a free withdrawal. Restore connectivity/configuration and the
original journal to resolve holds; never delete a pending journal to unblock
the bank. If storage is permanently lost, reconcile against the retained
request and ledger records before any manual repair.
The journal provides recovery from map/process restart. It cannot make live
game memory and SQLite one atomic transaction or preserve match points after a
machine/storage failure. Back up persistent storage and verify crash behavior
in the actual Windows/Plutonium deployment before rollout.
## History and validation
Player profiles show the shared balance, pending status and 20 newest ledger
entries, including signed amounts, resulting balances, server, time and match.
Accounts, request IDs and ledger entries have no cascading match/server foreign
keys and are not deleted by match retention or server deletion. Retained IDs
also keep old retries idempotent. Expired match links become plain historical
IDs. Include these tables in normal database backups.
Run `npx vitest run tests/unit/bank.test.ts`, then the usual lint, type checks,
tests and build. TWS supplies `node tools/check_bank.mjs` and
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
telemetry, `.help`, score HUD updates, death/respawn/disconnect during requests,
lost prepare/commit replies, controller outage, disk failures and map/process
restart with pending work. Confirm earned score and leaderboard behavior, and
verify that another server's credential and the telemetry credential fail.
+1
View File
@@ -37,6 +37,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin'; return 'admin';
if (within(routeId, '/api/agent')) return 'machine'; if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public'; if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
+41
View File
@@ -0,0 +1,41 @@
import { createHash, timingSafeEqual } from 'node:crypto';
const digest = (value: string) => createHash('sha256').update(value).digest();
const valid = (value: unknown): value is string =>
typeof value === 'string' && /^ec_bank_[A-Za-z0-9_-]{32,128}$/.test(value);
/** Dedicated, unique credentials; telemetry and browser credentials never authorize spending. */
export function authenticateBank(
request: Request
): { ok: true; server: string } | { ok: false; status: 401 | 503; code: string } {
let entries: [string, string][];
try {
const config: unknown = JSON.parse(process.env.EC_BANK_TOKENS ?? '{}');
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error();
const values = Object.entries(config);
if (
!values.length ||
values.some(
([server, token]) =>
!server || !valid(token) || token === process.env.EC_TELEMETRY_TOKEN
)
)
throw new Error();
entries = values as [string, string][];
if (new Set(entries.map(([, token]) => token)).size !== entries.length) throw new Error();
} catch {
return { ok: false, status: 503, code: 'unconfigured' };
}
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_bank_[A-Za-z0-9_-]{32,128})$/i)?.[1];
if (!token) return { ok: false, status: 401, code: 'unauthorized' };
const supplied = digest(token);
let matched: string | undefined;
for (const [server, expected] of entries) {
if (timingSafeEqual(supplied, digest(expected))) matched = server;
}
return matched
? { ok: true, server: matched }
: { ok: false, status: 401, code: 'unauthorized' };
}
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** No match/server foreign keys: money and idempotency outlive fleet/history cleanup. */
export function migrateBank(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS bank_accounts (
guid TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0 CHECK (typeof(balance) = 'integer' AND balance BETWEEN 0 AND 1000000)
);
CREATE TABLE IF NOT EXISTS bank_requests (
server TEXT NOT NULL, request_id TEXT NOT NULL, guid TEXT NOT NULL,
fingerprint TEXT NOT NULL, operation TEXT NOT NULL CHECK (operation IN ('deposit','withdraw')),
match_id INTEGER, amount INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL CHECK (state IN ('prepared','committed','cancelled','rejected')),
result TEXT NOT NULL, created_at TEXT NOT NULL, settled_at TEXT,
PRIMARY KEY(server, request_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS bank_pending_guid ON bank_requests(guid) WHERE state = 'prepared';
CREATE TABLE IF NOT EXISTS bank_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL,
server TEXT NOT NULL, request_id TEXT NOT NULL, match_id INTEGER,
amount INTEGER NOT NULL, balance INTEGER NOT NULL, created_at TEXT NOT NULL,
UNIQUE(server, request_id)
);
CREATE INDEX IF NOT EXISTS bank_ledger_guid ON bank_ledger(guid, id DESC);
`);
}
+3 -1
View File
@@ -18,6 +18,7 @@ import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js'; import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an // Append only: never renumber entries or change an applied migration to add an
@@ -109,7 +110,8 @@ export const migrations: readonly Migration[] = [
version: 19, version: 19,
name: 'cumulative player score per match', name: 'cumulative player score per match',
run: migratePlayerScoreTotal run: migratePlayerScoreTotal
} },
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank }
]; ];
export const databaseVersion = migrations.length; export const databaseVersion = migrations.length;
+232
View File
@@ -0,0 +1,232 @@
import { db } from '../db/index.js';
export const BANK_HARD_LIMIT = 1_000_000;
export type BankAction = 'balance' | 'deposit' | 'withdraw' | 'commit' | 'cancel';
interface Transfer {
server: string;
guid: string;
match: string;
request_id: string;
operation: 'deposit' | 'withdraw';
amount: number | 'all';
capacity: number;
}
interface Result {
ok: boolean;
state: string;
code: string;
request_id: string;
amount: number;
balance: number;
}
interface Saved {
fingerprint: string;
state: string;
amount: number;
match_id: number | null;
result: string;
}
const token = (value: unknown, limit: number): value is string =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= limit &&
/^[A-Za-z0-9_.:-]+$/.test(value);
const integer = (value: unknown, min = 0): value is number =>
Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= BANK_HARD_LIMIT;
export function bankMaximum(): number {
const value = Number(process.env.EC_BANK_MAX_BALANCE ?? 250_000);
if (!integer(value, 1))
throw new Error('EC_BANK_MAX_BALANCE must be an integer from 1 to 1000000');
return value;
}
export function bankProfile(guid: string) {
const handle = db();
const account = handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as
{ balance: number } | undefined;
const pending = handle
.prepare(
"SELECT operation, amount FROM bank_requests WHERE guid = ? AND state = 'prepared'"
)
.get(guid) as { operation: string; amount: number } | undefined;
const entries = handle
.prepare(
`SELECT l.id, l.server, l.amount, l.balance, l.created_at AS createdAt,
l.match_id AS matchId, CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable
FROM bank_ledger l LEFT JOIN leaderboard_matches m ON m.id = l.match_id
WHERE l.guid = ? ORDER BY l.id DESC LIMIT 20`
)
.all(guid) as {
id: number;
server: string;
amount: number;
balance: number;
createdAt: string;
matchId: number | null;
matchAvailable: number;
}[];
const balance = account?.balance ?? 0;
return {
balance,
available: balance - (pending?.operation === 'withdraw' ? pending.amount : 0),
pending: Boolean(pending),
entries
};
}
/** One immediate SQLite transaction serializes limits, reservations and ledger updates. */
export function bankRequest(
action: BankAction,
value: unknown,
authenticatedServer: string
): { status: number; body: Result } {
const result = (status: number, code: string, request_id = '') => ({
status,
body: { ok: false, state: 'rejected', code, request_id, amount: 0, balance: 0 }
});
if (!value || typeof value !== 'object' || Array.isArray(value))
return result(400, 'invalid_request');
const input = value as Transfer;
if (input.server !== authenticatedServer) return result(403, 'wrong_server');
if (!token(input.guid, 64)) return result(400, 'invalid_request');
if (action === 'balance') {
const profile = bankProfile(input.guid);
return {
status: 200,
body: {
ok: true,
state: 'balance',
code: profile.pending ? 'pending' : 'ok',
request_id: '',
amount: profile.available,
balance: profile.balance
}
};
}
if (
!token(input.request_id, 64) ||
!token(input.match, 64) ||
!['deposit', 'withdraw'].includes(input.operation) ||
!(integer(input.amount, 1) || (input.operation === 'withdraw' && input.amount === 'all')) ||
!integer(input.capacity) ||
((action === 'deposit' || action === 'withdraw') && action !== input.operation)
)
return result(400, 'invalid_request');
const { server, guid, request_id, operation, match, amount, capacity } = input;
const fingerprint = JSON.stringify({ guid, operation, match, amount, capacity });
const handle = db();
return handle
.transaction(() => {
const saved = handle
.prepare('SELECT * FROM bank_requests WHERE server = ? AND request_id = ?')
.get(server, request_id) as Saved | undefined;
if (saved && saved.fingerprint !== fingerprint)
return result(409, 'request_conflict', request_id);
const balance =
(
handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as
{ balance: number } | undefined
)?.balance ?? 0;
const now = new Date().toISOString();
const reply = (
state: string,
code: string,
transferred = 0,
resulting = balance
): Result => ({
ok: state !== 'rejected',
state,
code,
request_id,
amount: transferred,
balance: resulting
});
const insert = (body: Result, matchId: number | null = null) => {
handle
.prepare(
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`
)
.run(
server,
request_id,
guid,
fingerprint,
operation,
matchId,
body.amount,
body.state,
JSON.stringify(body),
now
);
return { status: 200, body };
};
if (action === 'cancel') {
// Tombstones win even when an earlier prepare is still in transit.
if (!saved) return insert(reply('cancelled', 'cancelled'));
if (saved.state !== 'prepared')
return { status: 200, body: JSON.parse(saved.result) as Result };
const body = reply('cancelled', 'cancelled');
handle
.prepare(
'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?'
)
.run(body.state, JSON.stringify(body), now, server, request_id);
return { status: 200, body };
}
if (action === 'commit') {
if (!saved) return result(409, 'not_prepared', request_id);
if (saved.state !== 'prepared')
return { status: 200, body: JSON.parse(saved.result) as Result };
const delta = operation === 'deposit' ? saved.amount : -saved.amount;
const resulting = balance + delta;
handle
.prepare(
`INSERT INTO bank_accounts(guid,balance) VALUES (?,?) ON CONFLICT(guid) DO UPDATE SET balance = excluded.balance`
)
.run(guid, resulting);
handle
.prepare(
`INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)`
)
.run(guid, server, request_id, saved.match_id, delta, resulting, now);
const body = reply('committed', 'ok', saved.amount, resulting);
handle
.prepare(
'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?'
)
.run(body.state, JSON.stringify(body), now, server, request_id);
return { status: 200, body };
}
if (saved) return { status: 200, body: JSON.parse(saved.result) as Result };
const member = handle
.prepare(
`SELECT m.id FROM leaderboard_matches m
JOIN servers s ON s.name = m.server AND s.destroyed_at IS NULL
JOIN leaderboard_match_players p ON p.match_id = m.id AND p.guid = ? AND p.present = 1
WHERE m.server = ? AND m.reporter_match_key = ? AND m.status = 'active'
AND m.id = (SELECT MAX(id) FROM leaderboard_matches WHERE server = ?)`
)
.get(guid, server, match, server) as { id: number } | undefined;
if (!member) return insert(reply('rejected', 'not_in_match'));
if (
handle
.prepare("SELECT 1 FROM bank_requests WHERE guid = ? AND state = 'prepared'")
.get(guid)
)
return insert(reply('rejected', 'pending'), member.id);
const requested = amount === 'all' ? balance : amount;
let code = 'ok';
if (requested === 0) code = 'empty';
else if (operation === 'withdraw' && requested > balance) code = 'insufficient_balance';
else if (operation === 'withdraw' && requested > capacity) code = 'points_limit';
else if (operation === 'deposit' && balance + requested > bankMaximum())
code = 'bank_limit';
return insert(
reply(code === 'ok' ? 'prepared' : 'rejected', code, code === 'ok' ? requested : 0),
member.id
);
})
.immediate();
}
+39
View File
@@ -0,0 +1,39 @@
import { json, isHttpError } from '@sveltejs/kit';
import { authenticateBank } from '$lib/server/auth/bank.js';
import { bankRequest, type BankAction } from '$lib/server/fleet/bank.js';
import { jsonBody } from '$lib/server/request-body.js';
export async function POST({ request, params }: { request: Request; params: { action: string } }) {
const headers = { 'Cache-Control': 'no-store' };
const authentication = authenticateBank(request);
if (!authentication.ok)
return json(
{ ok: false, code: authentication.code },
{ status: authentication.status, headers }
);
if (!['balance', 'deposit', 'withdraw', 'commit', 'cancel'].includes(params.action))
return json({ ok: false, code: 'invalid_action' }, { status: 404, headers });
if (
request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
'application/json'
)
return json({ ok: false, code: 'invalid_content_type' }, { status: 415, headers });
let value: unknown;
try {
value = await jsonBody(request, 4096);
} catch (cause) {
if (isHttpError(cause, 413)) throw cause;
return json({ ok: false, code: 'invalid_request' }, { status: 400, headers });
}
try {
const { body, status } = bankRequest(
params.action as BankAction,
value,
authentication.server
);
return json(body, { status, headers });
} catch {
console.error('Bank transaction failed; request may be retried with the same identity.');
return json({ ok: false, code: 'unavailable' }, { status: 503, headers });
}
}
@@ -1,3 +1,4 @@
import { bankProfile } from '$lib/server/fleet/bank.js';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { playerReport } from '$lib/server/reports/index.js'; import { playerReport } from '$lib/server/reports/index.js';
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js'; import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
@@ -38,6 +39,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL
if (!profile) error(404, 'Player not found'); if (!profile) error(404, 'Player not found');
return { return {
profile, profile,
bank: bankProfile(params.guid),
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry } scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry }
}; };
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { bankProfile } from '$lib/server/fleet/bank.js';
import Select from '$ui/components/primitives/Select.svelte'; import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
@@ -43,6 +44,7 @@
}: { }: {
data: { data: {
profile: PlayerProfile; profile: PlayerProfile;
bank: ReturnType<typeof bankProfile>;
returnTo: string; returnTo: string;
scope: { scope: {
game: string; game: string;
@@ -162,6 +164,45 @@
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute." description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
/> />
{/if} {/if}
<section aria-labelledby="bank-title">
<h2 id="bank-title">Bank</h2>
<p>
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
</p>
{#if data.bank.pending}<p>
A transfer is pending. Available: {number.format(data.bank.available)} points.
</p>{/if}
{#if data.bank.entries.length}
<div class="table-scroll">
<table>
<caption>Recent bank transfers</caption>
<thead
><tr
><th scope="col">Time</th><th scope="col">Server</th><th scope="col"
>Transfer</th
><th scope="col">Balance</th><th scope="col">Match</th></tr
></thead
>
<tbody
>{#each data.bank.entries as entry (entry.id)}
<tr
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
>{serverTitle(entry.server)}</td
><td>{entry.amount > 0 ? '+' : ''}{number.format(entry.amount)}</td
><td>{number.format(entry.balance)}</td><td
>{#if entry.matchAvailable}<a href={`/matches/${entry.matchId}`}
>#{entry.matchId}</a
>{:else}{entry.matchId
? `#${entry.matchId} (history expired)`
: '—'}{/if}</td
></tr
>
{/each}</tbody
>
</table>
</div>
{:else}<p>No bank transfers yet.</p>{/if}
</section>
<nav class="jump-links" aria-label="Player sections"> <nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title" <a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a >Matches</a
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { routePermission } from '../../src/lib/server/auth/access.js';
let fixture: Fixture;
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
const secret = 'ec_bank_' + 'a'.repeat(40);
const otherSecret = 'ec_bank_' + 'b'.repeat(40);
let serial = 0;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([server, other]);
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret, [other]: otherSecret }));
vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40));
vi.stubEnv('EC_BANK_MAX_BALANCE', '250000');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
await seedServerRegistry();
for (const source of [server, other]) {
saveTelemetry({
server: source,
map: source === server ? 'zombie_theater' : 'zm_transit',
match: 'match-one',
seq: 1,
event: 'game_start',
players: [{ guid: 'ace', name: 'Ace' }],
receivedAt: new Date().toISOString()
});
}
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
const transfer = (operation = 'deposit', amount: number | string = 2000, source = server) => ({
server: source,
guid: 'ace',
match: 'match-one',
request_id: `request-${++serial}`,
operation,
amount,
capacity: 1000000
});
async function post(action: string, body: unknown, credential = secret) {
const { POST } = await import('../../src/routes/api/bank/[action]/+server.js');
const response = await POST({
params: { action },
request: new Request(`https://example.test/api/bank/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}` },
body: JSON.stringify(body)
})
});
expect(response.headers.get('cache-control')).toBe('no-store');
return { status: response.status, ...(await response.json()) };
}
async function fund(amount = 2000) {
const data = transfer('deposit', amount);
expect(await post('deposit', data)).toMatchObject({ state: 'prepared', amount });
expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: amount });
return data;
}
it('routes only bank POSTs as machine requests, authenticating before reading bodies', async () => {
expect(routePermission('/api/bank/[action]', 'POST')).toBe('machine');
expect(routePermission('/api/bank/[action]', 'GET')).toBe('viewer');
const { POST } = await import('../../src/routes/api/bank/[action]/+server.js');
for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!, otherSecret]) {
const request = new Request('https://test/api/bank/deposit', {
method: 'POST',
headers: { Authorization: `Bearer ${credential}` },
body: 'invalid JSON'
});
if (credential === otherSecret) continue;
expect((await POST({ request, params: { action: 'deposit' } })).status).toBe(401);
expect(request.bodyUsed).toBe(false);
}
expect(await post('deposit', transfer(), otherSecret)).toMatchObject({
status: 403,
code: 'wrong_server'
});
});
it('fails closed for missing, duplicate, telemetry-reused and invalid credentials', async () => {
for (const configured of [
'',
'{}',
'[]',
'{',
JSON.stringify({ [server]: secret, [other]: secret }),
JSON.stringify({ [server]: 'short' })
]) {
vi.stubEnv('EC_BANK_TOKENS', configured);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
}
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret }));
vi.stubEnv('EC_TELEMETRY_TOKEN', secret);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
});
it('persists one credit and one ledger row through lost replies, retries and database reopen', async () => {
const data = transfer();
const initial = await post('deposit', data);
expect(await post('deposit', data)).toEqual(initial);
const first = await post('commit', data);
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(await post('commit', data)).toEqual(first);
expect(await post('deposit', data)).toEqual(first);
expect(db().prepare('SELECT amount,balance FROM bank_ledger').all()).toEqual([
{ amount: 2000, balance: 2000 }
]);
expect(await post('deposit', { ...data, amount: 1 })).toMatchObject({
status: 409,
code: 'request_conflict'
});
});
it('shares one GUID balance across games and preserves GUID strings', async () => {
await fund();
expect(await post('balance', { server: other, guid: 'ace' }, otherSecret)).toMatchObject({
balance: 2000
});
const data = transfer('withdraw', 'all', other);
expect(await post('withdraw', data, otherSecret)).toMatchObject({
state: 'prepared',
amount: 2000
});
expect(await post('commit', data, otherSecret)).toMatchObject({
balance: 0,
state: 'committed'
});
const big = { ...transfer(), guid: '18446744073709551615' };
expect(await post('deposit', big)).toMatchObject({ code: 'not_in_match' });
});
it('rejects overdraws, maximum overflow, invalid amounts and point overflow without a ledger change', async () => {
await fund();
for (const [operation, amount, code] of [
['withdraw', 2001, 'insufficient_balance'],
['deposit', 250000, 'bank_limit']
] as const) {
const data = transfer(operation, amount);
const denied = await post(operation, data);
expect(denied).toMatchObject({ ok: false, state: 'rejected', code });
expect(await post(operation, data)).toEqual(denied);
}
expect(await post('withdraw', { ...transfer('withdraw', 'all'), capacity: 100 })).toMatchObject(
{ code: 'points_limit' }
);
for (const amount of [0, -1, 1.5, 1000001, '2000', 'all', null]) {
expect(await post('deposit', { ...transfer(), amount })).toMatchObject({ status: 400 });
}
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 2000, pending: false });
expect(bankProfile('ace').entries).toHaveLength(1);
});
it('requires a present player in the exact current active match', async () => {
expect(await post('deposit', { ...transfer(), guid: 'outsider' })).toMatchObject({
code: 'not_in_match'
});
expect(await post('deposit', { ...transfer(), match: 'old-match' })).toMatchObject({
code: 'not_in_match'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE leaderboard_match_players SET present = 0').run();
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
db().prepare('UPDATE leaderboard_match_players SET present = 1').run();
db().prepare("UPDATE leaderboard_matches SET status = 'completed'").run();
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
});
it('cancels uncertain prepares, including cancellation arriving before the prepare', async () => {
await fund();
for (const cancelFirst of [false, true]) {
const data = transfer('withdraw', 500);
if (!cancelFirst) expect(await post('withdraw', data)).toMatchObject({ state: 'prepared' });
const cancelled = await post('cancel', data);
expect(cancelled).toMatchObject({ state: 'cancelled' });
expect(await post('cancel', data)).toEqual(cancelled);
expect(await post('withdraw', data)).toEqual(cancelled);
expect(await post('commit', data)).toEqual(cancelled);
}
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 2000, available: 2000, pending: false });
expect(bankProfile('ace').entries).toHaveLength(1);
});
it('serializes transfers across servers and reserves available funds until settled', async () => {
await fund();
const data = transfer('withdraw', 1500);
await post('withdraw', data);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({
balance: 2000,
amount: 500,
code: 'pending'
});
expect(await post('withdraw', transfer('withdraw', 1000, other), otherSecret)).toMatchObject({
code: 'pending'
});
await post('commit', data);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({
balance: 500,
amount: 500
});
});
it('honors prepared deposits after configuration changes and match/server deletion', async () => {
const data = transfer();
await post('deposit', data);
vi.stubEnv('EC_BANK_MAX_BALANCE', '100');
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('DELETE FROM leaderboard_matches').run();
db().prepare('DELETE FROM servers').run();
expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: 2000 });
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace').entries[0]).toMatchObject({
amount: 2000,
matchAvailable: 0,
server
});
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
expect(await post('cancel', data)).toMatchObject({ state: 'committed' });
});
it('rolls back all writes if the ledger cannot be saved', async () => {
const data = transfer();
await post('deposit', data);
const { db } = await import('../../src/lib/server/db/index.js');
db().exec(
"CREATE TRIGGER reject_bank BEFORE INSERT ON bank_ledger BEGIN SELECT RAISE(ABORT,'test'); END"
);
expect(await post('commit', data)).toMatchObject({ status: 503 });
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 0, pending: true, entries: [] });
db().exec('DROP TRIGGER reject_bank');
expect(await post('commit', data)).toMatchObject({ balance: 2000 });
});
it('returns the original settled result after later transfers change the account', async () => {
const deposited = await fund();
const withdrawal = transfer('withdraw', 500);
await post('withdraw', withdrawal);
expect(await post('commit', withdrawal)).toMatchObject({ balance: 1500 });
expect(await post('commit', deposited)).toMatchObject({ balance: 2000 });
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ balance: 1500 });
});
it('concurrent preparations cannot reserve the same funds twice', async () => {
await fund();
const first = transfer('withdraw', 2000);
const second = transfer('withdraw', 2000, other);
const results = await Promise.all([
post('withdraw', first),
post('withdraw', second, otherSecret)
]);
expect(results.filter((result) => result.state === 'prepared')).toHaveLength(1);
expect(results.filter((result) => result.code === 'pending')).toHaveLength(1);
const index = results.findIndex((result) => result.state === 'prepared');
expect(
await post('commit', index === 0 ? first : second, index === 0 ? secret : otherSecret)
).toMatchObject({ balance: 0 });
});
+61
View File
@@ -91,6 +91,7 @@ it('shows the same ranks in the desktop profile with lifetime map scope explaine
props: { props: {
data: { data: {
profile, profile,
bank: { balance: 0, available: 0, pending: false, entries: [] },
returnTo: '/leaderboard', returnTo: '/leaderboard',
scope: { game: '', map: '', players: '2', metric: 'kills', entry: null } scope: { game: '', map: '', players: '2', metric: 'kills', entry: null }
} }
@@ -135,6 +136,7 @@ it('renders a known player without qualifying history with identity and unavaila
props: { props: {
data: { data: {
profile, profile,
bank: { balance: 0, available: 0, pending: false, entries: [] },
returnTo: '/leaderboard', returnTo: '/leaderboard',
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null } scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
} }
@@ -159,3 +161,62 @@ it('renders a known player without qualifying history with identity and unavaila
expect(body).not.toContain('<table'); expect(body).not.toContain('<table');
expect(body).not.toContain('NaN'); expect(body).not.toContain('NaN');
}); });
it('shows the shared balance and ledger with retained and expired match references', () => {
const profile: PlayerProfile = {
guid: '101',
name: 'Cherry',
firstSeen: evidence.startedAt,
lastSeen: evidence.endedAt!,
stats,
records: map.records,
maps: [],
matches: [],
matchesTotal: 0,
limit: 25,
offset: 0
};
const entry = {
id: 1,
server: evidence.server,
amount: 2000,
balance: 2000,
createdAt: evidence.startedAt,
matchId: 71,
matchAvailable: 1
};
const body = render(PlayerProfilePage, {
props: {
data: {
profile,
bank: {
balance: 1500,
available: 1000,
pending: true,
entries: [
{
...entry,
id: 2,
amount: -500,
balance: 1500,
matchId: 72,
matchAvailable: 0
},
entry
]
},
returnTo: '/leaderboard',
scope: { game: '', map: '', metric: 'highestRound', entry: null }
}
}
}).body;
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('1,500 points');
expect(text).toContain('shared across Black Ops I and II');
expect(text).toContain('Available: 1,000 points');
expect(text).toContain('+2,000');
expect(text).toContain('-500');
expect(text).toContain('#72 (history expired)');
expect(body).toContain('href="/matches/71"');
expect(body).not.toContain('href="/matches/72"');
});