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

This commit is contained in:
2026-10-12 20:21:58 -04:00
parent bcd3b99ee4
commit e071e5b574
14 changed files with 866 additions and 1 deletions
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { routePermission } from '../../src/lib/server/auth/access.js';
let fixture: Fixture;
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
const secret = 'ec_bank_' + 'a'.repeat(40);
const otherSecret = 'ec_bank_' + 'b'.repeat(40);
let serial = 0;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([server, other]);
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret, [other]: otherSecret }));
vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40));
vi.stubEnv('EC_BANK_MAX_BALANCE', '250000');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
await seedServerRegistry();
for (const source of [server, other]) {
saveTelemetry({
server: source,
map: source === server ? 'zombie_theater' : 'zm_transit',
match: 'match-one',
seq: 1,
event: 'game_start',
players: [{ guid: 'ace', name: 'Ace' }],
receivedAt: new Date().toISOString()
});
}
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
const transfer = (operation = 'deposit', amount: number | string = 2000, source = server) => ({
server: source,
guid: 'ace',
match: 'match-one',
request_id: `request-${++serial}`,
operation,
amount,
capacity: 1000000
});
async function post(action: string, body: unknown, credential = secret) {
const { POST } = await import('../../src/routes/api/bank/[action]/+server.js');
const response = await POST({
params: { action },
request: new Request(`https://example.test/api/bank/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}` },
body: JSON.stringify(body)
})
});
expect(response.headers.get('cache-control')).toBe('no-store');
return { status: response.status, ...(await response.json()) };
}
async function fund(amount = 2000) {
const data = transfer('deposit', amount);
expect(await post('deposit', data)).toMatchObject({ state: 'prepared', amount });
expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: amount });
return data;
}
it('routes only bank POSTs as machine requests, authenticating before reading bodies', async () => {
expect(routePermission('/api/bank/[action]', 'POST')).toBe('machine');
expect(routePermission('/api/bank/[action]', 'GET')).toBe('viewer');
const { POST } = await import('../../src/routes/api/bank/[action]/+server.js');
for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!, otherSecret]) {
const request = new Request('https://test/api/bank/deposit', {
method: 'POST',
headers: { Authorization: `Bearer ${credential}` },
body: 'invalid JSON'
});
if (credential === otherSecret) continue;
expect((await POST({ request, params: { action: 'deposit' } })).status).toBe(401);
expect(request.bodyUsed).toBe(false);
}
expect(await post('deposit', transfer(), otherSecret)).toMatchObject({
status: 403,
code: 'wrong_server'
});
});
it('fails closed for missing, duplicate, telemetry-reused and invalid credentials', async () => {
for (const configured of [
'',
'{}',
'[]',
'{',
JSON.stringify({ [server]: secret, [other]: secret }),
JSON.stringify({ [server]: 'short' })
]) {
vi.stubEnv('EC_BANK_TOKENS', configured);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
}
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret }));
vi.stubEnv('EC_TELEMETRY_TOKEN', secret);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
});
it('persists one credit and one ledger row through lost replies, retries and database reopen', async () => {
const data = transfer();
const initial = await post('deposit', data);
expect(await post('deposit', data)).toEqual(initial);
const first = await post('commit', data);
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(await post('commit', data)).toEqual(first);
expect(await post('deposit', data)).toEqual(first);
expect(db().prepare('SELECT amount,balance FROM bank_ledger').all()).toEqual([
{ amount: 2000, balance: 2000 }
]);
expect(await post('deposit', { ...data, amount: 1 })).toMatchObject({
status: 409,
code: 'request_conflict'
});
});
it('shares one GUID balance across games and preserves GUID strings', async () => {
await fund();
expect(await post('balance', { server: other, guid: 'ace' }, otherSecret)).toMatchObject({
balance: 2000
});
const data = transfer('withdraw', 'all', other);
expect(await post('withdraw', data, otherSecret)).toMatchObject({
state: 'prepared',
amount: 2000
});
expect(await post('commit', data, otherSecret)).toMatchObject({
balance: 0,
state: 'committed'
});
const big = { ...transfer(), guid: '18446744073709551615' };
expect(await post('deposit', big)).toMatchObject({ code: 'not_in_match' });
});
it('rejects overdraws, maximum overflow, invalid amounts and point overflow without a ledger change', async () => {
await fund();
for (const [operation, amount, code] of [
['withdraw', 2001, 'insufficient_balance'],
['deposit', 250000, 'bank_limit']
] as const) {
const data = transfer(operation, amount);
const denied = await post(operation, data);
expect(denied).toMatchObject({ ok: false, state: 'rejected', code });
expect(await post(operation, data)).toEqual(denied);
}
expect(await post('withdraw', { ...transfer('withdraw', 'all'), capacity: 100 })).toMatchObject(
{ code: 'points_limit' }
);
for (const amount of [0, -1, 1.5, 1000001, '2000', 'all', null]) {
expect(await post('deposit', { ...transfer(), amount })).toMatchObject({ status: 400 });
}
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 2000, pending: false });
expect(bankProfile('ace').entries).toHaveLength(1);
});
it('requires a present player in the exact current active match', async () => {
expect(await post('deposit', { ...transfer(), guid: 'outsider' })).toMatchObject({
code: 'not_in_match'
});
expect(await post('deposit', { ...transfer(), match: 'old-match' })).toMatchObject({
code: 'not_in_match'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE leaderboard_match_players SET present = 0').run();
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
db().prepare('UPDATE leaderboard_match_players SET present = 1').run();
db().prepare("UPDATE leaderboard_matches SET status = 'completed'").run();
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
});
it('cancels uncertain prepares, including cancellation arriving before the prepare', async () => {
await fund();
for (const cancelFirst of [false, true]) {
const data = transfer('withdraw', 500);
if (!cancelFirst) expect(await post('withdraw', data)).toMatchObject({ state: 'prepared' });
const cancelled = await post('cancel', data);
expect(cancelled).toMatchObject({ state: 'cancelled' });
expect(await post('cancel', data)).toEqual(cancelled);
expect(await post('withdraw', data)).toEqual(cancelled);
expect(await post('commit', data)).toEqual(cancelled);
}
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 2000, available: 2000, pending: false });
expect(bankProfile('ace').entries).toHaveLength(1);
});
it('serializes transfers across servers and reserves available funds until settled', async () => {
await fund();
const data = transfer('withdraw', 1500);
await post('withdraw', data);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({
balance: 2000,
amount: 500,
code: 'pending'
});
expect(await post('withdraw', transfer('withdraw', 1000, other), otherSecret)).toMatchObject({
code: 'pending'
});
await post('commit', data);
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({
balance: 500,
amount: 500
});
});
it('honors prepared deposits after configuration changes and match/server deletion', async () => {
const data = transfer();
await post('deposit', data);
vi.stubEnv('EC_BANK_MAX_BALANCE', '100');
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('DELETE FROM leaderboard_matches').run();
db().prepare('DELETE FROM servers').run();
expect(await post('commit', data)).toMatchObject({ state: 'committed', balance: 2000 });
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace').entries[0]).toMatchObject({
amount: 2000,
matchAvailable: 0,
server
});
expect(await post('deposit', transfer())).toMatchObject({ code: 'not_in_match' });
expect(await post('cancel', data)).toMatchObject({ state: 'committed' });
});
it('rolls back all writes if the ledger cannot be saved', async () => {
const data = transfer();
await post('deposit', data);
const { db } = await import('../../src/lib/server/db/index.js');
db().exec(
"CREATE TRIGGER reject_bank BEFORE INSERT ON bank_ledger BEGIN SELECT RAISE(ABORT,'test'); END"
);
expect(await post('commit', data)).toMatchObject({ status: 503 });
const { bankProfile } = await import('../../src/lib/server/fleet/bank.js');
expect(bankProfile('ace')).toMatchObject({ balance: 0, pending: true, entries: [] });
db().exec('DROP TRIGGER reject_bank');
expect(await post('commit', data)).toMatchObject({ balance: 2000 });
});
it('returns the original settled result after later transfers change the account', async () => {
const deposited = await fund();
const withdrawal = transfer('withdraw', 500);
await post('withdraw', withdrawal);
expect(await post('commit', withdrawal)).toMatchObject({ balance: 1500 });
expect(await post('commit', deposited)).toMatchObject({ balance: 2000 });
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ balance: 1500 });
});
it('concurrent preparations cannot reserve the same funds twice', async () => {
await fund();
const first = transfer('withdraw', 2000);
const second = transfer('withdraw', 2000, other);
const results = await Promise.all([
post('withdraw', first),
post('withdraw', second, otherSecret)
]);
expect(results.filter((result) => result.state === 'prepared')).toHaveLength(1);
expect(results.filter((result) => result.code === 'pending')).toHaveLength(1);
const index = results.findIndex((result) => result.state === 'prepared');
expect(
await post('commit', index === 0 ? first : second, index === 0 ? secret : otherSecret)
).toMatchObject({ balance: 0 });
});
+61
View File
@@ -91,6 +91,7 @@ it('shows the same ranks in the desktop profile with lifetime map scope explaine
props: {
data: {
profile,
bank: { balance: 0, available: 0, pending: false, entries: [] },
returnTo: '/leaderboard',
scope: { game: '', map: '', players: '2', metric: 'kills', entry: null }
}
@@ -135,6 +136,7 @@ it('renders a known player without qualifying history with identity and unavaila
props: {
data: {
profile,
bank: { balance: 0, available: 0, pending: false, entries: [] },
returnTo: '/leaderboard',
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
}
@@ -159,3 +161,62 @@ it('renders a known player without qualifying history with identity and unavaila
expect(body).not.toContain('<table');
expect(body).not.toContain('NaN');
});
it('shows the shared balance and ledger with retained and expired match references', () => {
const profile: PlayerProfile = {
guid: '101',
name: 'Cherry',
firstSeen: evidence.startedAt,
lastSeen: evidence.endedAt!,
stats,
records: map.records,
maps: [],
matches: [],
matchesTotal: 0,
limit: 25,
offset: 0
};
const entry = {
id: 1,
server: evidence.server,
amount: 2000,
balance: 2000,
createdAt: evidence.startedAt,
matchId: 71,
matchAvailable: 1
};
const body = render(PlayerProfilePage, {
props: {
data: {
profile,
bank: {
balance: 1500,
available: 1000,
pending: true,
entries: [
{
...entry,
id: 2,
amount: -500,
balance: 1500,
matchId: 72,
matchAvailable: 0
},
entry
]
},
returnTo: '/leaderboard',
scope: { game: '', map: '', metric: 'highestRound', entry: null }
}
}
}).body;
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('1,500 points');
expect(text).toContain('shared across Black Ops I and II');
expect(text).toContain('Available: 1,000 points');
expect(text).toContain('+2,000');
expect(text).toContain('-500');
expect(text).toContain('#72 (history expired)');
expect(body).toContain('href="/matches/71"');
expect(body).not.toContain('href="/matches/72"');
});