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