Add AFK account and session overview (Refs #151)
CI / checks (push) Successful in 7m19s

This commit is contained in:
2026-10-17 17:52:03 -04:00
parent d723f23d67
commit 59f88ac33d
22 changed files with 2035 additions and 6 deletions
+3 -1
View File
@@ -99,7 +99,9 @@ An administrator can reconcile an explicitly stopped `unresolved` session with `
The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration. The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration.
The existing player profile access controls protect balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion. Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls.
The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion.
## Coordinated rollout and validation ## Coordinated rollout and validation
+143
View File
@@ -0,0 +1,143 @@
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<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 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<T> {
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<AfkAccount>;
sessions: AfkPage<AfkSession>;
ledger: AfkPage<AfkLedgerEntry>;
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { activityRedirectHref } from './activity-redirect.js';
const origin = 'https://electric-cherry.invalid'; const origin = 'https://electric-cherry.invalid';
const pagePath = const pagePath =
/^\/(?:$|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\/[^/]+(?:\/[^/]+)?)?)$/; /^\/(?:$|activity|operations|autoscaling|account|bank|afk|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 {
@@ -63,6 +63,7 @@ export function returnLabel(href: string): string {
provisioning: 'Server queues', provisioning: 'Server queues',
leaderboard: 'Leaderboard', leaderboard: 'Leaderboard',
bank: 'Bank', bank: 'Bank',
afk: 'AFK',
matches: 'Match History', matches: 'Match History',
metrics: 'Metrics' metrics: 'Metrics'
}; };
+1
View File
@@ -72,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
{ 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: 'Bank', href: '/bank' },
{ label: 'AFK', href: '/afk' },
{ label: 'Moderation', href: '/moderation', moderation: true }, { label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' } { label: 'Usage', href: '/metrics', shortcut: 'm' }
] ]
+197
View File
@@ -0,0 +1,197 @@
import { db } from '../db/index.js';
import { afkPolicy } from './afk.js';
import {
afkPageSize,
type AfkAccount,
type AfkLedgerEntry,
type AfkOverview,
type AfkPage,
type AfkQuery,
type AfkSession
} from '../../afk.js';
const day = 86_400_000;
// A string GUID owns one reserve, including players who appear 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
)`;
// Mirror freeAvailability in afk.ts: held time never expires, and confirmed free
// consumption becomes available 24 hours after its durable stop receipt.
const accounts = `${identities}, session_totals AS (
SELECT guid,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END) AS reserved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN saved_seconds ELSE 0 END) AS reserved_saved,
SUM(CASE WHEN state = 'unresolved' THEN 1 ELSE 0 END) AS unresolved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
WHEN free_charge_at > ? THEN MIN(free_seconds, charged_seconds) ELSE 0 END) AS free_used,
MIN(CASE WHEN free_charge_at > ? AND MIN(free_seconds, charged_seconds) > 0 THEN free_charge_at END) AS next_free_at,
MAX(MAX(created_at, COALESCE(started_at, created_at), COALESCE(ended_at, created_at))) AS last_activity
FROM afk_sessions GROUP BY guid
), ledger_totals AS (
SELECT guid, MAX(created_at) AS last_activity FROM afk_ledger GROUP BY guid
), account_guids AS (
SELECT guid FROM afk_accounts UNION SELECT guid FROM session_totals UNION SELECT guid FROM ledger_totals
), balances AS (
SELECT g.guid, p.name,
CASE WHEN a.guid IS NOT NULL THEN MAX(0, ? - COALESCE(s.free_used, 0)) ELSE 0 END AS freeSeconds,
COALESCE(a.saved_seconds, 0) AS savedSeconds,
COALESCE(s.reserved, 0) AS reservedSeconds,
COALESCE(s.reserved_saved, 0) AS reservedSavedSeconds,
COALESCE(s.unresolved, 0) AS unresolvedSessions,
NULLIF(MAX(COALESCE(s.last_activity, ''), COALESCE(l.last_activity, '')), '') AS lastActivity,
strftime('%Y-%m-%dT%H:%M:%fZ', (s.next_free_at + ${day}) / 1000.0, 'unixepoch') AS freeRenewsAt
FROM account_guids g
LEFT JOIN afk_accounts a ON a.guid = g.guid
LEFT JOIN identities p ON p.guid = g.guid
LEFT JOIN session_totals s ON s.guid = g.guid
LEFT JOIN ledger_totals l ON l.guid = g.guid
), accounts AS (
SELECT *, freeSeconds + savedSeconds AS availableSeconds FROM balances
)`;
// Retained canonical server names identify the game after match/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 sessions = `${identities}, sessions AS (
SELECT s.guid, p.name, s.session_id AS sessionId, s.server, ${game('s')} AS game,
s.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, s.state,
s.authorized_seconds AS authorizedSeconds, s.consumed_seconds AS consumedSeconds,
s.charged_seconds AS chargedSeconds, s.confirmed_seconds AS confirmedSeconds,
s.end_reason AS endReason, s.created_at AS createdAt, s.started_at AS startedAt, s.ended_at AS endedAt
FROM afk_sessions s LEFT JOIN identities p ON p.guid = s.guid
LEFT JOIN leaderboard_matches m ON m.id = s.match_id
)`;
const ledger = `${identities}, ledger AS (
SELECT l.guid, p.name, l.id, l.server, ${game('l')} AS game,
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, l.kind,
l.free_delta AS freeDelta, l.saved_delta AS savedDelta,
l.free_seconds AS freeSeconds, l.saved_seconds AS savedSeconds,
l.operation_id AS operationId, l.session_id AS sessionId,
l.event_at AS eventAt, l.created_at AS createdAt
FROM afk_ledger l LEFT JOIN identities p ON p.guid = l.guid
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
)`;
const emptyPage = <T>(): AfkPage<T> => ({ entries: [], total: 0, offset: 0 });
/** Read-only fleet report; reads never grant, settle, or update AFK balances. */
export function afkOverview(query: AfkQuery, now = new Date()): AfkOverview {
const handle = db();
return handle.transaction(() => {
const policy = afkPolicy();
const checkedAt = now.toISOString();
const accountValues = [now.getTime() - day, now.getTime() - day, policy.freeSeconds];
const summary = handle
.prepare(
`SELECT
(SELECT COUNT(*) FROM (SELECT guid FROM afk_accounts UNION SELECT guid FROM afk_sessions UNION SELECT guid FROM afk_ledger)) AS accounts,
(SELECT COALESCE(SUM(saved_seconds), 0) FROM afk_accounts) AS savedSeconds,
(SELECT COALESCE(SUM(authorized_seconds), 0) FROM afk_sessions WHERE state IN ('reserved','started','unresolved')) AS reservedSeconds,
(SELECT COUNT(*) FROM afk_sessions WHERE state = 'unresolved') AS unresolvedSessions`
)
.get() as AfkOverview['summary'];
const servers = (
handle
.prepare(
'SELECT server FROM afk_sessions UNION SELECT server FROM afk_ledger 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
): AfkPage<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(
query.offset,
Math.max(0, Math.floor((total - 1) / afkPageSize) * afkPageSize)
);
const entries = handle
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...values, afkPageSize, 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);
}
if (query.player) {
where.push('guid = ?');
values.push(query.player);
}
let accountPage = emptyPage<AfkAccount>();
let sessionPage = emptyPage<AfkSession>();
let ledgerPage = emptyPage<AfkLedgerEntry>();
if (query.view === 'accounts') {
if (query.balance === 'saved') where.push('savedSeconds > 0');
if (query.balance === 'reserved') where.push('reservedSeconds > 0');
if (query.balance === 'unresolved') where.push('unresolvedSessions > 0');
const order =
query.sort === 'recent'
? 'lastActivity DESC, guid'
: query.sort === 'available'
? 'availableSeconds DESC, guid'
: 'savedSeconds DESC, guid';
accountPage = page(accounts, 'accounts', where, [...accountValues, ...values], order);
} else {
if (query.game) {
where.push('game = ?');
values.push(query.game);
}
if (query.server) {
where.push('server = ?');
values.push(query.server);
}
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 === 'sessions') {
if (query.state) {
where.push('state = ?');
values.push(query.state);
}
sessionPage = page(
sessions,
'sessions',
where,
values,
'createdAt DESC, sessionId'
);
} else {
if (query.kind) {
where.push('kind = ?');
values.push(query.kind);
}
ledgerPage = page(ledger, 'ledger', where, values, 'createdAt DESC, id DESC');
}
}
return {
query,
checkedAt,
capacitySeconds: policy.capacitySeconds,
summary,
servers,
accounts: accountPage,
sessions: sessionPage,
ledger: ledgerPage
};
})();
}
+14 -1
View File
@@ -3,8 +3,9 @@
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
let { account }: { account: ReturnType<typeof afkProfile> } = $props(); let { account, guid }: { account: ReturnType<typeof afkProfile>; guid?: string } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000); const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) => const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`; `${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
@@ -12,7 +13,12 @@
</script> </script>
<section aria-labelledby="afk-title"> <section aria-labelledby="afk-title">
<div class="heading">
<h2 id="afk-title">AFK reserve</h2> <h2 id="afk-title">AFK reserve</h2>
{#if guid}<Button size="sm" href={`/afk?view=ledger&player=${encodeURIComponent(guid)}`}
>View AFK history</Button
>{/if}
</div>
<p> <p>
<strong>{duration(account.freeSeconds)} free</strong> and <strong>{duration(account.freeSeconds)} free</strong> and
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II. <strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
@@ -164,6 +170,13 @@
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
font-size: var(--text-lg); font-size: var(--text-lg);
} }
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
dl { dl {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -0,0 +1,97 @@
<script lang="ts">
import type { AfkAccount } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '../RelativeTime.svelte';
let {
entries,
capacitySeconds,
source
}: { entries: AfkAccount[]; capacitySeconds: number; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const historyHref = (guid: string, view: string) =>
`/afk?${new URLSearchParams({ view, player: guid })}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK accounts" tabindex="0">
<table class="afk-table">
<caption class="sr-only">Shared player AFK accounts</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Available time</th><th scope="col"
>Saved capacity</th
><th scope="col">Reserved time</th><th scope="col">Last activity</th><th scope="col"
>History</th
></tr
></thead
>
<tbody>
{#each entries as account (account.guid)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(account.guid)}`,
source
)}>{account.name ?? account.guid}</a
><small class="afk-id">{account.guid}</small></td
>
<td class="afk-number"
><strong>{duration(account.availableSeconds)}</strong><small
>{duration(account.freeSeconds)} free · {duration(account.savedSeconds)} saved</small
>{#if account.freeRenewsAt}<small
>Free time next replenishes {$timeFormat.dateTime(
account.freeRenewsAt
)}</small
>{/if}</td
>
<td
><meter
min="0"
max={capacitySeconds}
value={Math.min(
account.savedSeconds + account.reservedSavedSeconds,
capacitySeconds
)}
aria-label={`${account.name ?? account.guid} saved time capacity`}
></meter><small
>{duration(account.savedSeconds + account.reservedSavedSeconds)} / {duration(
capacitySeconds
)}</small
>{#if account.reservedSavedSeconds > 0}<small
>Includes {duration(account.reservedSavedSeconds)} reserved</small
>{/if}</td
>
<td class="afk-number"
>{duration(account.reservedSeconds)}{#if account.unresolvedSessions}<small
class="afk-warning">Unresolved session</small
>{:else if account.reservedSeconds > 0}<small>Awaiting settlement</small
>{/if}</td
>
<td
>{#if account.lastActivity}<RelativeTime
value={account.lastActivity}
/>{:else}No activity{/if}</td
>
<td
><a href={historyHref(account.guid, 'sessions')}>Sessions</a><small
><a href={historyHref(account.guid, 'ledger')}>Time ledger</a></small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
meter {
width: 110px;
height: 12px;
accent-color: var(--accent-primary);
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { AfkLedgerEntry } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkLedgerEntry[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK time ledger" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK grants, purchases, reservations and consumption</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Received / event</th><th scope="col"
>Free change</th
><th scope="col">Saved change</th><th scope="col">Balances after</th><th scope="col"
>Server / match</th
></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(entry.guid)}`,
source
)}>{entry.name ?? entry.guid}</a
><small class="afk-id">{entry.guid}</small></td
>
<td
><time datetime={entry.createdAt}
>{$timeFormat.dateTime(entry.createdAt)}</time
><small>{entry.kind.replaceAll('_', ' ')}</small
>{#if entry.eventAt !== entry.createdAt}<small
>Reported {$timeFormat.dateTime(entry.eventAt)}</small
>{/if}<small class="afk-id">{entry.operationId}</small></td
>
<td class="afk-number">{change(entry.freeDelta)}</td><td class="afk-number"
>{change(entry.savedDelta)}</td
>
<td class="afk-number"
>{duration(entry.freeSeconds)} free<small
>{duration(entry.savedSeconds)} saved</small
></td
>
<td
><ServerIdentity name={entry.server} /><small
>{#if entry.matchAvailable && entry.matchId}<a
href={contextHref(`/matches/${entry.matchId}`, source)}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import { afkPageSize } from '$lib/afk.js';
import Button from '../primitives/Button.svelte';
let {
total,
offset,
disabled = false,
onchange
}: {
total: number;
offset: number;
disabled?: boolean;
onchange: (offset: number) => void;
} = $props();
</script>
<nav class="pagination" aria-label="AFK results pagination">
<span role="status"
>{total ? offset + 1 : 0}–{Math.min(offset + afkPageSize, total)} of {total.toLocaleString()}
results</span
>
{#if total > afkPageSize}<div>
<Button
size="sm"
disabled={disabled || offset === 0}
onclick={() => onchange(offset - afkPageSize)}>Previous</Button
><Button
size="sm"
disabled={disabled || offset + afkPageSize >= total}
onclick={() => onchange(offset + afkPageSize)}>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,78 @@
<script lang="ts">
import type { AfkSession } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkSession[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK sessions" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK session reservations and outcomes</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Session</th><th scope="col">Time</th><th
scope="col">Outcome</th
><th scope="col">Server / match</th></tr
></thead
>
<tbody>
{#each entries as session (session.sessionId)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(session.guid)}`,
source
)}>{session.name ?? session.guid}</a
><small class="afk-id">{session.guid}</small></td
>
<td
><time datetime={session.createdAt}
>{$timeFormat.dateTime(session.createdAt)}</time
><small
>{session.startedAt
? `Started ${$timeFormat.dateTime(session.startedAt)}`
: 'Start unconfirmed'}</small
><small class="afk-id">{session.sessionId}</small></td
>
<td class="afk-number"
><strong
>{session.consumedSeconds === null
? 'Unconfirmed duration'
: `${duration(session.consumedSeconds)} used`}</strong
><small>{duration(session.authorizedSeconds)} authorized</small
>{#if session.state === 'corrected' && session.chargedSeconds !== null}<small
>Corrected charge: {duration(session.chargedSeconds)}</small
>{/if}{#if session.consumedSeconds === null && session.confirmedSeconds > 0}<small
>Confirmed at least {duration(session.confirmedSeconds)}</small
>{/if}</td
>
<td
><span class:afk-warning={session.state === 'unresolved'}
>{label(session.state)}</span
>{#if session.endReason}<small>{label(session.endReason)}</small
>{/if}{#if session.endedAt}<small
>{$timeFormat.dateTime(session.endedAt)}</small
>{/if}</td
>
<td
><ServerIdentity name={session.server} /><small
>{#if session.matchAvailable && session.matchId}<a
href={contextHref(`/matches/${session.matchId}`, source)}
>Match #{session.matchId}</a
>{:else if session.matchId}Match #{session.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
+53
View File
@@ -0,0 +1,53 @@
.afk-table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.afk-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
.afk-table th {
color: var(--text-tertiary);
background: var(--surface-sunken);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.afk-table th,
.afk-table td {
padding: var(--space-4);
vertical-align: top;
}
.afk-table tbody tr + tr {
border-top: 1px solid var(--border-subtle);
}
.afk-table td {
min-width: 130px;
}
.afk-table small {
display: block;
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.afk-id {
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.afk-number {
font-variant-numeric: tabular-nums;
}
.afk-table a {
color: var(--accent-primary-hover);
}
.afk-player {
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.afk-table .afk-warning {
color: var(--state-warning);
}
+13
View File
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function load({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return { report: afkOverview(query) };
}
+484
View File
@@ -0,0 +1,484 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { afkLedgerKinds, type AfkOverview, type AfkQuery } from '$lib/afk.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import AfkAccounts from '$ui/components/afk/AfkAccounts.svelte';
import AfkSessions from '$ui/components/afk/AfkSessions.svelte';
import AfkLedger from '$ui/components/afk/AfkLedger.svelte';
import AfkPagination from '$ui/components/afk/AfkPagination.svelte';
import '$ui/styles/afk.css';
let { data }: { data: import('./$types').PageData } = $props();
const number = new Intl.NumberFormat('en-US');
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
let snapshot = $state<AfkOverview | null>(null);
const report = $derived(snapshot ?? data.report);
let filters = $state<AfkQuery>(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 = [
{ id: 'accounts', label: 'Accounts' },
{ id: 'sessions', label: 'Sessions' },
{ id: 'ledger', label: 'Time ledger' }
];
const serverOptions = $derived([
{ value: '', label: 'All servers' },
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
(server) => ({ value: server, label: serverTitle(server) })
)
]);
const clearedFilters = {
q: null,
balance: null,
sort: null,
game: null,
server: null,
state: null,
kind: null,
from: null,
to: null,
offset: null
};
$effect(() => {
const initial = data.report;
untrack(() => {
cancelRefresh();
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/afk${$page.url.search}`, { signal: request.signal });
if (!response.ok) throw new Error('AFK refresh failed.');
const next: AfkOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation) refreshError = 'AFK updates paused.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_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(`/afk?${params}`, { keepFocus: true, noScroll: true });
} catch {
navigationError = 'Could not load this AFK view. Please try again.';
}
}
function changeView(view: string) {
void navigate({ ...clearedFilters, q: report.query.q, view });
}
function applyFilters(event: SubmitEvent) {
event.preventDefault();
void navigate({
q: filters.q,
balance: filters.balance,
sort: filters.sort,
game: filters.game,
server: filters.server,
state: filters.state,
kind: filters.kind,
from: filters.from,
to: filters.to,
offset: null
});
}
</script>
<svelte:head><title>AFK · Electric Cherry</title></svelte:head>
<div class="afk-page">
<SectionHeader
title="AFK"
eyebrow="Players"
subtitle="Shared AFK time across Black Ops I & II."
>
{#snippet actions()}
<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 15 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>AFK accounts</dt>
<dd>{number.format(report.summary.accounts)}</dd>
</div>
<div>
<dt>Available saved time</dt>
<dd>{duration(report.summary.savedSeconds)}</dd>
</div>
<div>
<dt>Reserved time</dt>
<dd>{duration(report.summary.reservedSeconds)}</dd>
</div>
<div>
<dt>Unresolved sessions</dt>
<dd>
<button
class="summary-link"
disabled={busy}
onclick={() =>
void navigate({
...clearedFilters,
player: null,
view: 'sessions',
state: 'unresolved'
})}>{number.format(report.summary.unresolvedSessions)}</button
>
</dd>
</div>
</dl>
<p class="hint">
Totals cover all accounts, regardless of filters. Reserved time is held until settlement and
is excluded from available balances.
</p>
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<Tabs
{tabs}
value={report.query.view}
onchange={changeView}
ariaLabel="AFK views"
panelId="afk-results"
idPrefix="afk-tab"
/>
<div
id="afk-results"
role="tabpanel"
aria-labelledby={`afk-tab-${report.query.view}`}
aria-busy={busy}
class="results"
>
{#if report.query.player}
<div class="player-filter">
<span>Player GUID: <strong>{report.query.player}</strong></span><Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => void navigate({ player: null, offset: null })}
>All players</Button
>
</div>
{/if}
<form class="filters" method="GET" action="/afk" onsubmit={applyFilters}>
<input type="hidden" name="view" value={report.query.view} />
{#if report.query.player}<input
type="hidden"
name="player"
value={report.query.player}
/>{/if}
<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: 'saved', label: 'Saved time available' },
{ value: 'reserved', label: 'Reserved time' },
{ value: 'unresolved', label: 'Unresolved sessions' }
]}
/></Field
>
<Field label="Sort by"
><Select
name="sort"
bind:value={filters.sort}
options={[
{ value: 'saved', label: 'Most saved time' },
{ value: 'available', label: 'Most available time' },
{ 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
>
{#if report.query.view === 'sessions'}
<Field label="Session state"
><Select
name="state"
bind:value={filters.state}
options={[
{ value: '', label: 'All states' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'started', label: 'Started' },
{ value: 'settled', label: 'Settled' },
{ value: 'unresolved', label: 'Unresolved' },
{ value: 'corrected', label: 'Corrected' }
]}
/></Field
>
{:else}
<Field label="Event"
><Select
name="kind"
bind:value={filters.kind}
options={[
{ value: '', label: 'All events' },
...afkLedgerKinds.map((kind) => ({
value: kind,
label: label(kind)
}))
]}
/></Field
>
{/if}
<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({ ...clearedFilters, player: null })}>Reset</Button
>
</div>
</form>
{#if report.query.view === 'accounts'}
<p class="hint">
Free time replenishes on a rolling 24-hour basis. Saved capacity is {duration(
report.capacitySeconds
)} per player, including saved time reserved for sessions.
</p>
{#if report.accounts.entries.length}<AfkAccounts
entries={report.accounts.entries}
capacitySeconds={report.capacitySeconds}
source={$page.url}
/>{:else}<EmptyState
title="No AFK accounts found"
description="Try another player name or GUID, or reset the filters. Accounts appear after their first AFK service request."
/>{/if}
{:else if report.query.view === 'sessions'}
<p class="hint">
Dates filter when reservations were created. Unresolved sessions keep their time
reserved until a confirmed stop or an administrator correction. Corrected charges
are separate from confirmed usage.
</p>
{#if report.sessions.entries.length}<AfkSessions
entries={report.sessions.entries}
source={$page.url}
/>{:else}<EmptyState
title="No AFK sessions found"
description="No sessions match these filters."
/>{/if}
{:else}
<p class="hint">
Dates filter when events were received. Balances after each event are historical
snapshots; current balances appear in Accounts.
</p>
{#if report.ledger.entries.length}<AfkLedger
entries={report.ledger.entries}
source={$page.url}
/>{:else}<EmptyState
title="No time ledger entries found"
description="No AFK grants, purchases or session activity match these filters."
/>{/if}
{/if}
<AfkPagination
total={results.total}
offset={results.offset}
disabled={busy}
onchange={(offset) => void navigate({ offset })}
/>
</div>
</div>
<style>
.afk-page,
.results {
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;
overflow-wrap: anywhere;
}
.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);
}
.player-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.player-filter strong {
font-family: var(--font-mono);
}
@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>
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function GET({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return json(afkOverview(query), { headers: { 'Cache-Control': 'no-store' } });
}
@@ -223,7 +223,7 @@
</div> </div>
{:else}<p>No bank transfers yet.</p>{/if} {:else}<p>No bank transfers yet.</p>{/if}
</section> </section>
<AfkReserveCard account={data.afk} /> <AfkReserveCard account={data.afk} guid={profile.guid} />
<nav class="jump-links" aria-label="Player sections"> <nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title" <a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a >Matches</a
+320
View File
@@ -0,0 +1,320 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import AfkPage from '../../src/routes/afk/+page.svelte';
import { readAfkQuery, type AfkOverview } from '../../src/lib/afk.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({ url: new URL('http://localhost/afk'), 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()
}));
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.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const guid = '18446744073709551615';
const server = 't5_zombie_theater_1';
const checkedAt = '2026-10-17T12:00:00.000Z';
function report(search = ''): AfkOverview {
const query = readAfkQuery(new URLSearchParams(search));
return {
query,
checkedAt,
capacitySeconds: 21600,
summary: { accounts: 1, savedSeconds: 7200, reservedSeconds: 600, unresolvedSessions: 1 },
servers: [server],
accounts: {
entries: [
{
guid,
name: 'Dempsey',
freeSeconds: 1200,
savedSeconds: 7200,
availableSeconds: 8400,
reservedSeconds: 600,
reservedSavedSeconds: 300,
unresolvedSessions: 1,
lastActivity: checkedAt,
freeRenewsAt: null
}
],
total: 1,
offset: query.offset
},
sessions: {
entries: [
{
guid,
name: 'Dempsey',
sessionId: 'crash-session',
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
state: 'corrected',
authorizedSeconds: 600,
consumedSeconds: null,
chargedSeconds: 45,
confirmedSeconds: 40,
endReason: 'crash',
createdAt: '2026-10-17T11:00:00.000Z',
startedAt: '2026-10-17T11:00:00.000Z',
endedAt: '2026-10-17T11:00:45.000Z'
}
],
total: 1,
offset: query.offset
},
ledger: {
entries: [
{
guid,
name: 'Dempsey',
id: 1,
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
kind: 'purchase',
freeDelta: 0,
savedDelta: 600,
freeSeconds: 1200,
savedSeconds: 7200,
operationId: 'purchase-1',
sessionId: null,
eventAt: checkedAt,
createdAt: checkedAt
}
],
total: 1,
offset: query.offset
}
};
}
function show(initial = report()) {
const data = writable({ report: initial, user: null, setupRequired: false });
const state = fromStore(data);
const updatePage = (next: AfkOverview) =>
currentPage.set({
url: new URL(
`http://localhost/afk?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
updatePage(initial);
component = mount(AfkPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: AfkOverview) => {
updatePage(next);
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');
const enter = (name: string, value: string) => {
const input = document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
};
const select = (name: string, value: string) => {
const node = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`)!;
// happy-dom's :checked selector only matches inputs, not selected options.
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
};
const submit = () =>
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('links the exact account identity to its profile, sessions and ledger', () => {
show(report('q=Dempsey&sort=recent&offset=25'));
const panel = document.querySelector('#afk-results')!;
const links = [...panel.querySelectorAll('a')];
const profile = links.find((link) => link.textContent === 'Dempsey')!;
const profileUrl = new URL(profile.getAttribute('href')!, 'http://localhost');
expect(profileUrl.pathname).toBe(`/leaderboard/players/${guid}`);
expect(profileUrl.searchParams.get('returnTo')).toContain('q=Dempsey');
expect(profileUrl.searchParams.get('returnTo')).toContain('offset=25');
for (const [label, view] of [
['Sessions', 'sessions'],
['Time ledger', 'ledger']
]) {
const history = links.find((link) => link.textContent === label)!;
const historyUrl = new URL(history.getAttribute('href')!, 'http://localhost');
expect(historyUrl.pathname).toBe('/afk');
expect(historyUrl.searchParams.get('view')).toBe(view);
expect(historyUrl.searchParams.get('player')).toBe(guid);
expect(historyUrl.searchParams.has('q')).toBe(false);
expect(historyUrl.searchParams.has('offset')).toBe(false);
}
// Held saved time still occupies account capacity.
expect(panel.querySelector('meter')!.getAttribute('value')).toBe('7500');
expect(panel.textContent).toContain('Unresolved session');
});
it('applies session filters without losing an exact player and resets pagination', () => {
show(report(`view=sessions&offset=25&player=${guid}`));
enter('q', 'Nikolai');
select('game', 't6');
select('state', 'unresolved');
enter('from', '2026-10-01');
enter('to', '2026-10-17');
submit();
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
expect(lastNavigation().searchParams.get('game')).toBe('t6');
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('from')).toBe('2026-10-01');
expect(lastNavigation().searchParams.get('to')).toBe('2026-10-17');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
button('Reset').click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
for (const key of ['q', 'game', 'state', 'from', 'to', 'player', 'offset'])
expect(lastNavigation().searchParams.has(key)).toBe(false);
});
it('keeps the exact player across tabs and pagination', () => {
const update = show(report(`q=Dempsey&player=${guid}&offset=25`));
document.querySelector<HTMLButtonElement>('#afk-tab-sessions')!.click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
const next = report(`view=sessions&player=${guid}&state=unresolved&game=t5`);
next.sessions.total = 30;
update(next);
button('Next').click();
expect(lastNavigation().searchParams.get('offset')).toBe('25');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('game')).toBe('t5');
});
it('opens unresolved sessions across the fleet instead of retaining list filters', () => {
show(report(`view=ledger&player=${guid}&q=Dempsey&game=t5&kind=purchase&offset=25`));
document.querySelector<HTMLButtonElement>('.summary button')!.click();
const destination = lastNavigation();
expect(destination.searchParams.get('view')).toBe('sessions');
expect(destination.searchParams.get('state')).toBe('unresolved');
for (const key of ['player', 'q', 'game', 'kind', 'offset'])
expect(destination.searchParams.has(key)).toBe(false);
});
it('keeps corrected charges distinct from confirmed time and avoids expired match links', () => {
const update = show(report('view=sessions'));
const panel = document.querySelector('#afk-results')!;
expect(panel.textContent).toContain('Unconfirmed duration');
expect(panel.textContent).toContain('Corrected charge: 45s');
expect(panel.textContent).toContain('Confirmed at least 40s');
expect(panel.textContent).not.toContain('45s used');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
update(report('view=ledger'));
expect(panel.textContent).toContain('purchase-1');
expect(panel.textContent).toContain('+10m 00s');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
});
it('retains the last snapshot and draft filters after a refresh failure and retries', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
vi.stubGlobal('fetch', fetch);
show();
enter('q', 'draft');
const before = document.querySelector('.summary')!.textContent;
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('AFK updates paused.'));
expect(document.querySelector('.summary')!.textContent).toBe(before);
button('Retry').click();
await vi.waitFor(() => expect(document.body.textContent).not.toContain('AFK updates paused.'));
expect(document.querySelector<HTMLInputElement>('input[name="q"]')!.value).toBe('draft');
expect(fetch.mock.calls[0][0]).toContain('/api/afk?');
});
it('discards an old refresh after navigation changes the AFK 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=ledger');
next.summary.accounts = 42;
update(next);
resolve(new Response(JSON.stringify(report())));
await tick();
await tick();
expect(document.querySelector('.summary')?.textContent).toContain('42');
expect(document.querySelector('#afk-tab-ledger')?.getAttribute('aria-selected')).toBe('true');
});
it('polls after 15 seconds, pauses while hidden, and refreshes when visible again', async () => {
vi.useFakeTimers();
let visibility: DocumentVisibilityState = 'visible';
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility);
const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(report()))));
vi.stubGlobal('fetch', fetch);
show();
await vi.advanceTimersByTimeAsync(14_999);
expect(fetch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await tick();
expect(fetch).toHaveBeenCalledTimes(2);
});
+451
View File
@@ -0,0 +1,451 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { readAfkQuery, type AfkQuery, type AfkSessionState } from '../../src/lib/afk.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
let overview: typeof import('../../src/lib/server/fleet/afk-overview.js').afkOverview;
const now = new Date('2026-10-17T12:00:00.000Z');
const day = 86_400_000;
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
const guid = '18446744073709551615';
const query = (changes: Partial<AfkQuery> = {}) => ({
...readAfkQuery(new URLSearchParams()),
...changes
});
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
vi.stubEnv('EC_AFK_FREE_SECONDS', '1800');
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '21600');
handle = (await import('../../src/lib/server/db/index.js')).db();
overview = (await import('../../src/lib/server/fleet/afk-overview.js')).afkOverview;
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-17T10: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 account(id = guid, saved = 400, cachedFree = 0) {
handle
.prepare(
'INSERT INTO afk_accounts(guid,free_seconds,saved_seconds,free_started_at) VALUES (?,?,?,?)'
)
.run(id, cachedFree, saved, now.getTime() - day);
}
let serial = 0;
function session(
options: {
guid?: string;
sessionId?: string;
server?: string;
state?: AfkSessionState;
free?: number;
saved?: number;
consumed?: number | null;
charged?: number | null;
confirmed?: number;
chargeAt?: number | null;
createdAt?: string;
matchId?: number | null;
} = {}
) {
const id = options.sessionId ?? `session-${serial++}`;
const state = options.state ?? 'settled';
const held = ['reserved', 'started', 'unresolved'].includes(state);
const free = options.free ?? 100;
const saved = options.saved ?? 0;
const createdAt = options.createdAt ?? now.toISOString();
const consumed =
options.consumed === undefined ? (held ? null : free + saved) : options.consumed;
const charged = options.charged === undefined ? consumed : options.charged;
const chargeAt =
options.chargeAt === undefined ? (held ? null : now.getTime()) : options.chargeAt;
handle
.prepare(
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,
authorized_seconds,free_seconds,saved_seconds,free_started_at,consumed_seconds,charged_seconds,
free_charge_at,confirmed_seconds,end_reason,created_at,started_at,ended_at)
VALUES (?,?,'private-instance',?,'private-match-key',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
id,
options.server ?? server,
options.guid ?? guid,
options.matchId ?? null,
state,
free + saved,
free,
saved,
now.getTime() - day,
consumed,
charged,
chargeAt,
options.confirmed ?? 0,
state === 'unresolved' || state === 'corrected' ? 'crash' : held ? null : 'manual',
createdAt,
state === 'reserved' ? null : createdAt,
state === 'reserved' || state === 'started' ? null : createdAt
);
return id;
}
function ledger(
options: {
guid?: string;
server?: string;
kind?: string;
savedDelta?: number;
createdAt?: string;
eventAt?: string;
matchId?: number | null;
sessionId?: string | null;
} = {}
) {
return handle
.prepare(
`INSERT INTO afk_ledger(guid,server,match_id,operation_id,session_id,kind,
free_delta,saved_delta,free_seconds,saved_seconds,event_at,created_at,details)
VALUES (?,?,?,?,?,?,0,?,1800,400,?,?,'{"secret":"private-details"}')`
)
.run(
options.guid ?? guid,
options.server ?? server,
options.matchId ?? null,
`operation-${serial++}`,
options.sessionId ?? null,
options.kind ?? 'reward',
options.savedDelta ?? 60,
options.eventAt ?? now.toISOString(),
options.createdAt ?? now.toISOString()
).lastInsertRowid;
}
it('shares exact string GUID accounts across games and selects the latest player name', () => {
identity();
identity(guid, 'Latest name', 't6', '2026-10-17T11:00:00.000Z');
identity('00042', 'Leading zero');
identity('42', 'Separate account');
account();
account('00042', 100);
account('42', 0);
const report = overview(query(), now);
expect(report.accounts.total).toBe(3);
expect(report.accounts.entries[0]).toMatchObject({
guid,
name: 'Latest name',
freeSeconds: 1800,
savedSeconds: 400,
availableSeconds: 2200,
reservedSeconds: 0,
reservedSavedSeconds: 0,
unresolvedSessions: 0,
freeRenewsAt: null
});
expect(
overview(query({ player: '42' }), now).accounts.entries.map((row) => row.savedSeconds)
).toEqual([0]);
expect(
overview(query({ player: '00042' }), now).accounts.entries.map((row) => row.savedSeconds)
).toEqual([100]);
expect(overview(query({ player: 'unknown' }), now).accounts.total).toBe(0);
expect(report.summary).toEqual({
accounts: 3,
savedSeconds: 500,
reservedSeconds: 0,
unresolvedSessions: 0
});
});
it('computes rolling free availability from durable sessions without refreshing balances or granting time', () => {
account();
session({ state: 'started', free: 300, saved: 200, createdAt: '2026-10-14T12:00:00.000Z' });
session({ free: 600, consumed: 400, chargeAt: now.getTime() - 4 * 3_600_000 });
session({ free: 900, consumed: 900, chargeAt: now.getTime() - day });
session({ free: 300, consumed: 300, chargeAt: now.getTime() - day - 1 });
const before = handle.prepare('SELECT * FROM afk_accounts').all();
const report = overview(query(), now);
expect(report.accounts.entries[0]).toMatchObject({
freeSeconds: 1100,
savedSeconds: 400,
availableSeconds: 1500,
reservedSeconds: 500,
reservedSavedSeconds: 200,
freeRenewsAt: '2026-10-18T08:00:00.000Z'
});
expect(overview(query(), new Date(now.getTime() + day)).accounts.entries[0]).toMatchObject({
freeSeconds: 1500,
reservedSeconds: 500,
freeRenewsAt: null
});
expect(handle.prepare('SELECT * FROM afk_accounts').all()).toEqual(before);
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_ledger').get()).toEqual({ total: 0 });
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_operations').get()).toEqual({
total: 0
});
});
it('preserves unresolved holds and distinguishes accounting corrections from confirmed consumption', () => {
account();
account('corrected', 21000);
session({
state: 'unresolved',
free: 1000,
saved: 300,
confirmed: 250,
createdAt: '2026-10-01T00:00:00.000Z'
});
session({
guid: 'corrected',
state: 'corrected',
free: 800,
consumed: null,
charged: 500,
confirmed: 200
});
expect(overview(query({ balance: 'unresolved' }), now).accounts.entries[0]).toMatchObject({
guid,
freeSeconds: 800,
savedSeconds: 400,
reservedSeconds: 1300,
reservedSavedSeconds: 300,
unresolvedSessions: 1,
freeRenewsAt: null
});
expect(overview(query({ player: 'corrected' }), now).accounts.entries[0]).toMatchObject({
freeSeconds: 1300,
savedSeconds: 21000,
reservedSeconds: 0
});
const report = overview(query({ view: 'sessions', state: 'corrected' }), now);
expect(report.sessions.entries[0]).toMatchObject({
state: 'corrected',
consumedSeconds: null,
chargedSeconds: 500,
confirmedSeconds: 200
});
expect(report.summary).toEqual({
accounts: 2,
savedSeconds: 21400,
reservedSeconds: 1300,
unresolvedSessions: 1
});
expect(report.capacitySeconds).toBe(21600);
});
it('searches literally, filters and sorts accounts while retaining fleet-wide summary totals', () => {
identity();
identity('other', '100%_Player');
account();
account('other', 800);
account('empty', 0);
session({ guid: 'other', state: 'reserved', free: 1800, saved: 200 });
ledger({ createdAt: '2026-10-17T11:00:00.000Z' });
ledger({ guid: 'empty', createdAt: '2026-10-17T12:01: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: 'saved' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['other', guid]);
expect(
overview(query({ balance: 'reserved' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['other']);
expect(
overview(query({ sort: 'available' }), now).accounts.entries.map((row) => row.guid)
).toEqual([guid, 'empty', 'other']);
expect(
overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['empty', 'other', guid]);
expect(overview(query({ q: 'not-found' }), now).summary).toEqual({
accounts: 3,
savedSeconds: 1200,
reservedSeconds: 2000,
unresolvedSessions: 0
});
});
it('retains historical sessions and ledger entries after telemetry matches and identities are removed', () => {
identity();
account();
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','private-fingerprint')`
)
.run(server, now.toISOString(), now.toISOString()).lastInsertRowid
);
const sessionId = session({ matchId });
ledger({ matchId, sessionId });
expect(overview(query({ view: 'sessions' }), now).sessions.entries[0].matchAvailable).toBe(1);
expect(overview(query({ view: 'ledger' }), now).ledger.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);
for (const view of ['sessions', 'ledger'] as const) {
const report = overview(query({ view, game: 't5', q: guid, player: guid }), now);
expect(report[view].entries[0]).toMatchObject({
guid,
name: null,
matchId,
matchAvailable: 0,
game: 't5'
});
expect(JSON.stringify(report)).not.toMatch(
/private-instance|private-match-key|private-fingerprint|private-details|instance_id|details/
);
expect(report.servers).toEqual([server]);
}
expect(overview(query(), now).accounts.entries[0].savedSeconds).toBe(400);
});
it('filters session and ledger history by game, server, state or kind, exact GUID and inclusive UTC receipt dates', () => {
account();
const dates = [
'2026-10-16T23:59:59.999Z',
'2026-10-17T00:00:00.000Z',
'2026-10-17T23:59:59.999Z',
'2026-10-18T00:00:00.000Z'
];
for (const createdAt of dates) {
session({ server: other, createdAt });
ledger({ server: other, createdAt, eventAt: '2026-10-16T12:00:00.000Z', kind: 'purchase' });
}
session({ guid: 'someone-else', server: other });
ledger({ guid: 'someone-else', server: other });
session({ state: 'reserved', server });
ledger({ server, kind: 'reserve' });
for (const view of ['sessions', 'ledger'] as const) {
const report = overview(
query({
view,
game: 't6',
server: other,
player: guid,
from: '2026-10-17',
to: '2026-10-17'
}),
now
);
expect(report[view].entries.map((row) => row.createdAt)).toEqual([dates[2], dates[1]]);
expect(overview(query({ view, server: 'missing' }), now)[view].total).toBe(0);
expect(overview(query({ view, player: guid }), now)[view].total).toBe(5);
expect(overview(query({ view, player: 'unknown' }), now)[view].total).toBe(0);
expect(overview(query({ view, game: 't5' }), now)[view].total).toBe(1);
}
expect(overview(query({ view: 'sessions', state: 'reserved' }), now).sessions.total).toBe(1);
expect(overview(query({ view: 'ledger', kind: 'purchase' }), now).ledger.total).toBe(4);
expect(
overview(query({ view: 'ledger' }), now).ledger.entries.every(
(row) => typeof row.operationId === 'string'
)
).toBe(true);
});
it('paginates every view deterministically and clamps stale offsets', () => {
for (let i = 0; i < 31; i++) {
const id = String(i).padStart(2, '0');
account(id, 100);
session({ guid: id });
ledger({ guid: id });
}
for (const view of ['accounts', 'sessions', 'ledger'] as const) {
const first = overview(query({ view }), now)[view];
const second = overview(query({ view, offset: 999999 }), now)[view];
expect(first.entries).toHaveLength(25);
expect(second).toMatchObject({ total: 31, offset: 25 });
expect(second.entries).toHaveLength(6);
expect(new Set([...first.entries, ...second.entries].map((row) => row.guid)).size).toBe(31);
expect(overview(query({ view, q: 'absent', offset: 999999 }), now)[view]).toEqual({
entries: [],
total: 0,
offset: 0
});
}
});
it('updates free allowance calculations without changing retained saved balances or reservations', () => {
account();
session({ state: 'reserved', free: 1000, saved: 200 });
vi.stubEnv('EC_AFK_FREE_SECONDS', '300');
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '100');
expect(overview(query(), now)).toMatchObject({
capacitySeconds: 100,
accounts: {
entries: [
expect.objectContaining({
freeSeconds: 0,
savedSeconds: 400,
reservedSeconds: 1200,
reservedSavedSeconds: 200
})
]
}
});
});
it('serves an empty read-only report and uncached API, rejects invalid filters in both routes', async () => {
identity();
const { GET } = await import('../../src/routes/api/afk/+server.js');
const { load } = await import('../../src/routes/afk/+page.server.js');
const url = new URL('https://test/afk');
const response = GET({ url });
expect(response.headers.get('cache-control')).toBe('no-store');
expect((await response.json()).summary).toEqual({
accounts: 0,
savedSeconds: 0,
reservedSeconds: 0,
unresolvedSessions: 0
});
expect(load({ url }).report.accounts.total).toBe(0);
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_accounts').get()).toEqual({
total: 0
});
for (const invalid of ['view=unknown', 'state=unknown', 'offset=-1', 'from=2026-02-30']) {
expect(() => GET({ url: new URL(`https://test/api/afk?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
expect(() => load({ url: new URL(`https://test/afk?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
}
});
describe('AFK query validation', () => {
it.each([
'view=nope',
'balance=nope',
'game=t7',
'sort=nope',
'state=crash',
'kind=secret',
'offset=NaN',
'offset=1.5',
'offset=Infinity',
'from=2026-02-30',
'to=2026-13-01',
'from=2026-10-18&to=2026-10-17',
`q=${'a'.repeat(101)}`,
`server=${'a'.repeat(257)}`,
`player=${'a'.repeat(129)}`
])('rejects %s', (value) => {
expect(() => readAfkQuery(new URLSearchParams(value))).toThrow();
});
it('retains string GUIDs and normalizes page boundaries', () => {
expect(readAfkQuery(new URLSearchParams(`player=${guid}&offset=27`))).toMatchObject({
player: guid,
offset: 25
});
});
});
+17
View File
@@ -102,6 +102,8 @@ describe('management route permissions', () => {
'/metrics', '/metrics',
'/bank', '/bank',
'/api/bank', '/api/bank',
'/afk',
'/api/afk',
'/api/matches', '/api/matches',
'/matches/[id]', '/matches/[id]',
'/leaderboard/players/[game]/[guid]', '/leaderboard/players/[game]/[guid]',
@@ -128,6 +130,21 @@ describe('management route permissions', () => {
expect(allows('viewer', route, 'HEAD')).toBe(true); expect(allows('viewer', route, 'HEAD')).toBe(true);
}); });
it('keeps AFK reporting separate from game credentials and administrator corrections', () => {
for (const route of ['/afk', '/api/afk']) {
for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('viewer');
for (const role of roles) expect(allows(role, route, method)).toBe(true);
}
}
expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine');
for (const role of roles) {
expect(allows(role, '/api/afk/[action]', 'POST')).toBe(false);
expect(allows(role, '/api/afk/corrections', 'POST')).toBe(role === 'admin');
}
expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin');
});
it.each([ it.each([
['/api/servers', 'POST'], ['/api/servers', 'POST'],
['/api/servers/[name]', 'DELETE'], ['/api/servers/[name]', 'DELETE'],
+9
View File
@@ -8,6 +8,15 @@ describe('navigation context', () => {
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source); expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Bank'); expect(returnLabel(source)).toBe('Back to Bank');
}); });
it('restores AFK history filters and pagination after opening a player or match', () => {
const source =
'/afk?view=sessions&game=t6&server=town&state=unresolved&from=2026-10-01&offset=25';
for (const destination of ['/leaderboard/players/00042', '/matches/17']) {
const target = new URL(contextHref(destination, source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to AFK');
}
});
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'
+9
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/configs/42', 'releases', '/configs'], ['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'], ['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'], ['/bank', 'players', '/bank'],
['/afk', 'players', '/afk'],
['/moderation', 'players', '/moderation'], ['/moderation', 'players', '/moderation'],
['/settings/users', 'admin', '/settings/users'], ['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys'] ['/server-keys', 'admin', '/server-keys']
@@ -62,6 +63,14 @@ describe('application navigation', () => {
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k'); expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
}); });
it('keeps AFK beside Bank for every signed-in role', () => {
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
const items = visibleNavigation(role).find((group) => group.id === 'players')!.items;
const bankIndex = items.findIndex((item) => item.href === '/bank');
expect(items[bankIndex + 1]).toMatchObject({ label: 'AFK', href: '/afk' });
}
});
it('reserves private moderation navigation for moderators, administrators, and Super Viewers', () => { it('reserves private moderation navigation for moderators, administrators, and Super Viewers', () => {
for (const role of ['admin', 'moderator', 'super_viewer'] as const) for (const role of ['admin', 'moderator', 'super_viewer'] as const)
expect(navigationLocation('/moderation', role)?.group.id).toBe('players'); expect(navigationLocation('/moderation', role)?.group.id).toBe('players');
+3 -1
View File
@@ -50,7 +50,9 @@ it('separates active play, confirmed usage and corrected crash charges without l
} }
] ]
}; };
const { body } = render(AfkReserveCard, { props: { account } }); const { body } = render(AfkReserveCard, { props: { account, guid: '18446744073709551615' } });
expect(body).toContain('href="/afk?view=ledger&amp;player=18446744073709551615"');
expect(body).toContain('View AFK history');
expect(body).toContain('Recorded active play'); expect(body).toContain('Recorded active play');
expect(body).toContain('Confirmed AFK time'); expect(body).toContain('Confirmed AFK time');
expect(body).toContain('Unconfirmed'); expect(body).toContain('Unconfirmed');
+8
View File
@@ -36,10 +36,18 @@ describe('sidebar navigation', () => {
expect(body).toContain('href="/maintenance"'); expect(body).toContain('href="/maintenance"');
expect(body).toContain('href="/metrics"'); expect(body).toContain('href="/metrics"');
expect(body).toContain('href="/bank"'); expect(body).toContain('href="/bank"');
expect(body).toContain('href="/afk"');
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"');
}); });
it('marks the AFK overview active for viewers', () => {
currentPage.set({ url: new URL('http://localhost/afk?view=sessions'), data: {} });
const body = render(Navigation, { props: { role: 'viewer' } }).body;
expect(body).toMatch(/<a[^>]*href="\/afk"[^>]*aria-current="page"/);
expect(body).not.toMatch(/<a[^>]*href="\/bank"[^>]*aria-current="page"/);
});
it('does not show invented attention counts before the fleet snapshot loads', () => { it('does not show invented attention counts before the fleet snapshot loads', () => {
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch( expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(
/needs? attention/ /needs? attention/