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
+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
};
})();
}