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