This commit is contained in:
+22
-3
@@ -195,14 +195,33 @@ in the actual Windows/Plutonium deployment before rollout.
|
|||||||
|
|
||||||
## History and validation
|
## History and validation
|
||||||
|
|
||||||
Player profiles show the shared balance, pending status and 20 newest ledger
|
The **Players → Bank** page shows fleet-wide balances, funded accounts, completed
|
||||||
entries, including signed amounts, resulting balances, server, time and match.
|
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
|
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
|
keys and are not deleted by match retention or server deletion. Retained IDs
|
||||||
also keep old retries idempotent. Expired match links become plain historical
|
also keep old retries idempotent. Expired match links become plain historical
|
||||||
IDs. Include these tables in normal database backups.
|
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
|
tests and build. TWS supplies `node tools/check_bank.mjs` and
|
||||||
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
|
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
|
||||||
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
|
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
|
||||||
|
|||||||
+116
@@ -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<T extends string>(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<T> {
|
||||||
|
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<BankAccount>;
|
||||||
|
transactions: BankPage<BankTransfer>;
|
||||||
|
pending: BankPage<BankPending>;
|
||||||
|
detail: {
|
||||||
|
account: BankAccount;
|
||||||
|
pending: BankPending | null;
|
||||||
|
history: BankPage<BankTransfer>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const origin = 'https://electric-cherry.invalid';
|
const origin = 'https://electric-cherry.invalid';
|
||||||
const pagePath =
|
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. */
|
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||||
@@ -60,6 +60,7 @@ export function returnLabel(href: string): string {
|
|||||||
maintenance: 'Maintenance',
|
maintenance: 'Maintenance',
|
||||||
provisioning: 'Server queues',
|
provisioning: 'Server queues',
|
||||||
leaderboard: 'Leaderboard',
|
leaderboard: 'Leaderboard',
|
||||||
|
bank: 'Bank',
|
||||||
matches: 'Match History',
|
matches: 'Match History',
|
||||||
metrics: 'Metrics'
|
metrics: 'Metrics'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||||
{ label: 'Matches', href: '/matches' },
|
{ label: 'Matches', href: '/matches' },
|
||||||
|
{ label: 'Bank', href: '/bank' },
|
||||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = <T>(): BankPage<T> => ({ 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<T>(
|
||||||
|
cte: string,
|
||||||
|
table: string,
|
||||||
|
where: string[],
|
||||||
|
values: (string | number)[],
|
||||||
|
order: string,
|
||||||
|
requested: number
|
||||||
|
): BankPage<T> {
|
||||||
|
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<BankAccount>();
|
||||||
|
let transactionPage = emptyPage<BankTransfer>();
|
||||||
|
let pendingPage = emptyPage<BankPending>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { bankPageSize } from '$lib/bank.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
let {
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
disabled = false,
|
||||||
|
onchange,
|
||||||
|
label = 'results'
|
||||||
|
}: {
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange: (offset: number) => void;
|
||||||
|
label?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="pagination" aria-label={`${label} pagination`}>
|
||||||
|
<span role="status"
|
||||||
|
>{total ? offset + 1 : 0}–{Math.min(offset + bankPageSize, total)} of {total.toLocaleString()}
|
||||||
|
{label}</span
|
||||||
|
>
|
||||||
|
{#if total > bankPageSize}
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || offset === 0}
|
||||||
|
onclick={() => onchange(offset - bankPageSize)}>Previous</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || offset + bankPageSize >= total}
|
||||||
|
onclick={() => onchange(offset + bankPageSize)}>Next</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.pagination div {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BankPending } from '$lib/bank.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import CopyValue from '../CopyValue.svelte';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
import ServerIdentity from '../ServerIdentity.svelte';
|
||||||
|
let { transfer, source }: { transfer: BankPending; source: URL } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pending-details">
|
||||||
|
<p>
|
||||||
|
Waiting for the game server to confirm or cancel this {transfer.operation === 'deposit'
|
||||||
|
? 'deposit'
|
||||||
|
: 'withdrawal'}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Restore the server’s connection and allow it to retry. Pending transfers do not expire
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<ServerIdentity name={transfer.server} /> · <RelativeTime value={transfer.createdAt} />
|
||||||
|
</div>
|
||||||
|
{#if transfer.matchAvailable}<a href={contextHref(`/matches/${transfer.matchId}`, source)}
|
||||||
|
>View match #{transfer.matchId}</a
|
||||||
|
>{:else if transfer.matchId}<span>Match #{transfer.matchId} · History expired</span>{/if}
|
||||||
|
<details>
|
||||||
|
<summary>Transfer details</summary><CopyValue
|
||||||
|
value={transfer.requestId}
|
||||||
|
label="Request ID"
|
||||||
|
/><CopyValue value={transfer.server} label="Server ID" />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pending-details {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BankTransfer } from '$lib/bank.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
import ServerIdentity from '../ServerIdentity.svelte';
|
||||||
|
import EmptyState from '../primitives/EmptyState.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
source,
|
||||||
|
onPlayer,
|
||||||
|
compact = false
|
||||||
|
}: {
|
||||||
|
entries: BankTransfer[];
|
||||||
|
source: URL;
|
||||||
|
onPlayer?: (guid: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
} = $props();
|
||||||
|
const number = new Intl.NumberFormat('en-US');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entries.length}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div class="bank-table-scroll" role="region" aria-label="Bank transactions" tabindex="0">
|
||||||
|
<table class="bank-table" class:compact>
|
||||||
|
<caption class="sr-only">Completed bank transfers</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Time</th>{#if onPlayer}<th scope="col">Player</th>{/if}<th
|
||||||
|
scope="col">Transfer</th
|
||||||
|
><th scope="col">Balance after</th><th scope="col">Server / match</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<tr>
|
||||||
|
<td><RelativeTime value={entry.createdAt} /></td>
|
||||||
|
{#if onPlayer}<td
|
||||||
|
><button class="bank-player" onclick={() => onPlayer?.(entry.guid)}
|
||||||
|
>{entry.name ?? entry.guid}</button
|
||||||
|
><small class="bank-id">{entry.guid}</small></td
|
||||||
|
>{/if}
|
||||||
|
<td class="bank-number"
|
||||||
|
><strong
|
||||||
|
>{entry.amount > 0 ? '+' : '−'}{number.format(
|
||||||
|
Math.abs(entry.amount)
|
||||||
|
)}</strong
|
||||||
|
><small>{entry.amount > 0 ? 'Deposit' : 'Withdrawal'}</small></td
|
||||||
|
>
|
||||||
|
<td class="bank-number">{number.format(entry.balance)}</td>
|
||||||
|
<td
|
||||||
|
><ServerIdentity name={entry.server} /><small
|
||||||
|
>{#if entry.matchAvailable}<a
|
||||||
|
href={contextHref(`/matches/${entry.matchId}`, source)}
|
||||||
|
>Match #{entry.matchId}</a
|
||||||
|
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}Match
|
||||||
|
unavailable{/if}</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
title="No transactions found"
|
||||||
|
description="Completed deposits and withdrawals appear here. Try changing your filters if you expected to see more."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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' } });
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||||
|
import { page, navigating } from '$app/stores';
|
||||||
|
import { bankAttentionAgeMs, type BankOverview, type BankQuery } from '$lib/bank.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
|
import Freshness from '$ui/components/Freshness.svelte';
|
||||||
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
import BankPagination from '$ui/components/bank/BankPagination.svelte';
|
||||||
|
import BankTransfers from '$ui/components/bank/BankTransfers.svelte';
|
||||||
|
import BankPendingDetails from '$ui/components/bank/BankPendingDetails.svelte';
|
||||||
|
import '$ui/styles/bank.css';
|
||||||
|
|
||||||
|
let { data }: { data: import('./$types').PageData } = $props();
|
||||||
|
const number = new Intl.NumberFormat('en-US');
|
||||||
|
let snapshot = $state<BankOverview | null>(null);
|
||||||
|
const report = $derived(snapshot ?? data.report);
|
||||||
|
let filters = $state<BankQuery>(untrack(() => ({ ...data.report.query })));
|
||||||
|
let refreshError = $state('');
|
||||||
|
let navigationError = $state('');
|
||||||
|
let updatedAt = $state<number | null>(null);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let controller: AbortController | undefined;
|
||||||
|
let generation = 0;
|
||||||
|
const busy = $derived(!!$navigating);
|
||||||
|
const results = $derived(report[report.query.view]);
|
||||||
|
const tabs = $derived([
|
||||||
|
{ id: 'accounts', label: 'Accounts' },
|
||||||
|
{ id: 'transactions', label: 'Transactions' },
|
||||||
|
{ id: 'pending', label: 'Pending', badge: report.summary.pending || null }
|
||||||
|
]);
|
||||||
|
const serverOptions = $derived([
|
||||||
|
{ value: '', label: 'All servers' },
|
||||||
|
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
|
||||||
|
(server) => ({ value: server, label: serverTitle(server) })
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
$effect(() => {
|
||||||
|
const initial = data.report;
|
||||||
|
untrack(() => {
|
||||||
|
snapshot = null;
|
||||||
|
filters = { ...initial.query };
|
||||||
|
refreshError = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function cancelRefresh() {
|
||||||
|
generation++;
|
||||||
|
controller?.abort();
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
beforeNavigate(cancelRefresh);
|
||||||
|
afterNavigate(() => {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
navigationError = '';
|
||||||
|
});
|
||||||
|
async function refresh(signal?: AbortSignal) {
|
||||||
|
if (busy || refreshing) return;
|
||||||
|
const current = ++generation;
|
||||||
|
const request = new AbortController();
|
||||||
|
controller = request;
|
||||||
|
const abort = () => request.abort();
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal?.aborted) request.abort();
|
||||||
|
const timeout = setTimeout(abort, 30_000);
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bank${$page.url.search}`, {
|
||||||
|
signal: request.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Bank refresh failed.');
|
||||||
|
const next: BankOverview = await response.json();
|
||||||
|
if (current !== generation || request.signal.aborted) return;
|
||||||
|
snapshot = next;
|
||||||
|
updatedAt = Date.now();
|
||||||
|
refreshError = '';
|
||||||
|
} catch {
|
||||||
|
if (current === generation) refreshError = 'Bank updates paused.';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (current === generation) refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
const stop = pollVisible(refresh, 30_000, false);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
cancelRefresh();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
async function navigate(changes: Record<string, string | number | null>) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
if (value === null || value === '') params.delete(key);
|
||||||
|
else params.set(key, String(value));
|
||||||
|
}
|
||||||
|
navigationError = '';
|
||||||
|
try {
|
||||||
|
await goto(`/bank?${params}`, { keepFocus: true, noScroll: true });
|
||||||
|
} catch {
|
||||||
|
navigationError = 'Could not load this bank view. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function changeView(view: string) {
|
||||||
|
void navigate({
|
||||||
|
view,
|
||||||
|
offset: null,
|
||||||
|
balance: null,
|
||||||
|
sort: null,
|
||||||
|
game: null,
|
||||||
|
server: null,
|
||||||
|
operation: null,
|
||||||
|
from: null,
|
||||||
|
to: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function applyFilters(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
void navigate({
|
||||||
|
q: filters.q,
|
||||||
|
balance: filters.balance,
|
||||||
|
sort: filters.sort,
|
||||||
|
game: filters.game,
|
||||||
|
server: filters.server,
|
||||||
|
operation: filters.operation,
|
||||||
|
from: filters.from,
|
||||||
|
to: filters.to,
|
||||||
|
offset: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const openPlayer = (guid: string) => {
|
||||||
|
void navigate({ player: guid, historyOffset: null });
|
||||||
|
};
|
||||||
|
function setDrawerOpen(open: boolean) {
|
||||||
|
if (!open && report.query.player && !busy)
|
||||||
|
void navigate({ player: null, historyOffset: null });
|
||||||
|
}
|
||||||
|
const isOlder = (createdAt: string) =>
|
||||||
|
Date.parse(report.checkedAt) - Date.parse(createdAt) >= bankAttentionAgeMs;
|
||||||
|
const capacity = (balance: number) =>
|
||||||
|
report.maximum ? Math.round((balance / report.maximum) * 100) : 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Bank · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="bank-page">
|
||||||
|
<SectionHeader
|
||||||
|
title="Bank"
|
||||||
|
eyebrow="Players"
|
||||||
|
subtitle="Shared player balances across Black Ops I & II."
|
||||||
|
>
|
||||||
|
{#snippet actions()}<Freshness
|
||||||
|
{updatedAt}
|
||||||
|
error={refreshError}
|
||||||
|
intervalLabel="every 30 s"
|
||||||
|
retryDisabled={refreshing || busy}
|
||||||
|
onRetry={() => void refresh()}
|
||||||
|
/><Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
|
||||||
|
>Refresh</Button
|
||||||
|
>{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
<dl class="summary">
|
||||||
|
<div>
|
||||||
|
<dt>Total banked</dt>
|
||||||
|
<dd>{number.format(report.summary.totalBanked)} <small>pts</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Funded accounts</dt>
|
||||||
|
<dd>{number.format(report.summary.fundedAccounts)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Transfers · last 24 hours</dt>
|
||||||
|
<dd>{number.format(report.summary.transfers24h)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Pending transfers</dt>
|
||||||
|
<dd>
|
||||||
|
<button class="summary-link" onclick={() => changeView('pending')}
|
||||||
|
>{number.format(report.summary.pending)}</button
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if report.maximum === null}<p class="notice">
|
||||||
|
The bank deposit limit is unavailable. Balances and transfer history remain visible; new
|
||||||
|
deposits are disabled until the configuration is corrected.
|
||||||
|
</p>{/if}
|
||||||
|
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||||
|
<Tabs
|
||||||
|
{tabs}
|
||||||
|
value={report.query.view}
|
||||||
|
onchange={changeView}
|
||||||
|
ariaLabel="Bank views"
|
||||||
|
panelId="bank-results"
|
||||||
|
idPrefix="bank-tab"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="bank-results"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`bank-tab-${report.query.view}`}
|
||||||
|
aria-busy={busy}
|
||||||
|
class="results"
|
||||||
|
>
|
||||||
|
<form class="filters" method="GET" action="/bank" onsubmit={applyFilters}>
|
||||||
|
<input type="hidden" name="view" value={report.query.view} />
|
||||||
|
<div class="search">
|
||||||
|
<Field label="Player name or GUID"
|
||||||
|
><Input
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
bind:value={filters.q}
|
||||||
|
maxlength={100}
|
||||||
|
placeholder="Search players…"
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if report.query.view === 'accounts'}
|
||||||
|
<Field label="Accounts"
|
||||||
|
><Select
|
||||||
|
name="balance"
|
||||||
|
bind:value={filters.balance}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All accounts' },
|
||||||
|
{ value: 'funded', label: 'Funded accounts' },
|
||||||
|
{
|
||||||
|
value: 'limit',
|
||||||
|
label: 'At or above limit',
|
||||||
|
disabled: report.maximum === null
|
||||||
|
},
|
||||||
|
{ value: 'pending', label: 'Pending transfers' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Sort by"
|
||||||
|
><Select
|
||||||
|
name="sort"
|
||||||
|
bind:value={filters.sort}
|
||||||
|
options={[
|
||||||
|
{ value: 'balance', label: 'Highest balance' },
|
||||||
|
{ value: 'recent', label: 'Recent activity' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Field label="Game"
|
||||||
|
><Select
|
||||||
|
name="game"
|
||||||
|
bind:value={filters.game}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All games' },
|
||||||
|
{ value: 't5', label: 'Black Ops I' },
|
||||||
|
{ value: 't6', label: 'Black Ops II' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Server"
|
||||||
|
><Select
|
||||||
|
name="server"
|
||||||
|
bind:value={filters.server}
|
||||||
|
options={serverOptions}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Transfer"
|
||||||
|
><Select
|
||||||
|
name="operation"
|
||||||
|
bind:value={filters.operation}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All transfers' },
|
||||||
|
{ value: 'deposit', label: 'Deposits' },
|
||||||
|
{ value: 'withdraw', label: 'Withdrawals' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="From (UTC)"
|
||||||
|
><Input
|
||||||
|
name="from"
|
||||||
|
type="date"
|
||||||
|
bind:value={filters.from}
|
||||||
|
max={filters.to || undefined}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Through (UTC)"
|
||||||
|
><Input
|
||||||
|
name="to"
|
||||||
|
type="date"
|
||||||
|
bind:value={filters.to}
|
||||||
|
min={filters.from || undefined}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<div class="filter-actions">
|
||||||
|
<Button type="submit" disabled={busy}>Apply</Button><Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => {
|
||||||
|
void navigate({
|
||||||
|
q: null,
|
||||||
|
balance: null,
|
||||||
|
sort: null,
|
||||||
|
game: null,
|
||||||
|
server: null,
|
||||||
|
operation: null,
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
offset: null
|
||||||
|
});
|
||||||
|
}}>Reset</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if report.query.view === 'accounts'}
|
||||||
|
<p class="hint">
|
||||||
|
Balances are shared across both games.{report.maximum !== null
|
||||||
|
? ` Account limit: ${number.format(report.maximum)} points.`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
{#if report.accounts.entries.length}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div
|
||||||
|
class="bank-table-scroll"
|
||||||
|
role="region"
|
||||||
|
aria-label="Bank accounts"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<table class="bank-table">
|
||||||
|
<caption class="sr-only">Player bank accounts</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Player</th><th scope="col">Balance</th><th
|
||||||
|
scope="col">Bank capacity</th
|
||||||
|
><th scope="col">Last activity</th><th scope="col">Status</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody
|
||||||
|
>{#each report.accounts.entries as account (account.guid)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
><button
|
||||||
|
class="bank-player"
|
||||||
|
onclick={() => openPlayer(account.guid)}
|
||||||
|
>{account.name ?? account.guid}</button
|
||||||
|
><small class="bank-id">{account.guid}</small></td
|
||||||
|
>
|
||||||
|
<td class="bank-number"
|
||||||
|
><strong>{number.format(account.balance)} pts</strong
|
||||||
|
>{#if account.available !== account.balance}<small
|
||||||
|
>{number.format(account.available)} available</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{#if report.maximum !== null}<div class="capacity">
|
||||||
|
<meter
|
||||||
|
min="0"
|
||||||
|
max={report.maximum}
|
||||||
|
value={Math.min(
|
||||||
|
account.balance,
|
||||||
|
report.maximum
|
||||||
|
)}
|
||||||
|
aria-label={`${account.name ?? account.guid} bank capacity`}
|
||||||
|
></meter><span>{capacity(account.balance)}%</span>
|
||||||
|
</div>{:else}Unavailable{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{#if account.lastActivity}<span
|
||||||
|
>{(account.lastAmount ?? 0) > 0
|
||||||
|
? 'Deposit'
|
||||||
|
: 'Withdrawal'}</span
|
||||||
|
><small
|
||||||
|
><RelativeTime
|
||||||
|
value={account.lastActivity}
|
||||||
|
/></small
|
||||||
|
>{:else}No completed transfers{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{#if account.pendingOperation}<span
|
||||||
|
>Pending {account.pendingOperation === 'deposit'
|
||||||
|
? 'deposit'
|
||||||
|
: 'withdrawal'}</span
|
||||||
|
><small
|
||||||
|
>{number.format(account.pendingAmount ?? 0)} pts</small
|
||||||
|
>{:else if report.maximum !== null && account.balance >= report.maximum}At
|
||||||
|
limit{:else}Available{/if}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}</tbody
|
||||||
|
>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}<EmptyState
|
||||||
|
title="No bank accounts found"
|
||||||
|
description="Accounts appear when players deposit points in game. Try changing your filters if you expected to see more."
|
||||||
|
/>{/if}
|
||||||
|
{:else if report.query.view === 'transactions'}
|
||||||
|
<p class="hint">
|
||||||
|
Completed deposits and withdrawals across your fleet. Game filters apply to
|
||||||
|
activity; account balances remain shared.
|
||||||
|
</p>
|
||||||
|
<BankTransfers
|
||||||
|
entries={report.transactions.entries}
|
||||||
|
source={$page.url}
|
||||||
|
onPlayer={openPlayer}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">
|
||||||
|
Oldest transfers first. Transfers waiting at least five minutes are highlighted for
|
||||||
|
attention.
|
||||||
|
</p>
|
||||||
|
{#if report.pending.entries.length}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div
|
||||||
|
class="bank-table-scroll"
|
||||||
|
role="region"
|
||||||
|
aria-label="Pending bank transfers"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<table class="bank-table">
|
||||||
|
<caption class="sr-only">Transfers awaiting settlement</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Player</th><th scope="col">Transfer</th><th
|
||||||
|
scope="col">Server</th
|
||||||
|
><th scope="col">Waiting since</th><th scope="col">Details</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody
|
||||||
|
>{#each report.pending.entries as transfer (transfer.guid)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
><button
|
||||||
|
class="bank-player"
|
||||||
|
onclick={() => openPlayer(transfer.guid)}
|
||||||
|
>{transfer.name ?? transfer.guid}</button
|
||||||
|
><small class="bank-id">{transfer.guid}</small></td
|
||||||
|
>
|
||||||
|
<td class="bank-number"
|
||||||
|
><strong>{number.format(transfer.amount)} pts</strong><small
|
||||||
|
>{transfer.operation === 'deposit'
|
||||||
|
? 'Deposit'
|
||||||
|
: 'Withdrawal'}</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
<td><ServerIdentity name={transfer.server} /></td>
|
||||||
|
<td
|
||||||
|
><RelativeTime
|
||||||
|
value={transfer.createdAt}
|
||||||
|
/>{#if isOlder(transfer.createdAt)}<small
|
||||||
|
class="bank-warning">Waiting over 5 minutes</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><details>
|
||||||
|
<summary>Why pending?</summary>
|
||||||
|
<div class="pending-expanded">
|
||||||
|
<BankPendingDetails {transfer} source={$page.url} />
|
||||||
|
</div>
|
||||||
|
</details></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}</tbody
|
||||||
|
>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}<EmptyState
|
||||||
|
title="No pending transfers"
|
||||||
|
description="No transfers are waiting for settlement in this view."
|
||||||
|
/>{/if}
|
||||||
|
{/if}
|
||||||
|
<BankPagination
|
||||||
|
total={results.total}
|
||||||
|
offset={results.offset}
|
||||||
|
disabled={busy}
|
||||||
|
onchange={(offset) => {
|
||||||
|
void navigate({ offset });
|
||||||
|
}}
|
||||||
|
label={report.query.view === 'accounts' ? 'accounts' : 'transfers'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
drawer
|
||||||
|
bind:open={() => !!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}
|
||||||
|
<div class="account-detail">
|
||||||
|
<Freshness
|
||||||
|
{updatedAt}
|
||||||
|
error={refreshError}
|
||||||
|
intervalLabel="every 30 s"
|
||||||
|
retryDisabled={refreshing || busy}
|
||||||
|
onRetry={() => void refresh()}
|
||||||
|
/>
|
||||||
|
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||||
|
<CopyValue value={detail.account.guid} label="Player GUID" />
|
||||||
|
<dl class="detail-summary">
|
||||||
|
<div>
|
||||||
|
<dt>Balance</dt>
|
||||||
|
<dd>{number.format(detail.account.balance)} pts</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Available</dt>
|
||||||
|
<dd>{number.format(detail.account.available)} pts</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if detail.account.name}<Button
|
||||||
|
size="sm"
|
||||||
|
href={contextHref(
|
||||||
|
`/leaderboard/players/${encodeURIComponent(detail.account.guid)}`,
|
||||||
|
$page.url
|
||||||
|
)}>View player profile</Button
|
||||||
|
>{/if}
|
||||||
|
{#if detail.pending}<section class="pending-card">
|
||||||
|
<h3>
|
||||||
|
Pending {detail.pending.operation === 'deposit' ? 'deposit' : 'withdrawal'} ·
|
||||||
|
{number.format(detail.pending.amount)} pts
|
||||||
|
</h3>
|
||||||
|
<BankPendingDetails transfer={detail.pending} source={$page.url} />
|
||||||
|
</section>{/if}
|
||||||
|
<h3>Transaction history</h3>
|
||||||
|
<BankTransfers entries={detail.history.entries} source={$page.url} compact />
|
||||||
|
<BankPagination
|
||||||
|
total={detail.history.total}
|
||||||
|
offset={detail.history.offset}
|
||||||
|
disabled={busy}
|
||||||
|
onchange={(historyOffset) => {
|
||||||
|
void navigate({ historyOffset });
|
||||||
|
}}
|
||||||
|
label="player transfers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}<EmptyState
|
||||||
|
title="Player bank not found"
|
||||||
|
description="There is no bank or player record for this GUID."
|
||||||
|
/>{/if}
|
||||||
|
{#snippet actions()}<Button onclick={() => setDrawerOpen(false)}>Close</Button>{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bank-page,
|
||||||
|
.results,
|
||||||
|
.account-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.summary > div {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
dd small {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.summary-link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.capacity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
meter {
|
||||||
|
width: 100px;
|
||||||
|
height: 12px;
|
||||||
|
accent-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
meter::-webkit-meter-bar {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
meter::-webkit-meter-optimum-value {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
meter::-moz-meter-bar {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pending-expanded {
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 380px;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.detail-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.detail-summary dd {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
.pending-card {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--state-warning);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--state-warning);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.summary {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.summary > div {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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."
|
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">
|
<section class="bank-card" aria-labelledby="bank-title">
|
||||||
|
<div class="bank-heading">
|
||||||
<h2 id="bank-title">Bank</h2>
|
<h2 id="bank-title">Bank</h2>
|
||||||
|
<Button size="sm" href={`/bank?player=${encodeURIComponent(profile.guid)}`}
|
||||||
|
>View bank history</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
|
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
|
||||||
</p>
|
</p>
|
||||||
@@ -184,7 +189,7 @@
|
|||||||
></thead
|
></thead
|
||||||
>
|
>
|
||||||
<tbody
|
<tbody
|
||||||
>{#each data.bank.entries as entry (entry.id)}
|
>{#each data.bank.entries.slice(0, 3) as entry (entry.id)}
|
||||||
<tr
|
<tr
|
||||||
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
|
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
|
||||||
>{serverTitle(entry.server)}</td
|
>{serverTitle(entry.server)}</td
|
||||||
@@ -499,6 +504,22 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.bank-card {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.bank-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.bank-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.profile {
|
.profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import BankPage from '../../src/routes/bank/+page.svelte';
|
||||||
|
import { readBankQuery, type BankOverview } from '../../src/lib/bank.js';
|
||||||
|
|
||||||
|
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
|
||||||
|
const currentPage = writable({ url: new URL('http://localhost/bank'), data: { user: null } });
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
},
|
||||||
|
navigating: writable(null)
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
goto: navigation.goto,
|
||||||
|
beforeNavigate: (fn: () => void) => navigation.before.push(fn),
|
||||||
|
afterNavigate: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
beforeEach(() => {
|
||||||
|
navigation.goto.mockReset().mockResolvedValue(undefined);
|
||||||
|
navigation.before.length = 0;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
guid: '18446744073709551615',
|
||||||
|
name: 'Dempsey',
|
||||||
|
balance: 2000,
|
||||||
|
available: 1500,
|
||||||
|
pendingOperation: 'withdraw' as const,
|
||||||
|
pendingAmount: 500,
|
||||||
|
lastActivity: '2026-10-13T11:00:00.000Z',
|
||||||
|
lastAmount: 2000
|
||||||
|
};
|
||||||
|
const transfer = {
|
||||||
|
id: 1,
|
||||||
|
guid: account.guid,
|
||||||
|
name: account.name,
|
||||||
|
amount: 2000,
|
||||||
|
balance: 2000,
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
game: 't5',
|
||||||
|
matchId: 17,
|
||||||
|
matchAvailable: 1,
|
||||||
|
createdAt: account.lastActivity
|
||||||
|
};
|
||||||
|
function report(search = ''): BankOverview {
|
||||||
|
const query = readBankQuery(new URLSearchParams(search));
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
maximum: 250000,
|
||||||
|
checkedAt: '2026-10-13T12:00:00.000Z',
|
||||||
|
summary: { totalBanked: 2000, fundedAccounts: 1, transfers24h: 1, pending: 1 },
|
||||||
|
servers: [transfer.server],
|
||||||
|
accounts: { entries: [account], total: 1, offset: 0 },
|
||||||
|
transactions: { entries: [transfer], total: 1, offset: 0 },
|
||||||
|
pending: {
|
||||||
|
entries: [{ ...transfer, requestId: 'pending-id', amount: 500, operation: 'withdraw' }],
|
||||||
|
total: 1,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
detail: query.player
|
||||||
|
? {
|
||||||
|
account,
|
||||||
|
pending: null,
|
||||||
|
history: { entries: [transfer], total: 30, offset: query.historyOffset }
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function show(initial = report()) {
|
||||||
|
const data = writable({ report: initial, user: null, setupRequired: false });
|
||||||
|
const state = fromStore(data);
|
||||||
|
currentPage.set({
|
||||||
|
url: new URL(
|
||||||
|
`http://localhost/bank?${new URLSearchParams(Object.entries(initial.query).map(([key, value]) => [key, String(value)]))}`
|
||||||
|
),
|
||||||
|
data: { user: null }
|
||||||
|
});
|
||||||
|
component = mount(BankPage, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get data() {
|
||||||
|
return state.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return (next: BankOverview) => {
|
||||||
|
currentPage.set({
|
||||||
|
url: new URL(
|
||||||
|
`http://localhost/bank?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
|
||||||
|
),
|
||||||
|
data: { user: null }
|
||||||
|
});
|
||||||
|
data.set({ report: next, user: null, setupRequired: false });
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||||
|
|
||||||
|
it('opens player details without losing list filters and restores the list when closed', async () => {
|
||||||
|
const update = show(report('q=Dempsey&sort=recent&offset=25'));
|
||||||
|
button('Dempsey').click();
|
||||||
|
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
|
||||||
|
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
|
||||||
|
expect(lastNavigation().searchParams.get('offset')).toBe('25');
|
||||||
|
update(report(`q=Dempsey&sort=recent&offset=25&player=${account.guid}`));
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelector('dialog')?.open).toBe(true);
|
||||||
|
expect(document.querySelector('dialog')?.textContent).toContain('1,500 pts');
|
||||||
|
const profile = [...document.querySelectorAll('dialog a')].find((link) =>
|
||||||
|
link.textContent?.includes('View player profile')
|
||||||
|
)!;
|
||||||
|
const target = new URL(profile.getAttribute('href')!, 'http://localhost');
|
||||||
|
expect(target.searchParams.get('returnTo')).toContain(`player=${account.guid}`);
|
||||||
|
button('Next').click();
|
||||||
|
expect(lastNavigation().searchParams.get('historyOffset')).toBe('25');
|
||||||
|
expect(lastNavigation().searchParams.get('offset')).toBe('25');
|
||||||
|
button('Close').click();
|
||||||
|
expect(lastNavigation().searchParams.has('player')).toBe(false);
|
||||||
|
expect(lastNavigation().searchParams.get('sort')).toBe('recent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies accessible transfer filters and resets pagination while preserving player detail', () => {
|
||||||
|
show(report(`view=transactions&offset=25&player=${account.guid}`));
|
||||||
|
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
|
||||||
|
search.value = 'Nikolai';
|
||||||
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const game = document.querySelector<HTMLSelectElement>('select[name="game"]')!;
|
||||||
|
// happy-dom's :checked selector only matches inputs, not selected options.
|
||||||
|
const selectQuery = game.querySelector.bind(game);
|
||||||
|
game.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (game.selectedOptions[0] ?? null)
|
||||||
|
: selectQuery(selector)) as typeof game.querySelector;
|
||||||
|
game.value = 't6';
|
||||||
|
game.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
document
|
||||||
|
.querySelector('form')!
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
|
||||||
|
expect(lastNavigation().searchParams.get('game')).toBe('t6');
|
||||||
|
expect(lastNavigation().searchParams.has('offset')).toBe(false);
|
||||||
|
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
|
||||||
|
button('Reset').click();
|
||||||
|
expect(lastNavigation().searchParams.has('game')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pending reservation guidance, age and historical matches without dead links', () => {
|
||||||
|
const initial = report('view=pending');
|
||||||
|
initial.pending.entries[0].matchAvailable = 0;
|
||||||
|
show(initial);
|
||||||
|
const panel = document.querySelector('#bank-results')!;
|
||||||
|
expect(panel.textContent).toContain('Waiting over 5 minutes');
|
||||||
|
expect(panel.textContent).toContain('These points are reserved');
|
||||||
|
expect(panel.textContent).toContain('Match #17 · History expired');
|
||||||
|
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
|
||||||
|
expect(panel.querySelector('details summary')?.textContent).toBe('Why pending?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains data on refresh errors and successfully retries without erasing draft filters', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
|
||||||
|
search.value = 'draft';
|
||||||
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
button('Refresh').click();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).toContain('Bank updates paused.'));
|
||||||
|
expect(document.querySelector('#bank-results')?.textContent).toContain('Dempsey');
|
||||||
|
button('Retry').click();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).not.toContain('Bank updates paused.'));
|
||||||
|
expect(search.value).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards an old refresh when navigation changes the bank view', async () => {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const update = show();
|
||||||
|
button('Refresh').click();
|
||||||
|
navigation.before.forEach((fn) => fn());
|
||||||
|
const next = report('view=transactions');
|
||||||
|
next.summary.totalBanked = 9000;
|
||||||
|
update(next);
|
||||||
|
resolve(new Response(JSON.stringify(report())));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelector('.summary')?.textContent).toContain('9,000');
|
||||||
|
expect(document.querySelector('#bank-tab-transactions')?.getAttribute('aria-selected')).toBe(
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('#bank-results')?.textContent).toContain('Balance after');
|
||||||
|
});
|
||||||
@@ -55,6 +55,8 @@ describe('management route permissions', () => {
|
|||||||
'/api/servers/[name]/scripts',
|
'/api/servers/[name]/scripts',
|
||||||
'/api/metrics',
|
'/api/metrics',
|
||||||
'/metrics',
|
'/metrics',
|
||||||
|
'/bank',
|
||||||
|
'/api/bank',
|
||||||
'/api/matches',
|
'/api/matches',
|
||||||
'/matches/[id]',
|
'/matches/[id]',
|
||||||
'/leaderboard/players/[game]/[guid]',
|
'/leaderboard/players/[game]/[guid]',
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { readBankQuery, type BankQuery } from '../../src/lib/bank.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
let overview: typeof import('../../src/lib/server/fleet/bank-overview.js').bankOverview;
|
||||||
|
const now = new Date('2026-10-13T12:00:00.000Z');
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const other = 't6_zm_transit_1';
|
||||||
|
const guid = '18446744073709551615';
|
||||||
|
const query = (changes: Partial<BankQuery> = {}) => ({
|
||||||
|
...readBankQuery(new URLSearchParams()),
|
||||||
|
...changes
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
vi.stubEnv('EC_BANK_MAX_BALANCE', '250000');
|
||||||
|
handle = (await import('../../src/lib/server/db/index.js')).db();
|
||||||
|
overview = (await import('../../src/lib/server/fleet/bank-overview.js')).bankOverview;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-13T10:00:00.000Z') {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES (?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(game, id, name, seen, seen);
|
||||||
|
}
|
||||||
|
function fund(id = guid, balance = 2000) {
|
||||||
|
handle.prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run(id, balance);
|
||||||
|
}
|
||||||
|
function transfer(
|
||||||
|
id = guid,
|
||||||
|
amount = 2000,
|
||||||
|
source = server,
|
||||||
|
createdAt = now.toISOString(),
|
||||||
|
matchId: number | null = null
|
||||||
|
) {
|
||||||
|
return handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(id, source, `ledger-${serial++}`, matchId, amount, 2000, createdAt).lastInsertRowid;
|
||||||
|
}
|
||||||
|
let serial = 0;
|
||||||
|
function prepare(
|
||||||
|
id = guid,
|
||||||
|
operation = 'withdraw',
|
||||||
|
amount = 500,
|
||||||
|
createdAt = '2026-10-13T11:50:00.000Z'
|
||||||
|
) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,amount,state,result,created_at)
|
||||||
|
VALUES (?,?,?,'private-fingerprint',?,?,'prepared','private-result',?)`
|
||||||
|
)
|
||||||
|
.run(server, `pending-${serial++}`, id, operation, amount, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('deduplicates game identities, preserves exact GUIDs and exposes reserved withdrawal funds', () => {
|
||||||
|
identity();
|
||||||
|
identity(guid, 'Latest name', 't6', '2026-10-13T11:00:00.000Z');
|
||||||
|
identity('00042', 'Leading zero');
|
||||||
|
identity('42', 'Separate account');
|
||||||
|
fund();
|
||||||
|
fund('00042', 100);
|
||||||
|
fund('42', 0);
|
||||||
|
transfer();
|
||||||
|
prepare();
|
||||||
|
const report = overview(query({ player: guid }), now);
|
||||||
|
expect(report.accounts.total).toBe(3);
|
||||||
|
expect(report.accounts.entries[0]).toMatchObject({
|
||||||
|
guid,
|
||||||
|
name: 'Latest name',
|
||||||
|
balance: 2000,
|
||||||
|
available: 1500,
|
||||||
|
pendingAmount: 500
|
||||||
|
});
|
||||||
|
expect(report.detail?.account).toEqual(report.accounts.entries[0]);
|
||||||
|
expect(report.summary).toEqual({
|
||||||
|
totalBanked: 2100,
|
||||||
|
fundedAccounts: 2,
|
||||||
|
transfers24h: 1,
|
||||||
|
pending: 1
|
||||||
|
});
|
||||||
|
expect(overview(query({ player: '42' }), now).detail?.account.balance).toBe(0);
|
||||||
|
expect(overview(query({ player: '00042' }), now).detail?.account.balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes first deposits awaiting settlement without crediting them to balances', () => {
|
||||||
|
identity();
|
||||||
|
prepare(guid, 'deposit', 5000);
|
||||||
|
const report = overview(query({ balance: 'pending', player: guid }), now);
|
||||||
|
expect(report.accounts.entries).toHaveLength(1);
|
||||||
|
expect(report.accounts.entries[0]).toMatchObject({
|
||||||
|
balance: 0,
|
||||||
|
available: 0,
|
||||||
|
pendingOperation: 'deposit',
|
||||||
|
lastActivity: null
|
||||||
|
});
|
||||||
|
expect(report.summary).toMatchObject({
|
||||||
|
totalBanked: 0,
|
||||||
|
fundedAccounts: 0,
|
||||||
|
pending: 1,
|
||||||
|
transfers24h: 0
|
||||||
|
});
|
||||||
|
expect(report.detail?.pending).toMatchObject({ amount: 5000, operation: 'deposit' });
|
||||||
|
expect(report.detail?.history.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters and sorts accounts with literal searches and keeps summaries fleet-wide', () => {
|
||||||
|
identity();
|
||||||
|
identity('other', '100%_Player');
|
||||||
|
fund();
|
||||||
|
fund('other', 250000);
|
||||||
|
transfer(guid, 2000, server, '2026-10-13T11:00:00.000Z');
|
||||||
|
transfer('other', 1000, other, '2026-10-12T10:00:00.000Z');
|
||||||
|
expect(overview(query({ q: '%_' }), now).accounts.entries.map((row) => row.guid)).toEqual([
|
||||||
|
'other'
|
||||||
|
]);
|
||||||
|
expect(overview(query({ q: "' OR 1=1 --" }), now).accounts.total).toBe(0);
|
||||||
|
expect(overview(query({ q: 'dEmP' }), now).accounts.entries[0].guid).toBe(guid);
|
||||||
|
expect(
|
||||||
|
overview(query({ balance: 'limit' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual(['other']);
|
||||||
|
expect(
|
||||||
|
overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual([guid, 'other']);
|
||||||
|
expect(overview(query({ q: 'not-found' }), now).summary).toMatchObject({
|
||||||
|
totalBanked: 252000,
|
||||||
|
transfers24h: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters transfer activity by game, server, direction and inclusive UTC dates without narrowing player balances', () => {
|
||||||
|
identity();
|
||||||
|
fund();
|
||||||
|
transfer(guid, 3000, server, '2026-10-12T23:59:59.999Z');
|
||||||
|
transfer(guid, -1000, other, '2026-10-13T00:00:00.000Z');
|
||||||
|
transfer(guid, 500, other, '2026-10-13T23:59:59.999Z');
|
||||||
|
transfer(guid, 100, other, '2026-10-14T00:00:00.000Z');
|
||||||
|
const report = overview(
|
||||||
|
query({
|
||||||
|
view: 'transactions',
|
||||||
|
game: 't6',
|
||||||
|
server: other,
|
||||||
|
from: '2026-10-13',
|
||||||
|
to: '2026-10-13',
|
||||||
|
player: guid
|
||||||
|
}),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
expect(report.transactions.entries.map((row) => row.amount)).toEqual([500, -1000]);
|
||||||
|
expect(report.detail?.account.balance).toBe(2000);
|
||||||
|
expect(report.detail?.history.total).toBe(4);
|
||||||
|
expect(
|
||||||
|
overview(
|
||||||
|
query({ view: 'transactions', operation: 'withdraw' }),
|
||||||
|
now
|
||||||
|
).transactions.entries.map((row) => row.amount)
|
||||||
|
).toEqual([-1000]);
|
||||||
|
expect(overview(query({ view: 'transactions', game: 't5' }), now).transactions.total).toBe(1);
|
||||||
|
expect(
|
||||||
|
overview(query({ view: 'transactions', server: 'absent' }), now).transactions.total
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps historical transfers searchable after match and player records are removed', () => {
|
||||||
|
identity();
|
||||||
|
fund();
|
||||||
|
const matchId = Number(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||||
|
VALUES (?,'t5','theater','theater','Kino',?,?,'game_start','fingerprint')`
|
||||||
|
)
|
||||||
|
.run(server, now.toISOString(), now.toISOString()).lastInsertRowid
|
||||||
|
);
|
||||||
|
transfer(guid, 2000, server, now.toISOString(), matchId);
|
||||||
|
expect(
|
||||||
|
overview(query({ view: 'transactions' }), now).transactions.entries[0].matchAvailable
|
||||||
|
).toBe(1);
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches WHERE id = ?').run(matchId);
|
||||||
|
handle.prepare('DELETE FROM leaderboard_players WHERE guid = ?').run(guid);
|
||||||
|
const report = overview(
|
||||||
|
query({ view: 'transactions', game: 't5', q: guid, player: guid }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
expect(report.transactions.entries[0]).toMatchObject({
|
||||||
|
guid,
|
||||||
|
name: null,
|
||||||
|
matchId,
|
||||||
|
matchAvailable: 0,
|
||||||
|
game: 't5'
|
||||||
|
});
|
||||||
|
expect(report.detail?.account.balance).toBe(2000);
|
||||||
|
expect(report.servers).toEqual([server]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pages accounts and player history independently with stable ties and clamps stale offsets', () => {
|
||||||
|
for (let i = 0; i < 31; i++) {
|
||||||
|
fund(String(i).padStart(2, '0'), 100);
|
||||||
|
transfer('00', 100);
|
||||||
|
}
|
||||||
|
const first = overview(query({ player: '00' }), now);
|
||||||
|
const second = overview(query({ offset: 999999, player: '00', historyOffset: 999999 }), now);
|
||||||
|
expect(first.accounts.entries).toHaveLength(25);
|
||||||
|
expect(second.accounts).toMatchObject({ total: 31, offset: 25 });
|
||||||
|
expect(second.accounts.entries).toHaveLength(6);
|
||||||
|
expect(
|
||||||
|
new Set([...first.accounts.entries, ...second.accounts.entries].map((row) => row.guid)).size
|
||||||
|
).toBe(31);
|
||||||
|
expect(second.detail?.history).toMatchObject({ total: 31, offset: 25 });
|
||||||
|
expect(
|
||||||
|
new Set(
|
||||||
|
[...first.detail!.history.entries, ...second.detail!.history.entries].map(
|
||||||
|
(row) => row.id
|
||||||
|
)
|
||||||
|
).size
|
||||||
|
).toBe(31);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders pending transfers oldest first and excludes cancelled requests and private request data', () => {
|
||||||
|
prepare('new', 'deposit', 100, '2026-10-13T11:59:00.000Z');
|
||||||
|
prepare('old', 'withdraw', 200, '2026-10-12T11:00:00.000Z');
|
||||||
|
prepare('cancelled');
|
||||||
|
handle.prepare("UPDATE bank_requests SET state = 'cancelled' WHERE guid = 'cancelled'").run();
|
||||||
|
const report = overview(query({ view: 'pending' }), now);
|
||||||
|
expect(report.pending.entries.map((row) => row.guid)).toEqual(['old', 'new']);
|
||||||
|
expect(
|
||||||
|
overview(query({ view: 'pending', operation: 'deposit' }), now).pending.entries.map(
|
||||||
|
(row) => row.guid
|
||||||
|
)
|
||||||
|
).toEqual(['new']);
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(
|
||||||
|
/private-fingerprint|private-result|instance_id|token/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps existing accounts readable when the deposit limit changes or becomes invalid', () => {
|
||||||
|
fund(guid, 250000);
|
||||||
|
prepare();
|
||||||
|
vi.stubEnv('EC_BANK_MAX_BALANCE', '100000');
|
||||||
|
expect(overview(query({ balance: 'limit' }), now).accounts.total).toBe(1);
|
||||||
|
vi.stubEnv('EC_BANK_MAX_BALANCE', 'invalid');
|
||||||
|
const report = overview(query({ player: guid }), now);
|
||||||
|
expect(report.maximum).toBeNull();
|
||||||
|
expect(report.detail?.account).toMatchObject({ balance: 250000, available: 249500 });
|
||||||
|
expect(report.summary.pending).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows known players with no bank history, distinguishes unknown GUIDs, and serves uncached reads', async () => {
|
||||||
|
identity();
|
||||||
|
expect(overview(query({ player: guid }), now).detail?.account.balance).toBe(0);
|
||||||
|
expect(overview(query({ player: 'unknown' }), now).detail).toBeNull();
|
||||||
|
const { GET } = await import('../../src/routes/api/bank/+server.js');
|
||||||
|
const { load } = await import('../../src/routes/bank/+page.server.js');
|
||||||
|
const url = new URL(`https://test/bank?player=${guid}`);
|
||||||
|
const response = GET({ url });
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect((await response.json()).detail.account.guid).toBe(guid);
|
||||||
|
expect(load({ url }).report.detail?.account.guid).toBe(guid);
|
||||||
|
for (const invalid of ['view=unknown', 'offset=-1', 'from=2026-02-30']) {
|
||||||
|
expect(() => GET({ url: new URL(`https://test/api/bank?${invalid}`) })).toThrow(
|
||||||
|
expect.objectContaining({ status: 400 })
|
||||||
|
);
|
||||||
|
expect(() => load({ url: new URL(`https://test/bank?${invalid}`) })).toThrow(
|
||||||
|
expect.objectContaining({ status: 400 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bank query validation', () => {
|
||||||
|
it.each([
|
||||||
|
'view=nope',
|
||||||
|
'balance=nope',
|
||||||
|
'game=t7',
|
||||||
|
'sort=nope',
|
||||||
|
'operation=commit',
|
||||||
|
'offset=NaN',
|
||||||
|
'offset=1.5',
|
||||||
|
'historyOffset=Infinity',
|
||||||
|
'from=2026-02-30',
|
||||||
|
'to=2026-13-01',
|
||||||
|
'from=2026-10-14&to=2026-10-13',
|
||||||
|
`q=${'a'.repeat(101)}`
|
||||||
|
])('rejects %s', (value) => {
|
||||||
|
expect(() => readBankQuery(new URLSearchParams(value))).toThrow();
|
||||||
|
});
|
||||||
|
it('retains string identities and normalizes page boundaries', () => {
|
||||||
|
expect(
|
||||||
|
readBankQuery(new URLSearchParams(`player=${guid}&offset=27&historyOffset=51`))
|
||||||
|
).toMatchObject({ player: guid, offset: 25, historyOffset: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js';
|
import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js';
|
||||||
|
|
||||||
describe('navigation context', () => {
|
describe('navigation context', () => {
|
||||||
|
it('restores Bank filters, pagination and the selected player from detail pages', () => {
|
||||||
|
const source = '/bank?view=transactions&game=t5&offset=25&player=00042&historyOffset=25';
|
||||||
|
const target = new URL(contextHref('/matches/17', source), 'http://localhost');
|
||||||
|
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
|
||||||
|
expect(returnLabel(source)).toBe('Back to Bank');
|
||||||
|
});
|
||||||
it('retains nested origins, list scope, loaded results and anchors', () => {
|
it('retains nested origins, list scope, loaded results and anchors', () => {
|
||||||
const source = new URL(
|
const source = new URL(
|
||||||
'http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title'
|
'http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('application navigation', () => {
|
|||||||
['/campaigns/42', 'releases', '/campaigns'],
|
['/campaigns/42', 'releases', '/campaigns'],
|
||||||
['/configs/42', 'releases', '/configs'],
|
['/configs/42', 'releases', '/configs'],
|
||||||
['/leaderboard/players/42', 'players', '/leaderboard'],
|
['/leaderboard/players/42', 'players', '/leaderboard'],
|
||||||
|
['/bank', 'players', '/bank'],
|
||||||
['/settings/users', 'admin', '/settings/users'],
|
['/settings/users', 'admin', '/settings/users'],
|
||||||
['/server-keys', 'admin', '/server-keys']
|
['/server-keys', 'admin', '/server-keys']
|
||||||
])('gives %s exactly one canonical home', (path, group, href) => {
|
])('gives %s exactly one canonical home', (path, group, href) => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ describe('sidebar navigation', () => {
|
|||||||
expect(body).toContain('href="/betas"');
|
expect(body).toContain('href="/betas"');
|
||||||
expect(body).toContain('href="/activity"');
|
expect(body).toContain('href="/activity"');
|
||||||
expect(body).toContain('href="/metrics"');
|
expect(body).toContain('href="/metrics"');
|
||||||
|
expect(body).toContain('href="/bank"');
|
||||||
expect(body).not.toContain('href="/settings"');
|
expect(body).not.toContain('href="/settings"');
|
||||||
expect(body).not.toContain('href="/server-keys"');
|
expect(body).not.toContain('href="/server-keys"');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user