This commit is contained in:
@@ -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
|
||||
};
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user