945 lines
42 KiB
TypeScript
945 lines
42 KiB
TypeScript
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();
|
|
}
|