export const afkPageSize = 25; export const afkLedgerKinds = [ 'free_grant', 'purchase', 'reward', 'reserve', 'start', 'release', 'unresolved', 'consume', 'correction' ] as const; export type AfkLedgerKind = (typeof afkLedgerKinds)[number]; export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected'; export interface AfkQuery { view: 'accounts' | 'sessions' | 'ledger'; q: string; balance: '' | 'saved' | 'reserved' | 'unresolved'; sort: 'saved' | 'available' | 'recent'; game: '' | 't5' | 't6'; server: string; state: '' | AfkSessionState; kind: '' | AfkLedgerKind; from: string; to: string; offset: number; player: string; } export function readAfkQuery(params: URLSearchParams): AfkQuery { function choice(key: string, values: readonly T[], fallback: T): T { const value = params.get(key) || fallback; if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`); return value as T; } function text(key: string, max: number) { const value = (params.get(key) ?? '').trim(); if (value.length > max) throw new Error(`${key} is too long.`); return value; } function 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 offset = Number(params.get('offset') || 0); if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.'); 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', 'sessions', 'ledger'], 'accounts'), q: text('q', 100), balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''), sort: choice('sort', ['saved', 'available', 'recent'], 'saved'), game: choice('game', ['', 't5', 't6'], ''), server: text('server', 256), state: choice( 'state', ['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'], '' ), kind: choice('kind', ['', ...afkLedgerKinds], ''), from, to, offset: Math.floor(offset / afkPageSize) * afkPageSize, player: text('player', 128) }; } export interface AfkIdentity { guid: string; name: string | null; } export interface AfkAccount extends AfkIdentity { freeSeconds: number; savedSeconds: number; availableSeconds: number; reservedSeconds: number; reservedSavedSeconds: number; unresolvedSessions: number; lastActivity: string | null; freeRenewsAt: string | null; } export interface AfkSession extends AfkIdentity { sessionId: string; server: string; game: string | null; matchId: number | null; matchAvailable: number; state: AfkSessionState; authorizedSeconds: number; consumedSeconds: number | null; chargedSeconds: number | null; confirmedSeconds: number; endReason: string | null; createdAt: string; startedAt: string | null; endedAt: string | null; } export interface AfkLedgerEntry extends AfkIdentity { id: number; server: string; game: string | null; matchId: number | null; matchAvailable: number; kind: AfkLedgerKind; freeDelta: number; savedDelta: number; freeSeconds: number; savedSeconds: number; operationId: string; sessionId: string | null; eventAt: string; createdAt: string; } export interface AfkPage { entries: T[]; total: number; offset: number; } export interface AfkOverview { query: AfkQuery; checkedAt: string; capacitySeconds: number; summary: { accounts: number; savedSeconds: number; reservedSeconds: number; unresolvedSessions: number; }; servers: string[]; accounts: AfkPage; sessions: AfkPage; ledger: AfkPage; }