This commit is contained in:
@@ -39,7 +39,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
return 'admin';
|
||||
|
||||
if (within(routeId, '/api/agent')) return 'machine';
|
||||
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/afk/corrections') return 'admin';
|
||||
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
||||
return 'machine';
|
||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Accounts, receipts and audit evidence deliberately outlive matches and servers. */
|
||||
export function migrateAfk(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS afk_accounts (
|
||||
guid TEXT PRIMARY KEY, free_seconds INTEGER NOT NULL CHECK(free_seconds >= 0),
|
||||
saved_seconds INTEGER NOT NULL DEFAULT 0 CHECK(saved_seconds >= 0),
|
||||
free_started_at INTEGER NOT NULL, active_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
kill_progress INTEGER NOT NULL DEFAULT 0, headshot_progress INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_hour INTEGER NOT NULL DEFAULT 0, bonus_seconds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS afk_operations (
|
||||
server TEXT NOT NULL, request_id TEXT NOT NULL, instance_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, PRIMARY KEY(server,request_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS afk_progress (
|
||||
instance_id TEXT NOT NULL, match_key TEXT NOT NULL, guid TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL, active_seconds INTEGER NOT NULL, kills INTEGER NOT NULL,
|
||||
headshots INTEGER NOT NULL, rounds INTEGER NOT NULL, revives INTEGER NOT NULL,
|
||||
PRIMARY KEY(instance_id,match_key,guid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS afk_bonus_rewards (
|
||||
guid TEXT NOT NULL, active_second INTEGER NOT NULL, seconds INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS afk_bonus_rewards_guid ON afk_bonus_rewards(guid,active_second);
|
||||
CREATE TABLE IF NOT EXISTS afk_sessions (
|
||||
session_id TEXT PRIMARY KEY, server TEXT NOT NULL, instance_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL, match_key TEXT NOT NULL, match_id INTEGER,
|
||||
state TEXT NOT NULL CHECK(state IN ('reserved','started','settled','unresolved','corrected')),
|
||||
authorized_seconds INTEGER NOT NULL, free_seconds INTEGER NOT NULL,
|
||||
saved_seconds INTEGER NOT NULL, free_started_at INTEGER NOT NULL,
|
||||
consumed_seconds INTEGER, charged_seconds INTEGER, free_charge_at INTEGER, confirmed_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
end_reason TEXT, created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS afk_pending_guid ON afk_sessions(guid)
|
||||
WHERE state IN ('reserved','started','unresolved');
|
||||
CREATE TABLE IF NOT EXISTS afk_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, server TEXT NOT NULL,
|
||||
match_id INTEGER, operation_id TEXT NOT NULL, session_id TEXT,
|
||||
kind TEXT NOT NULL, free_delta INTEGER NOT NULL, saved_delta INTEGER NOT NULL,
|
||||
free_seconds INTEGER NOT NULL, saved_seconds INTEGER NOT NULL,
|
||||
event_at TEXT NOT NULL, created_at TEXT NOT NULL, details TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS afk_ledger_guid ON afk_ledger(guid,id DESC);
|
||||
CREATE INDEX IF NOT EXISTS afk_sessions_guid ON afk_sessions(guid,created_at DESC);
|
||||
`);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||
import { migrateBank } from './bank.js';
|
||||
import { migrateAfk } from './afk.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
|
||||
@@ -168,7 +169,8 @@ export const migrations: readonly Migration[] = [
|
||||
name: 'moderation case workflow and linked restrictions',
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModerationCases
|
||||
}
|
||||
},
|
||||
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk }
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export type AfkAction = 'balance' | 'event' | 'purchase' | 'reserve' | 'start' | 'settle';
|
||||
const MAX = 2_147_483_647;
|
||||
const DAY = 86_400_000;
|
||||
const token = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v);
|
||||
const integer = (v: unknown, min = 0, max = MAX): v is number =>
|
||||
Number.isSafeInteger(v) && Number(v) >= min && Number(v) <= max;
|
||||
|
||||
export function afkPolicy() {
|
||||
const setting = (name: string, fallback: number, min = 0, max = MAX) => {
|
||||
const value = Number(process.env[`EC_AFK_${name}`] ?? fallback);
|
||||
if (!integer(value, min, max)) throw new Error(`Invalid EC_AFK_${name}`);
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
freeSeconds: setting('FREE_SECONDS', 1800, 0, 604800),
|
||||
capacitySeconds: setting('CAPACITY_SECONDS', 21600, 1, 604800),
|
||||
activeInterval: setting('ACTIVE_INTERVAL_SECONDS', 300, 1),
|
||||
activeReward: setting('ACTIVE_REWARD_SECONDS', 60),
|
||||
roundReward: setting('ROUND_REWARD_SECONDS', 15),
|
||||
killInterval: setting('KILL_INTERVAL', 100, 1),
|
||||
killReward: setting('KILL_REWARD_SECONDS', 30),
|
||||
headshotInterval: setting('HEADSHOT_INTERVAL', 25, 1),
|
||||
headshotReward: setting('HEADSHOT_REWARD_SECONDS', 15),
|
||||
reviveReward: setting('REVIVE_REWARD_SECONDS', 30),
|
||||
bonusCap: setting('BONUS_HOURLY_SECONDS', 480),
|
||||
purchasePoints: setting('PURCHASE_POINTS', 10000, 1, 1000000),
|
||||
purchaseSeconds: setting('PURCHASE_SECONDS', 600, 1, 604800)
|
||||
};
|
||||
}
|
||||
interface Account {
|
||||
guid: string;
|
||||
free_seconds: number;
|
||||
saved_seconds: number;
|
||||
free_started_at: number;
|
||||
active_seconds: number;
|
||||
kill_progress: number;
|
||||
headshot_progress: number;
|
||||
bonus_hour: number;
|
||||
bonus_seconds: number;
|
||||
}
|
||||
interface Session {
|
||||
session_id: string;
|
||||
server: string;
|
||||
instance_id: string;
|
||||
guid: string;
|
||||
match_key: string;
|
||||
match_id: number | null;
|
||||
state: 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
|
||||
authorized_seconds: number;
|
||||
free_seconds: number;
|
||||
saved_seconds: number;
|
||||
free_started_at: number;
|
||||
consumed_seconds: number | null;
|
||||
charged_seconds: number | null;
|
||||
confirmed_seconds: number;
|
||||
end_reason: string | null;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
}
|
||||
interface Progress {
|
||||
seq: number;
|
||||
active_seconds: number;
|
||||
kills: number;
|
||||
headshots: number;
|
||||
rounds: number;
|
||||
revives: number;
|
||||
}
|
||||
interface Input extends Progress {
|
||||
server: string;
|
||||
guid: string;
|
||||
match: string;
|
||||
request_id: string;
|
||||
event_time?: string;
|
||||
activity: string;
|
||||
session_id: string;
|
||||
seconds: number;
|
||||
packs: number;
|
||||
confirmed: boolean;
|
||||
expected_purchase_points: number;
|
||||
expected_purchase_seconds: number;
|
||||
consumed_seconds: number | null;
|
||||
confirmed_seconds?: number;
|
||||
end_reason: string;
|
||||
}
|
||||
export interface AfkResult {
|
||||
ok: boolean;
|
||||
state: string;
|
||||
code: string;
|
||||
request_id: string;
|
||||
free_seconds: number;
|
||||
saved_seconds: number;
|
||||
available_seconds: number;
|
||||
reserved_seconds: number;
|
||||
capacity_seconds: number;
|
||||
authorized_seconds: number;
|
||||
bank_balance: number;
|
||||
purchase_points: number;
|
||||
purchase_seconds: number;
|
||||
max_purchase_packs: number;
|
||||
session_id?: string;
|
||||
consumed_seconds?: number | null;
|
||||
awarded_seconds?: number;
|
||||
}
|
||||
|
||||
/** Server-owned cumulative counters, receipts and balances commit together. */
|
||||
export function afkRequest(
|
||||
action: AfkAction,
|
||||
value: unknown,
|
||||
authenticatedServer: string,
|
||||
instanceId: string,
|
||||
options: { receiptOnly?: boolean } = {}
|
||||
): { status: number; body: AfkResult } {
|
||||
const policy = afkPolicy();
|
||||
const empty = (code: string, request_id = ''): AfkResult => ({
|
||||
ok: false,
|
||||
state: 'rejected',
|
||||
code,
|
||||
request_id,
|
||||
free_seconds: 0,
|
||||
saved_seconds: 0,
|
||||
available_seconds: 0,
|
||||
reserved_seconds: 0,
|
||||
capacity_seconds: policy.capacitySeconds,
|
||||
authorized_seconds: 0,
|
||||
bank_balance: 0,
|
||||
purchase_points: policy.purchasePoints,
|
||||
purchase_seconds: policy.purchaseSeconds,
|
||||
max_purchase_packs: 0
|
||||
});
|
||||
const fail = (status: number, code: string, id = '') => ({ status, body: empty(code, id) });
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return fail(400, 'invalid_request');
|
||||
const input = value as Input;
|
||||
if (input.server !== authenticatedServer) return fail(403, 'wrong_server');
|
||||
if (
|
||||
!token(input.guid) ||
|
||||
!token(input.match) ||
|
||||
(action !== 'balance' && !token(input.request_id)) ||
|
||||
(input.request_id !== undefined && !token(input.request_id))
|
||||
)
|
||||
return fail(400, 'invalid_request');
|
||||
if (
|
||||
input.event_time !== undefined &&
|
||||
(typeof input.event_time !== 'string' ||
|
||||
!Number.isFinite(Date.parse(input.event_time)) ||
|
||||
Date.parse(input.event_time) > Date.now() + 300000)
|
||||
)
|
||||
return fail(400, 'invalid_event_time', input.request_id);
|
||||
const counters = ['active_seconds', 'kills', 'headshots', 'rounds', 'revives'] as const;
|
||||
if (
|
||||
action === 'event' &&
|
||||
(!integer(input.seq, 1) ||
|
||||
counters.some((key) => !integer(input[key])) ||
|
||||
input.headshots > input.kills ||
|
||||
input.activity !== 'playing')
|
||||
)
|
||||
return fail(400, 'invalid_activity', input.request_id);
|
||||
if (
|
||||
action === 'purchase' &&
|
||||
(!integer(input.packs, 1, 1000) ||
|
||||
input.confirmed !== true ||
|
||||
!integer(input.expected_purchase_points, 1, 1000000) ||
|
||||
!integer(input.expected_purchase_seconds, 1, 604800))
|
||||
)
|
||||
return fail(400, 'invalid_purchase', input.request_id);
|
||||
if (['reserve', 'start', 'settle'].includes(action) && !token(input.session_id))
|
||||
return fail(400, 'invalid_session', input.request_id);
|
||||
if (action === 'reserve' && !integer(input.seconds, 1, 604800))
|
||||
return fail(400, 'invalid_seconds', input.request_id);
|
||||
if (
|
||||
action === 'settle' &&
|
||||
(![
|
||||
'manual',
|
||||
'disconnect',
|
||||
'death',
|
||||
'match_end',
|
||||
'expired',
|
||||
'never_started',
|
||||
'crash'
|
||||
].includes(input.end_reason) ||
|
||||
!(
|
||||
integer(input.consumed_seconds, 0, 604800) ||
|
||||
(input.consumed_seconds === null && input.end_reason === 'crash')
|
||||
) ||
|
||||
(input.confirmed_seconds !== undefined &&
|
||||
!integer(input.confirmed_seconds, 0, 604800)) ||
|
||||
(input.end_reason === 'never_started' && input.consumed_seconds !== 0))
|
||||
)
|
||||
return fail(400, 'invalid_settlement', input.request_id);
|
||||
const handle = db();
|
||||
// Canonical key order also binds extra caller fields, preventing ambiguous retries.
|
||||
const fingerprint = JSON.stringify([
|
||||
action,
|
||||
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
|
||||
]);
|
||||
return handle
|
||||
.transaction(() => {
|
||||
const { server, guid, match, request_id } = input;
|
||||
const receipt =
|
||||
action === 'balance'
|
||||
? undefined
|
||||
: (handle
|
||||
.prepare(
|
||||
'SELECT instance_id,fingerprint,result FROM afk_operations WHERE server=? AND request_id=?'
|
||||
)
|
||||
.get(server, request_id) as
|
||||
{ instance_id: string; fingerprint: string; result: string } | undefined);
|
||||
if (receipt) {
|
||||
if (receipt.instance_id !== instanceId || receipt.fingerprint !== fingerprint)
|
||||
return fail(409, 'request_conflict', request_id);
|
||||
return { status: 200, body: JSON.parse(receipt.result) as AfkResult };
|
||||
}
|
||||
if (options.receiptOnly) return fail(200, 'not_applied', request_id);
|
||||
const session = token(input.session_id)
|
||||
? (handle
|
||||
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
|
||||
.get(input.session_id) as Session | undefined)
|
||||
: undefined;
|
||||
if (
|
||||
session &&
|
||||
(session.instance_id !== instanceId ||
|
||||
session.server !== server ||
|
||||
session.guid !== guid ||
|
||||
session.match_key !== match)
|
||||
)
|
||||
return fail(409, 'session_conflict', request_id);
|
||||
const now = new Date().toISOString();
|
||||
const nowMs = Date.parse(now);
|
||||
const eventAt = input.event_time ?? now;
|
||||
let matchId: number | null = session?.match_id ?? null;
|
||||
let account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
|
||||
Account | undefined;
|
||||
const held = () =>
|
||||
handle
|
||||
.prepare(
|
||||
"SELECT COALESCE(SUM(authorized_seconds),0) AS total, COALESCE(SUM(saved_seconds),0) AS saved FROM afk_sessions WHERE guid=? AND state IN ('reserved','started','unresolved')"
|
||||
)
|
||||
.get(guid) as { total: number; saved: number };
|
||||
const reply = (
|
||||
state: string,
|
||||
code = 'ok',
|
||||
extra: Partial<AfkResult> = {}
|
||||
): AfkResult => {
|
||||
const reserved = held();
|
||||
const bank =
|
||||
(
|
||||
handle
|
||||
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
|
||||
.get(guid) as { balance: number } | undefined
|
||||
)?.balance ?? 0;
|
||||
const pending = Boolean(
|
||||
handle
|
||||
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
|
||||
.get(guid)
|
||||
);
|
||||
return {
|
||||
...empty(code, request_id ?? ''),
|
||||
ok: state !== 'rejected',
|
||||
state,
|
||||
free_seconds: account?.free_seconds ?? 0,
|
||||
saved_seconds: account?.saved_seconds ?? 0,
|
||||
available_seconds: (account?.free_seconds ?? 0) + (account?.saved_seconds ?? 0),
|
||||
reserved_seconds: reserved.total,
|
||||
bank_balance: bank,
|
||||
max_purchase_packs: pending
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(bank / policy.purchasePoints),
|
||||
Math.floor(
|
||||
(policy.capacitySeconds -
|
||||
(account?.saved_seconds ?? 0) -
|
||||
reserved.saved) /
|
||||
policy.purchaseSeconds
|
||||
)
|
||||
)
|
||||
),
|
||||
...extra
|
||||
};
|
||||
};
|
||||
const finish = (body: AfkResult) => {
|
||||
if (action !== 'balance')
|
||||
handle
|
||||
.prepare(
|
||||
'INSERT INTO afk_operations(server,request_id,instance_id,guid,fingerprint,result,created_at) VALUES (?,?,?,?,?,?,?)'
|
||||
)
|
||||
.run(
|
||||
server,
|
||||
request_id,
|
||||
instanceId,
|
||||
guid,
|
||||
fingerprint,
|
||||
JSON.stringify(body),
|
||||
now
|
||||
);
|
||||
return { status: 200, body };
|
||||
};
|
||||
const reject = (code: string) => finish(reply('rejected', code));
|
||||
let historicalEvent = false;
|
||||
// Settlement belongs to the original immutable instance, independent of current membership.
|
||||
if (
|
||||
action !== 'settle' &&
|
||||
!(
|
||||
session &&
|
||||
(action === 'start' || action === 'reserve') &&
|
||||
['settled', 'corrected'].includes(session.state)
|
||||
)
|
||||
) {
|
||||
if (
|
||||
!handle
|
||||
.prepare(
|
||||
'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL'
|
||||
)
|
||||
.get(instanceId, server)
|
||||
)
|
||||
return fail(403, 'retired_instance', request_id);
|
||||
let member = handle
|
||||
.prepare(
|
||||
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at FROM leaderboard_matches m
|
||||
JOIN servers s ON s.name=m.server AND s.destroyed_at IS NULL
|
||||
JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=? AND p.present=1
|
||||
WHERE m.server=? AND m.reporter_match_key=? AND m.status='active'
|
||||
AND m.id=(SELECT MAX(id) FROM leaderboard_matches WHERE server=?)`
|
||||
)
|
||||
.get(guid, server, match, server) as
|
||||
{ id: number; started_at: string; observed_at: string } | undefined;
|
||||
if (!member && action === 'event') {
|
||||
member = handle
|
||||
.prepare(
|
||||
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at
|
||||
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=?
|
||||
JOIN native_instances n ON n.id=? AND n.server=m.server
|
||||
WHERE m.server=? AND m.reporter_match_key=?
|
||||
AND julianday(n.created_at)<=julianday(m.started_at)+60.0/86400
|
||||
AND (n.retired_at IS NULL OR julianday(n.retired_at)>=julianday(m.started_at))
|
||||
AND NOT EXISTS(SELECT 1 FROM native_instances newer WHERE newer.server=m.server
|
||||
AND newer.created_at>n.created_at AND newer.created_at<=m.started_at)
|
||||
ORDER BY m.id DESC LIMIT 1`
|
||||
)
|
||||
.get(guid, instanceId, server, match) as typeof member;
|
||||
historicalEvent = Boolean(member);
|
||||
if (!member) return reject('not_in_match');
|
||||
}
|
||||
if (!member) {
|
||||
const known = handle
|
||||
.prepare(
|
||||
'SELECT id,status FROM leaderboard_matches WHERE server=? AND reporter_match_key=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.get(server, match) as { id: number; status: string } | undefined;
|
||||
const latest = handle
|
||||
.prepare('SELECT MAX(id) AS id FROM leaderboard_matches WHERE server=?')
|
||||
.get(server) as { id: number | null };
|
||||
const departed =
|
||||
known &&
|
||||
handle
|
||||
.prepare(
|
||||
'SELECT 1 FROM leaderboard_match_players WHERE match_id=? AND guid=? AND present=0'
|
||||
)
|
||||
.get(known.id, guid);
|
||||
if (known && (known.status !== 'active' || known.id !== latest.id || departed))
|
||||
return reject('not_in_match');
|
||||
return fail(409, 'not_in_match', request_id);
|
||||
}
|
||||
matchId = member.id;
|
||||
if (
|
||||
action === 'event' &&
|
||||
input.active_seconds >
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
((historicalEvent
|
||||
? Math.min(nowMs, Date.parse(member.observed_at))
|
||||
: nowMs) -
|
||||
Date.parse(member.started_at)) /
|
||||
1000
|
||||
)
|
||||
) +
|
||||
60
|
||||
)
|
||||
return reject('invalid_active_time');
|
||||
}
|
||||
if (
|
||||
action === 'settle' &&
|
||||
!session &&
|
||||
!(input.end_reason === 'never_started' && input.consumed_seconds === 0)
|
||||
)
|
||||
return reject('not_reserved');
|
||||
if (
|
||||
action === 'settle' &&
|
||||
!session &&
|
||||
!handle
|
||||
.prepare('SELECT 1 FROM native_instances WHERE id=? AND server=?')
|
||||
.get(instanceId, server)
|
||||
)
|
||||
return fail(403, 'unknown_instance', request_id);
|
||||
const ledger = (kind: string, freeDelta = 0, savedDelta = 0, details: unknown = {}) => {
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
guid,
|
||||
server,
|
||||
matchId,
|
||||
request_id ?? `free:${nowMs}`,
|
||||
input.session_id ?? null,
|
||||
kind,
|
||||
freeDelta,
|
||||
savedDelta,
|
||||
account?.free_seconds ?? 0,
|
||||
account?.saved_seconds ?? 0,
|
||||
eventAt,
|
||||
now,
|
||||
JSON.stringify(details)
|
||||
);
|
||||
};
|
||||
const saveAccount = () => {
|
||||
if (!account) throw new Error('Missing AFK account');
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE afk_accounts SET free_seconds=?,saved_seconds=?,free_started_at=?,active_seconds=?,kill_progress=?,headshot_progress=?,bonus_hour=?,bonus_seconds=? WHERE guid=?`
|
||||
)
|
||||
.run(
|
||||
account.free_seconds,
|
||||
account.saved_seconds,
|
||||
account.free_started_at,
|
||||
account.active_seconds,
|
||||
account.kill_progress,
|
||||
account.headshot_progress,
|
||||
account.bonus_hour,
|
||||
account.bonus_seconds,
|
||||
guid
|
||||
);
|
||||
};
|
||||
if (!account) {
|
||||
account = {
|
||||
guid,
|
||||
free_seconds: policy.freeSeconds,
|
||||
saved_seconds: 0,
|
||||
free_started_at: nowMs,
|
||||
active_seconds: 0,
|
||||
kill_progress: 0,
|
||||
headshot_progress: 0,
|
||||
bonus_hour: 0,
|
||||
bonus_seconds: 0
|
||||
};
|
||||
handle
|
||||
.prepare(
|
||||
'INSERT INTO afk_accounts(guid,free_seconds,free_started_at) VALUES (?,?,?)'
|
||||
)
|
||||
.run(guid, policy.freeSeconds, nowMs);
|
||||
ledger('free_grant', policy.freeSeconds);
|
||||
} else {
|
||||
const free = freeAvailability(guid, policy.freeSeconds, nowMs);
|
||||
const delta = free.seconds - account.free_seconds;
|
||||
if (delta !== 0) {
|
||||
account.free_seconds = free.seconds;
|
||||
saveAccount();
|
||||
ledger('free_grant', delta, 0, { rolling: true });
|
||||
}
|
||||
}
|
||||
const sessionReply = (row: Session) =>
|
||||
reply(row.state, 'ok', {
|
||||
session_id: row.session_id,
|
||||
authorized_seconds: row.authorized_seconds,
|
||||
consumed_seconds: row.consumed_seconds
|
||||
});
|
||||
if (action === 'balance') return finish(reply('balance'));
|
||||
if (action === 'purchase') {
|
||||
if (
|
||||
input.expected_purchase_points !== policy.purchasePoints ||
|
||||
input.expected_purchase_seconds !== policy.purchaseSeconds
|
||||
)
|
||||
return reject('price_changed');
|
||||
const cost = input.packs * policy.purchasePoints;
|
||||
const seconds = input.packs * policy.purchaseSeconds;
|
||||
if (
|
||||
handle
|
||||
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
|
||||
.get(guid)
|
||||
)
|
||||
return reject('bank_pending');
|
||||
const bank =
|
||||
(
|
||||
handle
|
||||
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
|
||||
.get(guid) as { balance: number } | undefined
|
||||
)?.balance ?? 0;
|
||||
if (cost > bank) return reject('insufficient_balance');
|
||||
if (account.saved_seconds + held().saved + seconds > policy.capacitySeconds)
|
||||
return reject('reserve_capacity');
|
||||
const bankId = `afk:${createHash('sha256').update(`${instanceId}:${request_id}`).digest('hex').slice(0, 60)}`;
|
||||
handle
|
||||
.prepare('UPDATE bank_accounts SET balance=balance-? WHERE guid=?')
|
||||
.run(cost, guid);
|
||||
handle
|
||||
.prepare(
|
||||
'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)'
|
||||
)
|
||||
.run(guid, server, bankId, matchId, -cost, bank - cost, now);
|
||||
account.saved_seconds += seconds;
|
||||
saveAccount();
|
||||
ledger('purchase', 0, seconds, { points: cost, bank_request_id: bankId });
|
||||
return finish(reply('purchased', 'ok', { awarded_seconds: seconds }));
|
||||
}
|
||||
if (action === 'event') {
|
||||
if (
|
||||
!historicalEvent &&
|
||||
handle
|
||||
.prepare(
|
||||
"SELECT 1 FROM afk_sessions WHERE guid=? AND state IN ('reserved','started')"
|
||||
)
|
||||
.get(guid)
|
||||
)
|
||||
return reject('session_pending');
|
||||
const previous = handle
|
||||
.prepare(
|
||||
'SELECT * FROM afk_progress WHERE instance_id=? AND match_key=? AND guid=?'
|
||||
)
|
||||
.get(instanceId, match, guid) as Progress | undefined;
|
||||
if (
|
||||
previous &&
|
||||
input.seq === previous.seq &&
|
||||
counters.some((key) => input[key] !== previous[key])
|
||||
)
|
||||
return reject('progress_conflict');
|
||||
if (previous && input.seq <= previous.seq) {
|
||||
if (counters.some((key) => input[key] > previous[key]))
|
||||
return reject('stale_progress');
|
||||
return finish(reply('rewarded', 'stale', { awarded_seconds: 0 }));
|
||||
}
|
||||
if (previous && counters.some((key) => input[key] < previous[key]))
|
||||
return reject('progress_regression');
|
||||
const delta = Object.fromEntries(
|
||||
counters.map((key) => [key, input[key] - (previous?.[key] ?? 0)])
|
||||
) as Omit<Progress, 'seq'>;
|
||||
const activeBefore = account.active_seconds;
|
||||
account.active_seconds += delta.active_seconds;
|
||||
const activeAward =
|
||||
(Math.floor(account.active_seconds / policy.activeInterval) -
|
||||
Math.floor(activeBefore / policy.activeInterval)) *
|
||||
policy.activeReward;
|
||||
const kills = account.kill_progress + delta.kills;
|
||||
const headshots = account.headshot_progress + delta.headshots;
|
||||
const performance =
|
||||
delta.rounds * policy.roundReward +
|
||||
Math.floor(kills / policy.killInterval) * policy.killReward +
|
||||
Math.floor(headshots / policy.headshotInterval) * policy.headshotReward;
|
||||
account.kill_progress = kills % policy.killInterval;
|
||||
account.headshot_progress = headshots % policy.headshotInterval;
|
||||
handle
|
||||
.prepare('DELETE FROM afk_bonus_rewards WHERE guid=? AND active_second<=?')
|
||||
.run(guid, account.active_seconds - 3600);
|
||||
const spent = (
|
||||
handle
|
||||
.prepare(
|
||||
'SELECT COALESCE(SUM(seconds),0) AS seconds FROM afk_bonus_rewards WHERE guid=?'
|
||||
)
|
||||
.get(guid) as { seconds: number }
|
||||
).seconds;
|
||||
const bonus = Math.min(performance, Math.max(0, policy.bonusCap - spent));
|
||||
if (bonus > 0)
|
||||
handle
|
||||
.prepare(
|
||||
'INSERT INTO afk_bonus_rewards(guid,active_second,seconds) VALUES (?,?,?)'
|
||||
)
|
||||
.run(guid, account.active_seconds, bonus);
|
||||
const reviveAward = delta.revives * policy.reviveReward;
|
||||
const awarded = Math.min(
|
||||
activeAward + bonus + reviveAward,
|
||||
Math.max(0, policy.capacitySeconds - account.saved_seconds - held().saved)
|
||||
);
|
||||
account.saved_seconds += awarded;
|
||||
saveAccount();
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO afk_progress(instance_id,match_key,guid,seq,active_seconds,kills,headshots,rounds,revives) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(instance_id,match_key,guid) DO UPDATE SET seq=excluded.seq,active_seconds=excluded.active_seconds,kills=excluded.kills,headshots=excluded.headshots,rounds=excluded.rounds,revives=excluded.revives`
|
||||
)
|
||||
.run(
|
||||
instanceId,
|
||||
match,
|
||||
guid,
|
||||
input.seq,
|
||||
input.active_seconds,
|
||||
input.kills,
|
||||
input.headshots,
|
||||
input.rounds,
|
||||
input.revives
|
||||
);
|
||||
ledger('reward', 0, awarded, {
|
||||
...delta,
|
||||
active_reward: activeAward,
|
||||
performance_reward: bonus,
|
||||
revive_reward: reviveAward,
|
||||
discarded_seconds: activeAward + bonus + reviveAward - awarded
|
||||
});
|
||||
return finish(reply('rewarded', 'ok', { awarded_seconds: awarded }));
|
||||
}
|
||||
if (action === 'reserve') {
|
||||
if (session) return finish(sessionReply(session));
|
||||
if (held().total > 0) return reject('session_pending');
|
||||
const authorized = Math.min(
|
||||
input.seconds,
|
||||
account.free_seconds + account.saved_seconds
|
||||
);
|
||||
if (authorized === 0) return reject('empty');
|
||||
const free = Math.min(account.free_seconds, authorized);
|
||||
const saved = authorized - free;
|
||||
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,created_at)
|
||||
VALUES (?,?,?,?,?,?,'reserved',?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
input.session_id,
|
||||
server,
|
||||
instanceId,
|
||||
guid,
|
||||
match,
|
||||
matchId,
|
||||
authorized,
|
||||
free,
|
||||
saved,
|
||||
account.free_started_at,
|
||||
now
|
||||
);
|
||||
account.free_seconds -= free;
|
||||
account.saved_seconds -= saved;
|
||||
saveAccount();
|
||||
ledger('reserve', -free, -saved, { authorized_seconds: authorized });
|
||||
return finish(
|
||||
reply('reserved', 'ok', {
|
||||
session_id: input.session_id,
|
||||
authorized_seconds: authorized,
|
||||
consumed_seconds: null
|
||||
})
|
||||
);
|
||||
}
|
||||
if (action === 'start') {
|
||||
if (!session) return reject('not_reserved');
|
||||
if (session.state !== 'reserved') return finish(sessionReply(session));
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE afk_sessions SET state='started',started_at=? WHERE session_id=?"
|
||||
)
|
||||
.run(eventAt, input.session_id);
|
||||
ledger('start');
|
||||
return finish(sessionReply({ ...session, state: 'started', started_at: eventAt }));
|
||||
}
|
||||
if (!session) {
|
||||
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,end_reason,created_at,ended_at)
|
||||
VALUES (?,?,?,?,?,?,'settled',0,0,0,?,0,'never_started',?,?)`
|
||||
)
|
||||
.run(
|
||||
input.session_id,
|
||||
server,
|
||||
instanceId,
|
||||
guid,
|
||||
match,
|
||||
matchId,
|
||||
account.free_started_at,
|
||||
now,
|
||||
eventAt
|
||||
);
|
||||
ledger('release', 0, 0, { never_started: true });
|
||||
return finish(
|
||||
reply('settled', 'ok', {
|
||||
session_id: input.session_id,
|
||||
authorized_seconds: 0,
|
||||
consumed_seconds: 0
|
||||
})
|
||||
);
|
||||
}
|
||||
if (['settled', 'corrected'].includes(session.state))
|
||||
return finish(sessionReply(session));
|
||||
const confirmed = input.confirmed_seconds ?? input.consumed_seconds ?? 0;
|
||||
if (
|
||||
confirmed > session.authorized_seconds ||
|
||||
(input.consumed_seconds !== null &&
|
||||
(input.consumed_seconds > session.authorized_seconds ||
|
||||
input.consumed_seconds < session.confirmed_seconds ||
|
||||
confirmed > input.consumed_seconds)) ||
|
||||
(input.end_reason === 'expired' &&
|
||||
input.consumed_seconds !== session.authorized_seconds)
|
||||
)
|
||||
return reject('invalid_consumption');
|
||||
if (input.consumed_seconds === null) {
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE afk_sessions SET state='unresolved',confirmed_seconds=MAX(confirmed_seconds,?),end_reason='crash',ended_at=? WHERE session_id=?"
|
||||
)
|
||||
.run(confirmed, eventAt, input.session_id);
|
||||
ledger('unresolved', 0, 0, {
|
||||
confirmed_seconds: Math.max(confirmed, session.confirmed_seconds)
|
||||
});
|
||||
return finish(
|
||||
sessionReply({ ...session, state: 'unresolved', confirmed_seconds: confirmed })
|
||||
);
|
||||
}
|
||||
const consumed = input.consumed_seconds;
|
||||
const freeConsumed = Math.min(session.free_seconds, consumed);
|
||||
const savedConsumed = consumed - freeConsumed;
|
||||
const priorFree = account.free_seconds;
|
||||
const savedRelease = session.saved_seconds - savedConsumed;
|
||||
account.saved_seconds += savedRelease;
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE afk_sessions SET state='settled',consumed_seconds=?,charged_seconds=?,free_charge_at=?,confirmed_seconds=?,end_reason=?,ended_at=? WHERE session_id=?"
|
||||
)
|
||||
.run(
|
||||
consumed,
|
||||
consumed,
|
||||
nowMs,
|
||||
consumed,
|
||||
input.end_reason,
|
||||
eventAt,
|
||||
input.session_id
|
||||
);
|
||||
account.free_seconds = freeAvailability(guid, policy.freeSeconds, nowMs).seconds;
|
||||
const freeRelease = account.free_seconds - priorFree;
|
||||
saveAccount();
|
||||
ledger('consume', 0, 0, {
|
||||
consumed_seconds: consumed,
|
||||
free_consumed: freeConsumed,
|
||||
saved_consumed: savedConsumed,
|
||||
end_reason: input.end_reason
|
||||
});
|
||||
ledger('release', freeRelease, savedRelease, {
|
||||
expired_free_seconds: session.free_seconds - freeConsumed - freeRelease
|
||||
});
|
||||
return finish(
|
||||
sessionReply({ ...session, state: 'settled', consumed_seconds: consumed })
|
||||
);
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
|
||||
export function afkProfile(guid: string) {
|
||||
const handle = db();
|
||||
const account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
|
||||
Account | undefined;
|
||||
const summary = handle
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END),0) AS reservedSeconds,
|
||||
COALESCE(SUM(consumed_seconds),0) AS confirmedAfkSeconds,
|
||||
SUM(CASE WHEN state='unresolved' THEN 1 ELSE 0 END) AS unresolvedSessions,
|
||||
SUM(CASE WHEN end_reason='expired' AND state='settled' THEN 1 ELSE 0 END) AS expiredSessions,
|
||||
SUM(CASE WHEN end_reason='manual' AND state='settled' THEN 1 ELSE 0 END) AS manualReturns
|
||||
FROM afk_sessions WHERE guid=?`
|
||||
)
|
||||
.get(guid) as Record<string, number | null>;
|
||||
const purchases = handle
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS purchaseCount,COALESCE(SUM(saved_delta),0) AS purchasedSeconds FROM afk_ledger WHERE guid=? AND kind='purchase'"
|
||||
)
|
||||
.get(guid) as { purchaseCount: number; purchasedSeconds: number };
|
||||
const entries = handle
|
||||
.prepare(
|
||||
`SELECT l.id,l.server,l.kind,l.free_delta AS freeDelta,l.saved_delta AS savedDelta,
|
||||
l.free_seconds AS freeSeconds,l.saved_seconds AS savedSeconds,l.session_id AS sessionId,l.operation_id AS operationId,
|
||||
l.event_at AS eventAt,l.created_at AS createdAt,l.match_id AS matchId,CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable
|
||||
FROM afk_ledger l LEFT JOIN leaderboard_matches m ON m.id=l.match_id WHERE l.guid=? ORDER BY l.id DESC LIMIT 20`
|
||||
)
|
||||
.all(guid) as {
|
||||
id: number;
|
||||
server: string;
|
||||
kind: string;
|
||||
freeDelta: number;
|
||||
savedDelta: number;
|
||||
freeSeconds: number;
|
||||
savedSeconds: number;
|
||||
sessionId: string | null;
|
||||
operationId: string;
|
||||
eventAt: string;
|
||||
createdAt: string;
|
||||
matchId: number | null;
|
||||
matchAvailable: number;
|
||||
}[];
|
||||
const sessions = handle
|
||||
.prepare(
|
||||
`SELECT session_id AS sessionId,server,state,authorized_seconds AS authorizedSeconds,
|
||||
consumed_seconds AS consumedSeconds,charged_seconds AS chargedSeconds,confirmed_seconds AS confirmedSeconds,end_reason AS endReason,started_at AS startedAt,ended_at AS endedAt
|
||||
FROM afk_sessions WHERE guid=? ORDER BY created_at DESC LIMIT 20`
|
||||
)
|
||||
.all(guid) as {
|
||||
sessionId: string;
|
||||
server: string;
|
||||
state: string;
|
||||
authorizedSeconds: number;
|
||||
consumedSeconds: number | null;
|
||||
chargedSeconds: number | null;
|
||||
confirmedSeconds: number;
|
||||
endReason: string | null;
|
||||
startedAt: string | null;
|
||||
endedAt: string | null;
|
||||
}[];
|
||||
const free = freeAvailability(guid, afkPolicy().freeSeconds);
|
||||
return {
|
||||
freeSeconds: account ? free.seconds : 0,
|
||||
savedSeconds: account?.saved_seconds ?? 0,
|
||||
availableSeconds: (account ? free.seconds : 0) + (account?.saved_seconds ?? 0),
|
||||
reservedSeconds: summary.reservedSeconds ?? 0,
|
||||
capacitySeconds: afkPolicy().capacitySeconds,
|
||||
freeRenewsAt: free.nextAt ? new Date(free.nextAt).toISOString() : null,
|
||||
activeSeconds: account?.active_seconds ?? 0,
|
||||
confirmedAfkSeconds: summary.confirmedAfkSeconds ?? 0,
|
||||
unresolvedSessions: summary.unresolvedSessions ?? 0,
|
||||
expiredSessions: summary.expiredSessions ?? 0,
|
||||
manualReturns: summary.manualReturns ?? 0,
|
||||
...purchases,
|
||||
entries,
|
||||
sessions
|
||||
};
|
||||
}
|
||||
|
||||
/** Held authorization never ages out. Confirmed free use replenishes 24h after stop receipt. */
|
||||
function freeAvailability(guid: string, allowance: number, now = Date.now()) {
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
|
||||
WHEN free_charge_at>? THEN MIN(free_seconds,charged_seconds) ELSE 0 END),0) AS used,
|
||||
MIN(CASE WHEN free_charge_at>? AND MIN(free_seconds,charged_seconds)>0 THEN free_charge_at END) AS next_at
|
||||
FROM afk_sessions WHERE guid=?`
|
||||
)
|
||||
.get(now - DAY, now - DAY, guid) as { used: number; next_at: number | null };
|
||||
return {
|
||||
seconds: Math.max(0, allowance - row.used),
|
||||
nextAt: row.next_at === null ? null : row.next_at + DAY
|
||||
};
|
||||
}
|
||||
|
||||
/** An administrator records an explicit accounting correction, never a fabricated exact duration. */
|
||||
export function afkCorrectSession(
|
||||
value: unknown,
|
||||
actor: string
|
||||
): { status: number; body: AfkResult } {
|
||||
const policy = afkPolicy();
|
||||
const invalid = (code: string, status = 400) => ({
|
||||
status,
|
||||
body: {
|
||||
ok: false,
|
||||
state: 'rejected',
|
||||
code,
|
||||
request_id: '',
|
||||
free_seconds: 0,
|
||||
saved_seconds: 0,
|
||||
available_seconds: 0,
|
||||
reserved_seconds: 0,
|
||||
capacity_seconds: policy.capacitySeconds,
|
||||
authorized_seconds: 0,
|
||||
bank_balance: 0,
|
||||
purchase_points: policy.purchasePoints,
|
||||
purchase_seconds: policy.purchaseSeconds,
|
||||
max_purchase_packs: 0
|
||||
} as AfkResult
|
||||
});
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return invalid('invalid_correction');
|
||||
const input = value as {
|
||||
session_id: string;
|
||||
request_id: string;
|
||||
consumed_seconds: number;
|
||||
reason: string;
|
||||
stopped: boolean;
|
||||
};
|
||||
if (
|
||||
!token(input.session_id) ||
|
||||
!token(input.request_id) ||
|
||||
!integer(input.consumed_seconds, 0, 604800) ||
|
||||
input.stopped !== true ||
|
||||
typeof input.reason !== 'string' ||
|
||||
input.reason.trim().length < 5 ||
|
||||
input.reason.length > 1000
|
||||
)
|
||||
return invalid('invalid_correction');
|
||||
const handle = db();
|
||||
return handle
|
||||
.transaction(() => {
|
||||
const session = handle
|
||||
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
|
||||
.get(input.session_id) as Session | undefined;
|
||||
if (!session) return invalid('not_reserved', 404);
|
||||
const request = {
|
||||
server: session.server,
|
||||
guid: session.guid,
|
||||
match: session.match_key,
|
||||
request_id: `correction:${createHash('sha256').update(input.request_id).digest('hex').slice(0, 53)}`,
|
||||
session_id: input.session_id,
|
||||
consumed_seconds: input.consumed_seconds,
|
||||
end_reason: 'crash',
|
||||
correction_reason: input.reason,
|
||||
correction_actor: actor
|
||||
};
|
||||
const prior = afkRequest('settle', request, session.server, session.instance_id, {
|
||||
receiptOnly: true
|
||||
});
|
||||
if (prior.body.code !== 'not_applied') return prior;
|
||||
if (session.state !== 'unresolved') return invalid('not_unresolved', 409);
|
||||
if (
|
||||
input.consumed_seconds < session.confirmed_seconds ||
|
||||
input.consumed_seconds > session.authorized_seconds
|
||||
)
|
||||
return invalid('invalid_consumption');
|
||||
const result = afkRequest('settle', request, session.server, session.instance_id);
|
||||
if (!result.body.ok) return result;
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE afk_sessions SET state='corrected',consumed_seconds=NULL,confirmed_seconds=? WHERE session_id=?"
|
||||
)
|
||||
.run(session.confirmed_seconds, session.session_id);
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE afk_ledger SET kind='correction',details=? WHERE server=? AND operation_id=? AND kind='consume'"
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({
|
||||
actor,
|
||||
reason: input.reason,
|
||||
charged_seconds: input.consumed_seconds,
|
||||
confirmed_seconds: session.confirmed_seconds
|
||||
}),
|
||||
session.server,
|
||||
request.request_id
|
||||
);
|
||||
result.body.state = 'corrected';
|
||||
result.body.consumed_seconds = null;
|
||||
handle
|
||||
.prepare('UPDATE afk_operations SET result=? WHERE server=? AND request_id=?')
|
||||
.run(JSON.stringify(result.body), session.server, request.request_id);
|
||||
return result;
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import type { afkProfile } from '$lib/server/fleet/afk.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
|
||||
let { account }: { account: ReturnType<typeof afkProfile> } = $props();
|
||||
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||
const change = (seconds: number) =>
|
||||
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
|
||||
const label = (value: string) => value.replaceAll('_', ' ');
|
||||
</script>
|
||||
|
||||
<section aria-labelledby="afk-title">
|
||||
<h2 id="afk-title">AFK reserve</h2>
|
||||
<p>
|
||||
<strong>{duration(account.freeSeconds)} free</strong> and
|
||||
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
|
||||
Saved capacity: {duration(account.capacitySeconds)}.
|
||||
</p>
|
||||
{#if account.freeRenewsAt}
|
||||
<p>
|
||||
Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free
|
||||
time does not accumulate.
|
||||
</p>
|
||||
{/if}
|
||||
{#if account.reservedSeconds > 0}
|
||||
<p>
|
||||
{duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers.
|
||||
</p>
|
||||
{/if}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Recorded active play</dt>
|
||||
<dd>{duration(account.activeSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Confirmed AFK time</dt>
|
||||
<dd>{duration(account.confirmedAfkSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Manual returns</dt>
|
||||
<dd>{account.manualReturns}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expirations</dt>
|
||||
<dd>{account.expiredSessions}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Purchases</dt>
|
||||
<dd>{account.purchaseCount} ({duration(account.purchasedSeconds)})</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if account.unresolvedSessions > 0}
|
||||
<p>
|
||||
{account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded
|
||||
from confirmed AFK usage.
|
||||
</p>
|
||||
{/if}
|
||||
{#if account.entries.length}
|
||||
<details>
|
||||
<summary>Recent time ledger</summary>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<caption>Recent AFK grants, reservations and consumption</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Time / 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 account.entries as entry (entry.id)}
|
||||
<tr>
|
||||
<td
|
||||
>{$timeFormat.dateTime(entry.eventAt)}<small
|
||||
>{label(entry.kind)}</small
|
||||
><small>{entry.operationId}</small></td
|
||||
>
|
||||
<td>{change(entry.freeDelta)}</td>
|
||||
<td>{change(entry.savedDelta)}</td>
|
||||
<td
|
||||
>{duration(entry.freeSeconds)} free<small
|
||||
>{duration(entry.savedSeconds)} saved</small
|
||||
></td
|
||||
>
|
||||
<td
|
||||
>{serverTitle(entry.server)}<small
|
||||
>{#if entry.matchAvailable}<a
|
||||
href={`/matches/${entry.matchId}`}
|
||||
>Match #{entry.matchId}</a
|
||||
>{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}</small
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<p>No AFK activity recorded yet.</p>
|
||||
{/if}
|
||||
{#if account.sessions.length}
|
||||
<details>
|
||||
<summary>Recent AFK sessions</summary>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<caption>Accepted AFK transitions</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Started</th><th scope="col">Server</th><th scope="col"
|
||||
>Duration</th
|
||||
><th scope="col">Outcome</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each account.sessions as session (session.sessionId)}
|
||||
<tr>
|
||||
<td
|
||||
>{session.startedAt
|
||||
? $timeFormat.dateTime(session.startedAt)
|
||||
: 'Start unconfirmed'}</td
|
||||
>
|
||||
<td>{serverTitle(session.server)}</td>
|
||||
<td
|
||||
>{session.consumedSeconds === null
|
||||
? 'Unconfirmed'
|
||||
: duration(session.consumedSeconds)}
|
||||
{#if session.state === 'corrected' && session.chargedSeconds !== null}
|
||||
<small
|
||||
>Corrected charge: {duration(
|
||||
session.chargedSeconds
|
||||
)}</small
|
||||
>
|
||||
{/if}</td
|
||||
>
|
||||
<td
|
||||
>{label(
|
||||
session.endReason ?? session.state
|
||||
)}{#if session.endedAt}<small
|
||||
>{$timeFormat.dateTime(session.endedAt)}</small
|
||||
>{/if}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
dt,
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
details {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { json, isHttpError } from '@sveltejs/kit';
|
||||
import { authenticateBank } from '$lib/server/auth/bank.js';
|
||||
import { afkRequest, type AfkAction } from '$lib/server/fleet/afk.js';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
|
||||
export async function POST({ request, params }: { request: Request; params: { action: string } }) {
|
||||
const headers = { 'Cache-Control': 'no-store' };
|
||||
const authentication = authenticateBank(request);
|
||||
if (!authentication.ok)
|
||||
return json(
|
||||
{ ok: false, code: authentication.code },
|
||||
{ status: authentication.status, headers }
|
||||
);
|
||||
if (!['balance', 'event', 'purchase', 'reserve', 'start', 'settle'].includes(params.action))
|
||||
return json({ ok: false, code: 'invalid_action' }, { status: 404, headers });
|
||||
if (
|
||||
request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
|
||||
'application/json'
|
||||
)
|
||||
return json({ ok: false, code: 'invalid_content_type' }, { status: 415, headers });
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await jsonBody(request, 4096);
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause, 413)) throw cause;
|
||||
return json({ ok: false, code: 'invalid_request' }, { status: 400, headers });
|
||||
}
|
||||
try {
|
||||
const { body, status } = afkRequest(
|
||||
params.action as AfkAction,
|
||||
value,
|
||||
authentication.server,
|
||||
authentication.instanceId
|
||||
);
|
||||
return json(body, { status, headers });
|
||||
} catch {
|
||||
console.error('AFK transaction failed; request may be retried with the same identity.');
|
||||
return json({ ok: false, code: 'unavailable' }, { status: 503, headers });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { afkCorrectSession } from '$lib/server/fleet/afk.js';
|
||||
|
||||
/** Human reconciliation is restricted to sessions already confirmed stopped. */
|
||||
export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {
|
||||
if (!locals.user || locals.user.role !== 'admin') error(403, 'Administrator access required.');
|
||||
const result = afkCorrectSession(await jsonBody(request, 4096), locals.user.id);
|
||||
return json(result.body, { status: result.status, headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { authenticateAgent, objectBody } from '$lib/server/hosts/http.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { afkRequest } from '$lib/server/fleet/afk.js';
|
||||
|
||||
/** Only the assigned teardown host may reconcile a stopped runtime. */
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
const body = await objectBody(request, 24576);
|
||||
if (
|
||||
typeof body.operationId !== 'string' ||
|
||||
!['settle', 'event', 'purchase'].includes(String(body.action))
|
||||
)
|
||||
error(400, 'Invalid AFK recovery.');
|
||||
const scope = db()
|
||||
.prepare(
|
||||
`SELECT n.id, n.server FROM ec_host_operations o
|
||||
JOIN native_instances n ON n.id=o.instance_id
|
||||
WHERE o.id=? AND o.host_id=? AND o.kind IN ('instance.remove','instance.rebuild','instance.cancel-create')
|
||||
AND o.state IN ('pending','accepted','running','unknown')`
|
||||
)
|
||||
.get(body.operationId, host.id) as { id: string; server: string } | undefined;
|
||||
if (!scope) error(403, 'AFK recovery requires an assigned, unfinished teardown.');
|
||||
const result = afkRequest(
|
||||
body.action as 'settle' | 'event' | 'purchase',
|
||||
body.transfer,
|
||||
scope.server,
|
||||
scope.id,
|
||||
{ receiptOnly: body.action !== 'settle' }
|
||||
);
|
||||
return json(result.body, { status: result.status, headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bankProfile } from '$lib/server/fleet/bank.js';
|
||||
import { afkProfile } from '$lib/server/fleet/afk.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { playerReport } from '$lib/server/reports/index.js';
|
||||
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
|
||||
@@ -40,6 +41,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL
|
||||
return {
|
||||
profile,
|
||||
bank: bankProfile(params.guid),
|
||||
afk: afkProfile(params.guid),
|
||||
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
|
||||
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry }
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { bankProfile } from '$lib/server/fleet/bank.js';
|
||||
import type { afkProfile } from '$lib/server/fleet/afk.js';
|
||||
import AfkReserveCard from '$ui/components/AfkReserveCard.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
@@ -47,6 +49,7 @@
|
||||
data: {
|
||||
profile: PlayerProfile;
|
||||
bank: ReturnType<typeof bankProfile>;
|
||||
afk: ReturnType<typeof afkProfile>;
|
||||
returnTo: string;
|
||||
scope: {
|
||||
game: string;
|
||||
@@ -220,6 +223,7 @@
|
||||
</div>
|
||||
{:else}<p>No bank transfers yet.</p>{/if}
|
||||
</section>
|
||||
<AfkReserveCard account={data.afk} />
|
||||
<nav class="jump-links" aria-label="Player sections">
|
||||
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
|
||||
>Matches</a
|
||||
|
||||
Reference in New Issue
Block a user