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