The PvP_Characters table tallied wins and losses but held no token balance and no purchase endpoint: earnings were client-reported (winners credited themselves locally), so verifying only a spend would have been security theater — a cheater could mint tokens by self-reporting battle wins. The server is now the ledger authority (FRE-142): registerCharacter seeds the balance from the client cache exactly once and reconciles it on every sync; battleResult requires the verified Lineage JWT bearer, rejects non-participants, and credits the winner via the reward formula ported from the client (computed from both registered rows); a new purchase mutation checks cost, balance, and cooldown in one conditional UPDATE scoped to the caller-owned row, so the spend is atomic under concurrency and rate limiting survives cold starts. Schema changes ship as an idempotent pvp-schema bootstrap (pvpTokens, ownerUserId, lastPurchaseAt), mirroring the analytics-schema pattern. Client-side counterpart shipped in the lineage repo; design record in its docs/security/pvp-server-side-purchases.md. Tests: pvp-schema (DDL + legacy-column migration + idempotency), pvp-rewards (formula parity with the client port, cost table), pvp-ledger (seed-once reconciliation, owner binding, participant verification, atomic spend, cooldown, refusal reasons) — 31 new tests against in-memory SQLite; suite 572 pass (1 pre-existing audit date-filter failure, unrelated).
396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
/**
|
|
* PvP Blood Token ledger operations (FRE-142) — the authoritative earn/spend
|
|
* logic behind the `lineage.pvp` router.
|
|
*
|
|
* Extracted from the router (mirroring the nessa-community authz pattern)
|
|
* so the authorization + atomicity decisions can be unit-tested against an
|
|
* in-memory SQLite database wrapped to the libsql `execute` contract. The
|
|
* router calls these helpers in the same order, so a pass here guarantees
|
|
* the decision each endpoint makes before touching data.
|
|
*
|
|
* Ledger invariants:
|
|
* - `pvpTokens` is server-owned. NULL means "not yet seeded"; the first
|
|
* registerCharacter call seeds it from the client's cached balance
|
|
* exactly once (one-time migration of pre-FRE-142 balances).
|
|
* - Earnings happen only in `recordBattleResult`, computed from both
|
|
* combatants' registered rows via the ported reward formula — never
|
|
* from client-supplied amounts.
|
|
* - Spend happens only in `purchaseReward`, as a single conditional
|
|
* UPDATE (balance + cooldown checked in the WHERE clause) so it is
|
|
* atomic under concurrency.
|
|
* - `ownerUserId` (from the verified Lineage JWT) gates every mutation:
|
|
* a caller can only earn on, and spend from, a row they own.
|
|
*/
|
|
|
|
import { ensurePvpSchema } from "~/server/pvp-schema";
|
|
import {
|
|
PVP_PURCHASE_COOLDOWN_MS,
|
|
PVP_PURCHASE_COSTS,
|
|
computeWinReward,
|
|
type PvpCombatantRow
|
|
} from "~/server/pvp-rewards";
|
|
|
|
/** libsql-shaped connection (real client or the bun:sqlite test adapter). */
|
|
export interface PvpLedgerConn {
|
|
execute(stmt: {
|
|
sql: string;
|
|
args?: (string | number | null)[];
|
|
}): Promise<{ rows: Record<string, unknown>[]; rowsAffected?: number }>;
|
|
}
|
|
|
|
/** Registered-character fields as persisted on `PvP_Characters`. */
|
|
export interface RegisteredCharacter {
|
|
playerClass: string;
|
|
blessing?: string | null;
|
|
name: string;
|
|
maxHealth: number;
|
|
maxSanity: number;
|
|
maxMana: number;
|
|
baseManaRegen: number;
|
|
strength: number;
|
|
intelligence: number;
|
|
dexterity: number;
|
|
resistanceTable: string;
|
|
damageTable: string;
|
|
attackStrings: string;
|
|
knownSpells: string;
|
|
}
|
|
|
|
function num(value: unknown, fallback = 0): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
/**
|
|
* Register (or refresh) the caller's character row and reconcile the ledger.
|
|
*
|
|
* - New row: seeded with the caller's cached balance (`seedTokens`) — a
|
|
* fresh character is 0 — and bound to `ownerUserId` when the caller is
|
|
* authenticated.
|
|
* - Existing row: character fields refresh; `ownerUserId` binds only if
|
|
* still NULL (never rebinds away from another owner); `pvpTokens` seeds
|
|
* only if still NULL (after that the server value is authoritative and
|
|
* is returned so the caller can reconcile).
|
|
*/
|
|
export async function registerPvpCharacter(
|
|
conn: PvpLedgerConn,
|
|
params: {
|
|
character: RegisteredCharacter;
|
|
linkID: string;
|
|
ownerUserId: string | null;
|
|
seedTokens: number | null;
|
|
}
|
|
): Promise<{
|
|
winCount: number;
|
|
lossCount: number;
|
|
tokenRedemptionCount: number;
|
|
pvpTokens: number;
|
|
status: number;
|
|
}> {
|
|
const { character, linkID, ownerUserId, seedTokens } = params;
|
|
await ensurePvpSchema(conn);
|
|
|
|
const existing = await conn.execute({
|
|
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [linkID]
|
|
});
|
|
|
|
if (existing.rows.length === 0) {
|
|
await conn.execute({
|
|
sql: `INSERT INTO PvP_Characters (
|
|
linkID,
|
|
blessing,
|
|
playerClass,
|
|
name,
|
|
maxHealth,
|
|
maxSanity,
|
|
maxMana,
|
|
baseManaRegen,
|
|
strength,
|
|
intelligence,
|
|
dexterity,
|
|
resistanceTable,
|
|
damageTable,
|
|
attackStrings,
|
|
knownSpells,
|
|
pushToken,
|
|
pushCurrentlyEnabled,
|
|
pvpTokens,
|
|
ownerUserId
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
args: [
|
|
linkID,
|
|
character.blessing ?? null,
|
|
character.playerClass,
|
|
character.name,
|
|
character.maxHealth,
|
|
character.maxSanity,
|
|
character.maxMana,
|
|
character.baseManaRegen,
|
|
character.strength,
|
|
character.intelligence,
|
|
character.dexterity,
|
|
character.resistanceTable,
|
|
character.damageTable,
|
|
character.attackStrings,
|
|
character.knownSpells,
|
|
null,
|
|
null,
|
|
seedTokens ?? 0,
|
|
ownerUserId
|
|
]
|
|
});
|
|
|
|
return {
|
|
winCount: 0,
|
|
lossCount: 0,
|
|
tokenRedemptionCount: 0,
|
|
pvpTokens: seedTokens ?? 0,
|
|
status: 201
|
|
};
|
|
}
|
|
|
|
const row = existing.rows[0];
|
|
await conn.execute({
|
|
sql: `UPDATE PvP_Characters SET
|
|
playerClass = ?,
|
|
blessing = ?,
|
|
name = ?,
|
|
maxHealth = ?,
|
|
maxSanity = ?,
|
|
maxMana = ?,
|
|
baseManaRegen = ?,
|
|
strength = ?,
|
|
intelligence = ?,
|
|
dexterity = ?,
|
|
resistanceTable = ?,
|
|
damageTable = ?,
|
|
attackStrings = ?,
|
|
knownSpells = ?,
|
|
ownerUserId = COALESCE(ownerUserId, ?),
|
|
pvpTokens = COALESCE(pvpTokens, ?)
|
|
WHERE linkID = ?`,
|
|
args: [
|
|
character.playerClass,
|
|
character.blessing ?? null,
|
|
character.name,
|
|
character.maxHealth,
|
|
character.maxSanity,
|
|
character.maxMana,
|
|
character.baseManaRegen,
|
|
character.strength,
|
|
character.intelligence,
|
|
character.dexterity,
|
|
character.resistanceTable,
|
|
character.damageTable,
|
|
character.attackStrings,
|
|
character.knownSpells,
|
|
ownerUserId,
|
|
seedTokens,
|
|
linkID
|
|
]
|
|
});
|
|
|
|
// The post-update balance is authoritative (seeded now if it was NULL).
|
|
const reconciled = await conn.execute({
|
|
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [linkID]
|
|
});
|
|
const pvpTokens = num(reconciled.rows[0]?.pvpTokens);
|
|
|
|
return {
|
|
winCount: num(row.winCount),
|
|
lossCount: num(row.lossCount),
|
|
tokenRedemptionCount: num(row.tokenRedemptionCount),
|
|
pvpTokens,
|
|
status: 200
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Record a battle outcome and credit the winner's Blood Tokens.
|
|
*
|
|
* The caller (verified via `callerUserId`) must own either the winner's or
|
|
* the loser's row — battle results reported by non-participants are
|
|
* rejected. Only a caller who owns the *winner's* row earns tokens; the
|
|
* reward is computed server-side from both registered rows, so the
|
|
* client-supplied linkIDs cannot influence the amount beyond picking the
|
|
* participants.
|
|
*/
|
|
export async function recordBattleResult(
|
|
conn: PvpLedgerConn,
|
|
params: {
|
|
winnerLinkID: string;
|
|
loserLinkID: string;
|
|
callerUserId: string;
|
|
}
|
|
): Promise<
|
|
| { ok: true; status: number; pvpTokens: number; credited: boolean }
|
|
| { ok: false; status: number; reason: "not_participant" }
|
|
> {
|
|
const { winnerLinkID, loserLinkID, callerUserId } = params;
|
|
await ensurePvpSchema(conn);
|
|
|
|
const callerRows = await conn.execute({
|
|
sql: `SELECT linkID FROM PvP_Characters WHERE ownerUserId = ?`,
|
|
args: [callerUserId]
|
|
});
|
|
const callerLinkIDs = new Set(callerRows.rows.map((row) => String(row.linkID)));
|
|
|
|
if (!callerLinkIDs.has(winnerLinkID) && !callerLinkIDs.has(loserLinkID)) {
|
|
return { ok: false, status: 403, reason: "not_participant" };
|
|
}
|
|
|
|
await conn.execute({
|
|
sql: `
|
|
UPDATE PvP_Characters
|
|
SET
|
|
winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END,
|
|
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
|
|
WHERE linkID IN (?, ?)
|
|
`,
|
|
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
|
|
});
|
|
|
|
const callerIsWinner = callerLinkIDs.has(winnerLinkID);
|
|
if (!callerIsWinner) {
|
|
// The loser reports the same outcome for tally purposes; no tokens move.
|
|
return { ok: true, status: 200, pvpTokens: 0, credited: false };
|
|
}
|
|
|
|
const winnerRes = await conn.execute({
|
|
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [winnerLinkID]
|
|
});
|
|
const loserRes = await conn.execute({
|
|
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [loserLinkID]
|
|
});
|
|
if (winnerRes.rows.length === 0 || loserRes.rows.length === 0) {
|
|
// An unregistered combatant cannot be rewarded — but the tallies above
|
|
// still applied to whatever rows exist, matching pre-FRE-142 behaviour.
|
|
return { ok: true, status: 200, pvpTokens: 0, credited: false };
|
|
}
|
|
|
|
const winnerRow = winnerRes.rows[0];
|
|
const loserRow = loserRes.rows[0];
|
|
const reward = computeWinReward(
|
|
{
|
|
playerClass: String(winnerRow.playerClass),
|
|
strength: num(winnerRow.strength),
|
|
intelligence: num(winnerRow.intelligence),
|
|
dexterity: num(winnerRow.dexterity),
|
|
maxHealth: num(winnerRow.maxHealth),
|
|
winCount: num(winnerRow.winCount),
|
|
lossCount: num(winnerRow.lossCount)
|
|
} satisfies PvpCombatantRow,
|
|
{
|
|
playerClass: String(loserRow.playerClass),
|
|
strength: num(loserRow.strength),
|
|
intelligence: num(loserRow.intelligence),
|
|
dexterity: num(loserRow.dexterity),
|
|
maxHealth: num(loserRow.maxHealth),
|
|
winCount: num(loserRow.winCount),
|
|
lossCount: num(loserRow.lossCount)
|
|
} satisfies PvpCombatantRow
|
|
);
|
|
|
|
await conn.execute({
|
|
sql: `UPDATE PvP_Characters
|
|
SET pvpTokens = COALESCE(pvpTokens, 0) + ?
|
|
WHERE linkID = ?`,
|
|
args: [reward, winnerLinkID]
|
|
});
|
|
|
|
const balance = await conn.execute({
|
|
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [winnerLinkID]
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
pvpTokens: num(balance.rows[0]?.pvpTokens),
|
|
credited: true
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Spend Blood Tokens on a reward atomically.
|
|
*
|
|
* The balance check, cooldown check, spend, and cooldown stamp all happen in
|
|
* one conditional UPDATE — under concurrency exactly one caller can commit
|
|
* a spend that satisfies the WHERE clause. `reason` explains a refusal:
|
|
* unknown reward id, no owned row for the linkID, insufficient tokens, or
|
|
* cooldown in effect.
|
|
*/
|
|
export async function purchaseReward(
|
|
conn: PvpLedgerConn,
|
|
params: {
|
|
linkID: string;
|
|
callerUserId: string;
|
|
rewardId: number;
|
|
now?: number;
|
|
}
|
|
): Promise<{
|
|
ok: boolean;
|
|
pvpTokens?: number;
|
|
reason?: "unknown_reward" | "not_found" | "insufficient_tokens" | "throttled";
|
|
}> {
|
|
const { linkID, callerUserId, rewardId } = params;
|
|
await ensurePvpSchema(conn);
|
|
|
|
const cost = PVP_PURCHASE_COSTS[rewardId];
|
|
if (cost === undefined) {
|
|
return { ok: false, reason: "unknown_reward" };
|
|
}
|
|
|
|
const now = params.now ?? Date.now();
|
|
const spend = await conn.execute({
|
|
sql: `UPDATE PvP_Characters
|
|
SET pvpTokens = pvpTokens - ?,
|
|
lastPurchaseAt = ?
|
|
WHERE linkID = ?
|
|
AND ownerUserId = ?
|
|
AND pvpTokens IS NOT NULL
|
|
AND pvpTokens >= ?
|
|
AND (lastPurchaseAt IS NULL OR ? - lastPurchaseAt >= ?)`,
|
|
args: [
|
|
cost,
|
|
now,
|
|
linkID,
|
|
callerUserId,
|
|
cost,
|
|
now,
|
|
PVP_PURCHASE_COOLDOWN_MS
|
|
]
|
|
});
|
|
|
|
if ((spend.rowsAffected ?? 0) === 1) {
|
|
const balance = await conn.execute({
|
|
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [linkID]
|
|
});
|
|
return { ok: true, pvpTokens: num(balance.rows[0]?.pvpTokens) };
|
|
}
|
|
|
|
// The conditional UPDATE refused — determine why, for observability and
|
|
// a precise client message.
|
|
const row = (
|
|
await conn.execute({
|
|
sql: `SELECT ownerUserId, pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = ?`,
|
|
args: [linkID]
|
|
})
|
|
).rows[0];
|
|
|
|
if (!row || row.ownerUserId !== callerUserId) {
|
|
return { ok: false, reason: "not_found" };
|
|
}
|
|
if (row.pvpTokens === null || row.pvpTokens === undefined) {
|
|
return { ok: false, reason: "insufficient_tokens" };
|
|
}
|
|
if (num(row.pvpTokens) < cost) {
|
|
return { ok: false, reason: "insufficient_tokens" };
|
|
}
|
|
return { ok: false, reason: "throttled" };
|
|
}
|