diff --git a/docs/guides/bank.md b/docs/guides/bank.md index bd4e969..233856e 100644 --- a/docs/guides/bank.md +++ b/docs/guides/bank.md @@ -195,14 +195,33 @@ 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. +The **Players → Bank** page shows fleet-wide balances, funded accounts, completed +transfers in the last 24 hours and pending transfers. Accounts can be searched by +player name or GUID, sorted by balance or recent completed activity, and filtered +to funded accounts, accounts at or above the configured limit, or pending transfers. +Balances always remain shared across both games. + +Transactions can be filtered by player, game, server, direction and inclusive UTC +dates. Pending transfers show oldest first, with an attention indicator after five +minutes and details explaining the reservation. This indicator does not expire or +cancel a transfer. Both views retain historical server identities and match IDs +after retention; unavailable matches have no live link. + +Selecting a player opens a drawer with their balance, available points, pending +transfer and paginated history. Filters and pagination are retained in the URL, +including when following a match or player-profile link. The page refreshes every +30 seconds while visible. Summary counts always cover the whole fleet. Bank is +read-only for all browser roles; transfers continue to originate in game. + +Player profiles show the shared balance, pending status and three newest ledger +entries, with a **View bank history** link to the drawer. Entries include 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, +Run `npx vitest run tests/unit/bank.test.ts tests/unit/bank-overview.test.ts tests/client/bank-page.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 diff --git a/src/lib/bank.ts b/src/lib/bank.ts new file mode 100644 index 0000000..77258d8 --- /dev/null +++ b/src/lib/bank.ts @@ -0,0 +1,116 @@ +export const bankPageSize = 25; +export const bankAttentionAgeMs = 5 * 60_000; + +export interface BankQuery { + view: 'accounts' | 'transactions' | 'pending'; + q: string; + balance: '' | 'funded' | 'limit' | 'pending'; + sort: 'balance' | 'recent'; + game: '' | 't5' | 't6'; + server: string; + operation: '' | 'deposit' | 'withdraw'; + from: string; + to: string; + offset: number; + player: string; + historyOffset: number; +} + +export function readBankQuery(params: URLSearchParams): BankQuery { + function choice(key: string, values: readonly T[], fallback: T): T { + const value = params.get(key) || fallback; + if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`); + return value as T; + } + function text(key: string, max: number) { + const value = (params.get(key) ?? '').trim(); + if (value.length > max) throw new Error(`${key} is too long.`); + return value; + } + function offset(key: string) { + const value = Number(params.get(key) || 0); + if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.'); + return Math.floor(value / bankPageSize) * bankPageSize; + } + function date(key: string) { + const value = text(key, 10); + if ( + value && + (!/^\d{4}-\d{2}-\d{2}$/.test(value) || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString().slice(0, 10) !== value) + ) + throw new Error('Use a valid date in YYYY-MM-DD format.'); + return value; + } + const from = date('from'); + const to = date('to'); + if (from && to && from > to) throw new Error('The end date must follow the start date.'); + return { + view: choice('view', ['accounts', 'transactions', 'pending'], 'accounts'), + q: text('q', 100), + balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''), + sort: choice('sort', ['balance', 'recent'], 'balance'), + game: choice('game', ['', 't5', 't6'], ''), + server: text('server', 256), + operation: choice('operation', ['', 'deposit', 'withdraw'], ''), + from, + to, + offset: offset('offset'), + player: text('player', 128), + historyOffset: offset('historyOffset') + }; +} + +export interface BankIdentity { + guid: string; + name: string | null; +} +export interface BankAccount extends BankIdentity { + balance: number; + available: number; + pendingOperation: 'deposit' | 'withdraw' | null; + pendingAmount: number | null; + lastActivity: string | null; + lastAmount: number | null; +} +export interface BankTransfer extends BankIdentity { + id: number; + server: string; + game: string | null; + matchId: number | null; + matchAvailable: number; + amount: number; + balance: number; + createdAt: string; +} +export interface BankPending extends BankIdentity { + requestId: string; + server: string; + game: string | null; + matchId: number | null; + matchAvailable: number; + operation: 'deposit' | 'withdraw'; + amount: number; + createdAt: string; +} +export interface BankPage { + entries: T[]; + total: number; + offset: number; +} +export interface BankOverview { + query: BankQuery; + maximum: number | null; + checkedAt: string; + summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number }; + servers: string[]; + accounts: BankPage; + transactions: BankPage; + pending: BankPage; + detail: { + account: BankAccount; + pending: BankPending | null; + history: BankPage; + } | null; +} diff --git a/src/lib/navigation-context.ts b/src/lib/navigation-context.ts index ddfedaa..81a14a9 100644 --- a/src/lib/navigation-context.ts +++ b/src/lib/navigation-context.ts @@ -1,6 +1,6 @@ const origin = 'https://electric-cherry.invalid'; const pagePath = - /^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/; + /^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/; /** Only return to application pages, never an external URL or a mutation/API endpoint. */ export function safeReturnHref(value: string | null | undefined, fallback: string): string { @@ -60,6 +60,7 @@ export function returnLabel(href: string): string { maintenance: 'Maintenance', provisioning: 'Server queues', leaderboard: 'Leaderboard', + bank: 'Bank', matches: 'Match History', metrics: 'Metrics' }; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 7702e7e..1b154e2 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -78,6 +78,7 @@ export const navigationGroups: NavigationGroup[] = [ items: [ { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, { label: 'Matches', href: '/matches' }, + { label: 'Bank', href: '/bank' }, { label: 'Usage', href: '/metrics', shortcut: 'm' } ] }, diff --git a/src/lib/server/fleet/bank-overview.ts b/src/lib/server/fleet/bank-overview.ts new file mode 100644 index 0000000..dbe007a --- /dev/null +++ b/src/lib/server/fleet/bank-overview.ts @@ -0,0 +1,228 @@ +import { db } from '../db/index.js'; +import { bankMaximum } from './bank.js'; +import { + bankPageSize, + type BankAccount, + type BankOverview, + type BankPage, + type BankPending, + type BankQuery, + type BankTransfer +} from '../../bank.js'; + +// A GUID owns one account even when telemetry has identities in both games. +const identities = `WITH identities AS ( + SELECT guid, name FROM ( + SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank + FROM leaderboard_players WHERE game IN ('t5', 't6') + ) WHERE rank = 1 +)`; +const accounts = `${identities}, account_guids AS ( + SELECT guid FROM bank_accounts UNION SELECT guid FROM bank_requests WHERE state = 'prepared' +), accounts AS ( + SELECT g.guid, p.name, COALESCE(a.balance, 0) AS balance, + COALESCE(a.balance, 0) - CASE WHEN r.operation = 'withdraw' THEN r.amount ELSE 0 END AS available, + r.operation AS pendingOperation, r.amount AS pendingAmount, + l.created_at AS lastActivity, l.amount AS lastAmount + FROM account_guids g + LEFT JOIN bank_accounts a ON a.guid = g.guid + LEFT JOIN identities p ON p.guid = g.guid + LEFT JOIN bank_requests r ON r.guid = g.guid AND r.state = 'prepared' + LEFT JOIN bank_ledger l ON l.id = (SELECT id FROM bank_ledger WHERE guid = g.guid ORDER BY id DESC LIMIT 1) +)`; +// Canonical server names retain the game after match retention or server removal. +const game = (alias: string) => `COALESCE(m.game, CASE + WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5' + WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`; +const transfers = `${identities}, transfers AS ( + SELECT l.id, l.guid, p.name, l.server, ${game('l')} AS game, + l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, + l.amount, l.balance, l.created_at AS createdAt + FROM bank_ledger l LEFT JOIN identities p ON p.guid = l.guid + LEFT JOIN leaderboard_matches m ON m.id = l.match_id +)`; +const pending = `${identities}, pending AS ( + SELECT r.guid, p.name, r.request_id AS requestId, r.server, ${game('r')} AS game, + r.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, + r.operation, r.amount, r.created_at AS createdAt + FROM bank_requests r LEFT JOIN identities p ON p.guid = r.guid + LEFT JOIN leaderboard_matches m ON m.id = r.match_id WHERE r.state = 'prepared' +)`; +const emptyPage = (): BankPage => ({ entries: [], total: 0, offset: 0 }); + +export function bankOverview(query: BankQuery, now = new Date()): BankOverview { + const handle = db(); + return handle.transaction(() => { + let maximum: number | null; + try { + maximum = bankMaximum(); + } catch { + // Invalid deposit configuration must not hide balances or recovery information. + maximum = null; + } + const checkedAt = now.toISOString(); + const summary = handle + .prepare( + `SELECT + (SELECT COALESCE(SUM(balance), 0) FROM bank_accounts) AS totalBanked, + (SELECT COUNT(*) FROM bank_accounts WHERE balance > 0) AS fundedAccounts, + (SELECT COUNT(*) FROM bank_ledger WHERE created_at >= ? AND created_at <= ?) AS transfers24h, + (SELECT COUNT(*) FROM bank_requests WHERE state = 'prepared') AS pending + ` + ) + .get( + new Date(now.getTime() - 86_400_000).toISOString(), + checkedAt + ) as BankOverview['summary']; + const servers = ( + handle + .prepare( + `SELECT server FROM bank_ledger + UNION SELECT server FROM bank_requests WHERE state = 'prepared' ORDER BY server` + ) + .all() as { server: string }[] + ).map((row) => row.server); + + function page( + cte: string, + table: string, + where: string[], + values: (string | number)[], + order: string, + requested: number + ): BankPage { + const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`; + const { total } = handle + .prepare(`${cte} SELECT COUNT(*) AS total ${scope}`) + .get(...values) as { total: number }; + const offset = Math.min( + requested, + Math.max(0, Math.floor((total - 1) / bankPageSize) * bankPageSize) + ); + const entries = handle + .prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`) + .all(...values, bankPageSize, offset) as T[]; + return { entries, total, offset }; + } + const where: string[] = []; + const values: (string | number)[] = []; + if (query.q) { + where.push( + "(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)" + ); + values.push(query.q, query.q); + } + let accountPage = emptyPage(); + let transactionPage = emptyPage(); + let pendingPage = emptyPage(); + if (query.view === 'accounts') { + if (query.balance === 'funded') where.push('balance > 0'); + if (query.balance === 'pending') where.push('pendingOperation IS NOT NULL'); + if (query.balance === 'limit') { + where.push('balance >= ?'); + values.push(maximum ?? 1_000_001); + } + accountPage = page( + accounts, + 'accounts', + where, + values, + query.sort === 'recent' ? 'lastActivity DESC, guid' : 'balance DESC, guid', + query.offset + ); + } else { + if (query.game) { + where.push('game = ?'); + values.push(query.game); + } + if (query.server) { + where.push('server = ?'); + values.push(query.server); + } + if (query.operation) { + where.push( + query.view === 'pending' + ? 'operation = ?' + : query.operation === 'deposit' + ? 'amount > 0' + : 'amount < 0' + ); + if (query.view === 'pending') values.push(query.operation); + } + if (query.from) { + where.push('createdAt >= ?'); + values.push(`${query.from}T00:00:00.000Z`); + } + if (query.to) { + where.push('createdAt <= ?'); + values.push(`${query.to}T23:59:59.999Z`); + } + if (query.view === 'transactions') + transactionPage = page( + transfers, + 'transfers', + where, + values, + 'createdAt DESC, id DESC', + query.offset + ); + else + pendingPage = page( + pending, + 'pending', + where, + values, + 'createdAt, server, requestId', + query.offset + ); + } + let detail: BankOverview['detail'] = null; + if (query.player) { + let account = handle + .prepare(`${accounts} SELECT * FROM accounts WHERE guid = ?`) + .get(query.player) as BankAccount | undefined; + if (!account) { + const identity = handle + .prepare(`${identities} SELECT * FROM identities WHERE guid = ?`) + .get(query.player) as { guid: string; name: string } | undefined; + if (identity) + account = { + ...identity, + balance: 0, + available: 0, + pendingOperation: null, + pendingAmount: null, + lastActivity: null, + lastAmount: null + }; + } + if (account) + detail = { + account, + pending: + (handle + .prepare(`${pending} SELECT * FROM pending WHERE guid = ?`) + .get(query.player) as BankPending | undefined) ?? null, + history: page( + transfers, + 'transfers', + ['guid = ?'], + [query.player], + 'createdAt DESC, id DESC', + query.historyOffset + ) + }; + } + return { + query, + maximum, + checkedAt, + summary, + servers, + accounts: accountPage, + transactions: transactionPage, + pending: pendingPage, + detail + }; + })(); +} diff --git a/src/lib/ui/components/bank/BankPagination.svelte b/src/lib/ui/components/bank/BankPagination.svelte new file mode 100644 index 0000000..828923c --- /dev/null +++ b/src/lib/ui/components/bank/BankPagination.svelte @@ -0,0 +1,55 @@ + + + + + diff --git a/src/lib/ui/components/bank/BankPendingDetails.svelte b/src/lib/ui/components/bank/BankPendingDetails.svelte new file mode 100644 index 0000000..f9bd084 --- /dev/null +++ b/src/lib/ui/components/bank/BankPendingDetails.svelte @@ -0,0 +1,53 @@ + + +
+

+ Waiting for the game server to confirm or cancel this {transfer.operation === 'deposit' + ? 'deposit' + : 'withdrawal'}. +

+

+ {transfer.operation === 'deposit' + ? 'These points have not been added to the bank balance. Deposit capacity is reserved until the transfer settles.' + : 'These points are reserved and cannot be withdrawn again until the transfer settles.'} +

+

+ Restore the server’s connection and allow it to retry. Pending transfers do not expire + automatically. +

+
+ · +
+ {#if transfer.matchAvailable}View match #{transfer.matchId}{:else if transfer.matchId}Match #{transfer.matchId} · History expired{/if} +
+ Transfer details +
+
+ + diff --git a/src/lib/ui/components/bank/BankTransfers.svelte b/src/lib/ui/components/bank/BankTransfers.svelte new file mode 100644 index 0000000..b223765 --- /dev/null +++ b/src/lib/ui/components/bank/BankTransfers.svelte @@ -0,0 +1,70 @@ + + +{#if entries.length} + +
+ + + {#if onPlayer}{/if} + + {#each entries as entry (entry.id)} + + + {#if onPlayer}{/if} + + + + + {/each} + +
Completed bank transfers
TimePlayerTransferBalance afterServer / match
{entry.guid}{entry.amount > 0 ? '+' : '−'}{number.format( + Math.abs(entry.amount) + )}{entry.amount > 0 ? 'Deposit' : 'Withdrawal'}{number.format(entry.balance)}{#if entry.matchAvailable}Match #{entry.matchId}{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}Match + unavailable{/if}
+
+{:else} + +{/if} diff --git a/src/lib/ui/styles/bank.css b/src/lib/ui/styles/bank.css new file mode 100644 index 0000000..59b72b8 --- /dev/null +++ b/src/lib/ui/styles/bank.css @@ -0,0 +1,68 @@ +.bank-table-scroll { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface); +} +.bank-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: var(--text-sm); +} +.bank-table th { + color: var(--text-tertiary); + background: var(--surface-sunken); + font-weight: var(--weight-medium); + white-space: nowrap; +} +.bank-table th, +.bank-table td { + padding: var(--space-4); + vertical-align: top; +} +.bank-table tbody tr + tr { + border-top: 1px solid var(--border-subtle); +} +.bank-table td { + min-width: 130px; +} +.bank-table small { + display: block; + margin-top: var(--space-1); + font-size: var(--text-xs); + color: var(--text-secondary); +} +.bank-table .bank-id { + font-family: var(--font-mono); + overflow-wrap: anywhere; +} +.bank-number { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.bank-player { + padding: 0; + background: none; + border: 0; + color: var(--accent-primary-hover); + text-align: left; + font: inherit; + font-weight: var(--weight-semibold); + overflow-wrap: anywhere; + cursor: pointer; +} +.bank-player:hover { + text-decoration: underline; +} +.bank-table.compact th, +.bank-table.compact td { + padding: var(--space-3); +} +.bank-table.compact td { + min-width: 100px; +} +.bank-table .bank-warning { + color: var(--state-warning); +} diff --git a/src/routes/api/bank/+server.ts b/src/routes/api/bank/+server.ts new file mode 100644 index 0000000..7552a98 --- /dev/null +++ b/src/routes/api/bank/+server.ts @@ -0,0 +1,13 @@ +import { error, json } from '@sveltejs/kit'; +import { readBankQuery } from '$lib/bank.js'; +import { bankOverview } from '$lib/server/fleet/bank-overview.js'; + +export function GET({ url }: { url: URL }) { + let query; + try { + query = readBankQuery(url.searchParams); + } catch (cause) { + error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.'); + } + return json(bankOverview(query), { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/src/routes/bank/+page.server.ts b/src/routes/bank/+page.server.ts new file mode 100644 index 0000000..f1120f6 --- /dev/null +++ b/src/routes/bank/+page.server.ts @@ -0,0 +1,13 @@ +import { error } from '@sveltejs/kit'; +import { readBankQuery } from '$lib/bank.js'; +import { bankOverview } from '$lib/server/fleet/bank-overview.js'; + +export function load({ url }: { url: URL }) { + let query; + try { + query = readBankQuery(url.searchParams); + } catch (cause) { + error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.'); + } + return { report: bankOverview(query) }; +} diff --git a/src/routes/bank/+page.svelte b/src/routes/bank/+page.svelte new file mode 100644 index 0000000..7d3f36c --- /dev/null +++ b/src/routes/bank/+page.svelte @@ -0,0 +1,693 @@ + + +Bank · Electric Cherry + +
+ + {#snippet actions()} void refresh()} + />{/snippet} + + +
+
+
Total banked
+
{number.format(report.summary.totalBanked)} pts
+
+
+
Funded accounts
+
{number.format(report.summary.fundedAccounts)}
+
+
+
Transfers · last 24 hours
+
{number.format(report.summary.transfers24h)}
+
+
+
Pending transfers
+
+ +
+
+
+ {#if report.maximum === null}

+ The bank deposit limit is unavailable. Balances and transfer history remain visible; new + deposits are disabled until the configuration is corrected. +

{/if} + {#if navigationError}{/if} + + +
+
+ + + {#if report.query.view === 'accounts'} + + {:else} + + + + {/if} +
+ +
+
+ + {#if report.query.view === 'accounts'} +

+ Balances are shared across both games.{report.maximum !== null + ? ` Account limit: ${number.format(report.maximum)} points.` + : ''} +

+ {#if report.accounts.entries.length} + +
+ + + + {#each report.accounts.entries as account (account.guid)} + + + + + + + + {/each} +
Player bank accounts
PlayerBalanceBank capacityLast activityStatus
{account.guid}{number.format(account.balance)} pts{#if account.available !== account.balance}{number.format(account.available)} available{/if}{#if report.maximum !== null}
+ {capacity(account.balance)}% +
{:else}Unavailable{/if}
{#if account.lastActivity}{(account.lastAmount ?? 0) > 0 + ? 'Deposit' + : 'Withdrawal'}{:else}No completed transfers{/if}{#if account.pendingOperation}Pending {account.pendingOperation === 'deposit' + ? 'deposit' + : 'withdrawal'}{number.format(account.pendingAmount ?? 0)} pts{:else if report.maximum !== null && account.balance >= report.maximum}At + limit{:else}Available{/if}
+
+ {:else}{/if} + {:else if report.query.view === 'transactions'} +

+ Completed deposits and withdrawals across your fleet. Game filters apply to + activity; account balances remain shared. +

+ + {:else} +

+ Oldest transfers first. Transfers waiting at least five minutes are highlighted for + attention. +

+ {#if report.pending.entries.length} + +
+ + + + {#each report.pending.entries as transfer (transfer.guid)} + + + + + + + + {/each} +
Transfers awaiting settlement
PlayerTransferServerWaiting sinceDetails
{transfer.guid}{number.format(transfer.amount)} pts{transfer.operation === 'deposit' + ? 'Deposit' + : 'Withdrawal'}{#if isOlder(transfer.createdAt)}Waiting over 5 minutes{/if}
+ Why pending? +
+ +
+
+
+ {:else}{/if} + {/if} + { + void navigate({ offset }); + }} + label={report.query.view === 'accounts' ? 'accounts' : 'transfers'} + /> +
+
+ + !!report.query.player, setDrawerOpen} + title={report.detail?.account.name ?? (report.query.player || 'Player bank')} + description="Shared bank account across Black Ops I & II." +> + {#if report.detail} + {@const detail = report.detail} + + {:else}{/if} + {#snippet actions()}{/snippet} + + + diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 533462a..579b0f9 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -164,8 +164,13 @@ 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} -
-

Bank

+
+
+

Bank

+ +

{number.format(data.bank.balance)} points shared across Black Ops I and II.

@@ -184,7 +189,7 @@ > {#each data.bank.entries as entry (entry.id)} + >{#each data.bank.entries.slice(0, 3) as entry (entry.id)} {$timeFormat.dateTime(entry.createdAt)}{serverTitle(entry.server)}