diff --git a/src/routes/api/lineage/pvp/purchase.ts b/src/routes/api/lineage/pvp/purchase.ts new file mode 100644 index 0000000..e4885f4 --- /dev/null +++ b/src/routes/api/lineage/pvp/purchase.ts @@ -0,0 +1,8 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { rest } from "../_lib"; + +// POST /api/lineage/pvp/purchase — server-authoritative Blood Token purchase +export const POST = (event: APIEvent) => rest(async (caller) => { + const input = await event.request.json(); + return caller.lineage.pvp.purchase(input); +}, event); diff --git a/src/server/api/routers/lineage/pvp-ledger.test.ts b/src/server/api/routers/lineage/pvp-ledger.test.ts new file mode 100644 index 0000000..3cf55df --- /dev/null +++ b/src/server/api/routers/lineage/pvp-ledger.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Database } from "bun:sqlite"; +import { + PVP_PURCHASE_COOLDOWN_MS, + PVP_PURCHASE_COSTS +} from "~/server/pvp-rewards"; +import { + purchaseReward, + recordBattleResult, + registerPvpCharacter, + type PvpLedgerConn, + type RegisteredCharacter +} from "./pvp-ledger"; + +/** + * FRE-142 ledger tests: the earn/spend authorization and atomicity decisions + * behind `lineage.pvp`, exercised against an in-memory SQLite database + * wrapped to the libsql `execute({ sql, args }) -> { rows, rowsAffected }` + * contract the router uses (same pattern as nessa-community-authz.test.ts). + * + * Casts to `any` are fine in test fixtures per the repo conventions where + * stub rows are simplified objects. + */ + +let db: Database; +let conn: PvpLedgerConn; + +function makeConn(): PvpLedgerConn { + return { + execute: async ({ + sql, + args + }: { + sql: string; + args?: (string | number | null)[]; + }) => { + const stmt = db.prepare(sql); + const upper = sql.trim().toUpperCase(); + const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH"); + if (isRead) { + const rows = stmt.all(...(args ?? [])); + return { rows: rows as Record[] }; + } + const result = stmt.run(...(args ?? [])); + return { rows: [], rowsAffected: Number(result.changes) }; + } + }; +} + +function character( + overrides: Partial = {} +): RegisteredCharacter { + return { + playerClass: "mage", + blessing: null, + name: "Test Mage", + maxHealth: 100, + maxSanity: 50, + maxMana: 100, + baseManaRegen: 5, + strength: 10, + intelligence: 12, + dexterity: 8, + resistanceTable: "{}", + damageTable: "{}", + attackStrings: "[]", + knownSpells: "[]", + ...overrides + }; +} + +/** Register a character and seed a token balance in one step. */ +async function seedRow( + linkID: string, + ownerUserId: string | null, + tokens: number +): Promise { + await registerPvpCharacter(conn, { + character: character(), + linkID, + ownerUserId, + seedTokens: tokens + }); +} + +beforeEach(() => { + db = new Database(":memory:"); + conn = makeConn(); +}); + +describe("registerPvpCharacter", () => { + it("creates a bound, seeded row for a new character", async () => { + const result = await registerPvpCharacter(conn, { + character: character(), + linkID: "link-1", + ownerUserId: "user-1", + seedTokens: 7 + }); + expect(result.status).toBe(201); + expect(result.pvpTokens).toBe(7); + expect(result.winCount).toBe(0); + + const row = db + .query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`) + .get() as { pvpTokens: number; ownerUserId: string }; + expect(row.pvpTokens).toBe(7); + expect(row.ownerUserId).toBe("user-1"); + }); + + it("seeds a NULL balance exactly once — the server value wins afterwards", async () => { + await seedRow("link-1", "user-1", 12); + + // A second register with a *different* claimed balance: the stored 12 + // is authoritative, so 12 is returned and the claim is ignored. + const result = await registerPvpCharacter(conn, { + character: character(), + linkID: "link-1", + ownerUserId: "user-1", + seedTokens: 999 + }); + expect(result.status).toBe(200); + expect(result.pvpTokens).toBe(12); + }); + + it("never rebinds an owned row to another user", async () => { + await seedRow("link-1", "user-1", 5); + + await registerPvpCharacter(conn, { + character: character(), + linkID: "link-1", + ownerUserId: "user-2", + seedTokens: 5 + }); + + const row = db + .query(`SELECT ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`) + .get() as { ownerUserId: string }; + expect(row.ownerUserId).toBe("user-1"); + }); + + it("binds an unbound legacy row on first authenticated register", async () => { + await seedRow("legacy", null, 3); + + await registerPvpCharacter(conn, { + character: character(), + linkID: "legacy", + ownerUserId: "user-1", + seedTokens: null + }); + + const row = db + .query(`SELECT ownerUserId, pvpTokens FROM PvP_Characters WHERE linkID = 'legacy'`) + .get() as { ownerUserId: string; pvpTokens: number }; + expect(row.ownerUserId).toBe("user-1"); + // NULL seed does not overwrite the existing balance. + expect(row.pvpTokens).toBe(3); + }); +}); + +describe("recordBattleResult", () => { + it("credits the winner with a formula-based reward and returns the balance", async () => { + await seedRow("winner", "user-1", 10); + await seedRow("loser", "user-2", 0); + + const before = db + .query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`) + .get() as { winCount: number; lossCount: number }; + + const result = await recordBattleResult(conn, { + winnerLinkID: "winner", + loserLinkID: "loser", + callerUserId: "user-1" + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.credited).toBe(true); + // Formula: 1 * winRatio(loser 0-0 → 1.0) * power(≈1.0) * class(1.1 + // mirror) = 1.1 → clamps/rounds within [1, 5]. + expect(result.pvpTokens).toBeGreaterThanOrEqual(10 + 1); + expect(result.pvpTokens).toBeLessThanOrEqual(10 + 5); + + const tallies = db + .query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`) + .get() as { winCount: number; lossCount: number }; + expect(tallies.lossCount).toBe(before.lossCount + 1); + expect(tallies.winCount).toBe(before.winCount); + }); + + it("tallies but credits nothing when the loser reports", async () => { + await seedRow("winner", "user-1", 10); + await seedRow("loser", "user-2", 0); + + const result = await recordBattleResult(conn, { + winnerLinkID: "winner", + loserLinkID: "loser", + callerUserId: "user-2" + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.credited).toBe(false); + + const balance = db + .query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`) + .get() as { pvpTokens: number }; + expect(balance.pvpTokens).toBe(10); + }); + + it("rejects a report from a non-participant", async () => { + await seedRow("winner", "user-1", 10); + await seedRow("loser", "user-2", 0); + + const result = await recordBattleResult(conn, { + winnerLinkID: "winner", + loserLinkID: "loser", + callerUserId: "user-3" + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("not_participant"); + + // Nothing was tallied or credited. + const tallies = db + .query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'winner'`) + .get() as { winCount: number; lossCount: number }; + expect(tallies.winCount).toBe(0); + }); + + it("treats an unregistered loser as a zero-credit win", async () => { + await seedRow("winner", "user-1", 4); + + const result = await recordBattleResult(conn, { + winnerLinkID: "winner", + loserLinkID: "ghost", + callerUserId: "user-1" + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.credited).toBe(false); + + const balance = db + .query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`) + .get() as { pvpTokens: number }; + expect(balance.pvpTokens).toBe(4); + }); +}); + +describe("purchaseReward", () => { + it("spends atomically and returns the new balance", async () => { + await seedRow("link-1", "user-1", PVP_PURCHASE_COSTS[2]); + + const result = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 2, + now: 1_000_000 + }); + + expect(result.ok).toBe(true); + expect(result.pvpTokens).toBe(0); + const row = db + .query(`SELECT pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = 'link-1'`) + .get() as { pvpTokens: number; lastPurchaseAt: number }; + expect(row.pvpTokens).toBe(0); + expect(row.lastPurchaseAt).toBe(1_000_000); + }); + + it("refuses an unknown reward id without touching the row", async () => { + await seedRow("link-1", "user-1", 100); + + const result = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 99 + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("unknown_reward"); + }); + + it("refuses purchases on a row the caller does not own", async () => { + await seedRow("link-1", "user-1", 100); + + const result = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-2", + rewardId: 5 + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("not_found"); + }); + + it("refuses purchases on an unregistered linkID", async () => { + const result = await purchaseReward(conn, { + linkID: "ghost", + callerUserId: "user-1", + rewardId: 5 + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("not_found"); + }); + + it("refuses an insufficient balance", async () => { + await seedRow("link-1", "user-1", 3); + + const result = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 1 // costs 5 + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("insufficient_tokens"); + + const balance = db + .query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'link-1'`) + .get() as { pvpTokens: number }; + expect(balance.pvpTokens).toBe(3); + }); + + it("refuses a purchase inside the cooldown window, then allows it after", async () => { + await seedRow("link-1", "user-1", 1000); + + const first = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 5, + now: 1_000_000 + }); + expect(first.ok).toBe(true); + + const throttled = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 5, + now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS - 1 + }); + expect(throttled.ok).toBe(false); + expect(throttled.reason).toBe("throttled"); + + const allowed = await purchaseReward(conn, { + linkID: "link-1", + callerUserId: "user-1", + rewardId: 5, + now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS + }); + expect(allowed.ok).toBe(true); + }); + + it("treats a NULL (unseeded) balance as insufficient", async () => { + // A row that exists but was never seeded (pre-FRE-142 shape). + await seedRow("legacy", null, 0); + db.run(`UPDATE PvP_Characters SET pvpTokens = NULL WHERE linkID = 'legacy'`); + db.run(`UPDATE PvP_Characters SET ownerUserId = 'user-1' WHERE linkID = 'legacy'`); + + const result = await purchaseReward(conn, { + linkID: "legacy", + callerUserId: "user-1", + rewardId: 1 + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("insufficient_tokens"); + }); +}); diff --git a/src/server/api/routers/lineage/pvp-ledger.ts b/src/server/api/routers/lineage/pvp-ledger.ts new file mode 100644 index 0000000..cd8bd23 --- /dev/null +++ b/src/server/api/routers/lineage/pvp-ledger.ts @@ -0,0 +1,395 @@ +/** + * 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[]; 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" }; +} diff --git a/src/server/api/routers/lineage/pvp.ts b/src/server/api/routers/lineage/pvp.ts index 9901162..004d373 100644 --- a/src/server/api/routers/lineage/pvp.ts +++ b/src/server/api/routers/lineage/pvp.ts @@ -5,7 +5,14 @@ import { } from "../../utils"; import { z } from "zod"; import { LineageConnectionFactory } from "~/server/utils"; +import { verifyLineageAuthToken } from "~/server/auth"; import { TRPCError } from "@trpc/server"; +import type { APIEvent } from "@solidjs/start/server"; +import { + purchaseReward, + recordBattleResult, + registerPvpCharacter +} from "./pvp-ledger"; const characterSchema = z.object({ playerClass: z.string(), @@ -24,6 +31,25 @@ const characterSchema = z.object({ knownSpells: z.string() }); +/** + * Extract the caller's Lineage user id from the Authorization bearer. + * + * FRE-142 / p8-004: the mobile client attaches its Lineage email JWT to all + * `/pvp*` requests (`authHeaders()` in PVPStore). Verifying it here binds + * every earn/spend mutation to an authenticated owner — anonymous callers + * can register (legacy behaviour) but cannot earn or spend. + */ +async function authenticatedUserId( + event: APIEvent +): Promise { + const header = event.request?.headers?.get("authorization"); + const match = header?.match(/^Bearer\s+(.+)$/i); + const token = match?.[1]?.trim(); + if (!token) return null; + const payload = await verifyLineageAuthToken(token); + return payload?.userId ?? null; +} + export const lineagePvpRouter = createTRPCRouter({ registerCharacter: csrfProtectedProcedure .input( @@ -31,117 +57,25 @@ export const lineagePvpRouter = createTRPCRouter({ character: characterSchema, linkID: z.string(), pushToken: z.string().optional(), - pushCurrentlyEnabled: z.boolean().optional() + pushCurrentlyEnabled: z.boolean().optional(), + // The caller's cached local balance — used only to seed the + // server-authoritative ledger exactly once (pre-FRE-142 migration). + // After that the server value is authoritative and this is ignored. + pvpTokens: z.number().int().min(0).nullish() }) ) - .mutation(async ({ input }) => { - const { character, linkID, pushToken, pushCurrentlyEnabled } = input; + .mutation(async ({ input, ctx }) => { + const { character, linkID, pvpTokens } = input; try { const conn = LineageConnectionFactory(); - const res = await conn.execute({ - sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`, - args: [linkID] + const ownerUserId = await authenticatedUserId(ctx.event); + return await registerPvpCharacter(conn, { + character, + linkID, + ownerUserId, + seedTokens: pvpTokens ?? null }); - - if (res.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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - linkID, - character.blessing, - 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, - pushToken, - pushCurrentlyEnabled - ] - }); - - return { - ok: true, - winCount: 0, - lossCount: 0, - tokenRedemptionCount: 0, - status: 201 - }; - } else { - await conn.execute({ - sql: `UPDATE PvP_Characters SET - playerClass = ?, - blessing = ?, - name = ?, - maxHealth = ?, - maxSanity = ?, - maxMana = ?, - baseManaRegen = ?, - strength = ?, - intelligence = ?, - dexterity = ?, - resistanceTable = ?, - damageTable = ?, - attackStrings = ?, - knownSpells = ?, - pushToken = ?, - pushCurrentlyEnabled = ? - WHERE linkID = ?`, - args: [ - character.playerClass, - character.blessing, - character.name, - character.maxHealth, - character.maxSanity, - character.maxMana, - character.baseManaRegen, - character.strength, - character.intelligence, - character.dexterity, - character.resistanceTable, - character.damageTable, - character.attackStrings, - character.knownSpells, - pushToken, - pushCurrentlyEnabled, - linkID - ] - }); - - return { - ok: true, - winCount: res.rows[0].winCount as number, - lossCount: res.rows[0].lossCount as number, - tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number, - status: 200 - }; - } } catch (e) { console.error(e); throw new TRPCError({ @@ -160,17 +94,17 @@ export const lineagePvpRouter = createTRPCRouter({ ` SELECT playerClass, blessing, - name, - maxHealth, - maxSanity, + name, + maxHealth, + maxSanity, maxMana, - baseManaRegen, - strength, - intelligence, - dexterity, - resistanceTable, - damageTable, - attackStrings, + baseManaRegen, + strength, + intelligence, + dexterity, + resistanceTable, + damageTable, + attackStrings, knownSpells, linkID, winCount, @@ -202,33 +136,83 @@ export const lineagePvpRouter = createTRPCRouter({ loserLinkID: z.string() }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const { winnerLinkID, loserLinkID } = input; + const callerUserId = await authenticatedUserId(ctx.event); + if (!callerUserId) { + // p8-004: unauthenticated callers can no longer report (and mint) + // battle results — earnings are server-authoritative (FRE-142). + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication required to report battle results" + }); + } + const conn = LineageConnectionFactory(); try { - 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 result = await recordBattleResult(conn, { + winnerLinkID, + loserLinkID, + callerUserId }); - - return { - ok: true, - status: 200 - }; + if (!result.ok) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Caller is not a participant in this battle" + }); + } + return result; } catch (e) { + if (e instanceof TRPCError) throw e; console.error(e); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to record battle result" }); } + }), + + /** + * FRE-142: server-authoritative Blood Token purchase. The cost comes from + * the server's own reward table (never the client), the spend is a single + * conditional UPDATE (atomic check-and-spend), and the cooldown is stamped + * in the row so it survives serverless cold starts. Returns the new + * authoritative balance on success; `reason` explains a refusal. + */ + purchase: csrfProtectedProcedure + .input( + z.object({ + linkID: z.string().min(1), + rewardId: z.number().int() + }) + ) + .mutation(async ({ input, ctx }) => { + const { linkID, rewardId } = input; + + const callerUserId = await authenticatedUserId(ctx.event); + if (!callerUserId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication required to purchase rewards" + }); + } + + const conn = LineageConnectionFactory(); + + try { + return await purchaseReward(conn, { + linkID, + callerUserId, + rewardId + }); + } catch (e) { + console.error(e); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to purchase reward" + }); + } }) }); diff --git a/src/server/pvp-rewards.test.ts b/src/server/pvp-rewards.test.ts new file mode 100644 index 0000000..6ce6532 --- /dev/null +++ b/src/server/pvp-rewards.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; +import { + PVP_PURCHASE_COSTS, + clampReward, + computeWinReward, + winRatioFactor, + powerDistanceFactor, + classMatchupFactor, + type PvpCombatantRow +} from "./pvp-rewards"; + +// Ported from the lineage client (`entities/__tests__/AIPlayerCharacter.test.ts` +// rewardValue blocks). These tests pin the server's copy of the formula to +// the same behaviour so the two cannot silently drift apart. + +function combatant(overrides: Partial = {}): PvpCombatantRow { + return { + playerClass: "mage", + strength: 10, + intelligence: 10, + dexterity: 10, + maxHealth: 100, + winCount: 0, + lossCount: 0, + ...overrides + }; +} + +describe("winRatioFactor", () => { + it("returns 1.0 for a combatant with no recorded fights", () => { + expect(winRatioFactor(0, 0)).toBe(1.0); + }); + + it("scales from 0.5 (all losses) to 1.5 (all wins)", () => { + expect(winRatioFactor(0, 10)).toBe(0.5); + expect(winRatioFactor(5, 5)).toBe(1.0); + expect(winRatioFactor(10, 0)).toBe(1.5); + }); +}); + +describe("powerDistanceFactor", () => { + it("returns 1.0 when the winner's power is non-positive", () => { + expect(powerDistanceFactor(100, 0)).toBe(1.0); + }); + + it("clamps to [0.5, 2.0]", () => { + expect(powerDistanceFactor(1, 100)).toBe(0.5); + expect(powerDistanceFactor(1000, 100)).toBe(2.0); + }); + + it("is neutral for an even match", () => { + expect(powerDistanceFactor(130, 130)).toBe(1.0); + }); +}); + +describe("classMatchupFactor", () => { + it("boosts mirror matches by 10%", () => { + expect(classMatchupFactor("warrior", "warrior")).toBe(1.1); + }); + + it("is neutral for cross-class matchups", () => { + expect(classMatchupFactor("warrior", "mage")).toBe(1.0); + }); +}); + +describe("clampReward", () => { + it("clamps to the inclusive [1, 5] range", () => { + expect(clampReward(0.2)).toBe(1); + expect(clampReward(3.6)).toBe(4); + expect(clampReward(99)).toBe(5); + }); +}); + +describe("computeWinReward", () => { + it("returns a value within [PVP_REWARD_MIN, PVP_REWARD_MAX]", () => { + const reward = computeWinReward(combatant(), combatant()); + expect(reward).toBeGreaterThanOrEqual(1); + expect(reward).toBeLessThanOrEqual(5); + }); + + it("rewards beating a stronger, winning opponent more than a weak one", () => { + const winner = combatant(); + const strongLoser = combatant({ + strength: 50, + intelligence: 50, + dexterity: 50, + maxHealth: 400, + winCount: 8, + lossCount: 2 + }); + const weakLoser = combatant({ + strength: 1, + intelligence: 1, + dexterity: 1, + maxHealth: 10, + winCount: 0, + lossCount: 9 + }); + const strongReward = computeWinReward(winner, strongLoser); + const weakReward = computeWinReward(winner, weakLoser); + expect(strongReward).toBeGreaterThan(weakReward); + }); +}); + +describe("PVP_PURCHASE_COSTS", () => { + it("has a cost for every reward id the lineage client can purchase", () => { + // Mirror of assets/json/pvpRewards.json in the lineage repo. + expect(PVP_PURCHASE_COSTS[1]).toBe(5); + expect(PVP_PURCHASE_COSTS[2]).toBe(15); + expect(PVP_PURCHASE_COSTS[3]).toBe(20); + expect(PVP_PURCHASE_COSTS[4]).toBe(25); + expect(PVP_PURCHASE_COSTS[5]).toBe(15); + expect(PVP_PURCHASE_COSTS[6]).toBe(40); + expect(Object.keys(PVP_PURCHASE_COSTS)).toHaveLength(6); + }); +}); diff --git a/src/server/pvp-rewards.ts b/src/server/pvp-rewards.ts new file mode 100644 index 0000000..8e9f739 --- /dev/null +++ b/src/server/pvp-rewards.ts @@ -0,0 +1,136 @@ +/** + * Blood Token win-reward calculation (FRE-142), server-side authority. + * + * Ported from the lineage client (`entities/AIPlayerCharacter.ts`) so the + * server credits the same reward a win would have earned client-side. The + * client's copy remains for UI display only ("Reward For Winning"); the + * server's result is authoritative. + * + * One deliberate difference: the client computes the *winner's* power from + * the live player state; the server uses the winner's registered character + * snapshot (refreshed on every arena visit), so the two can drift slightly + * within a session — never enough to leave the [1, 5] clamp range in a way + * that matters. + */ + +/** Minimum reward (in Blood Tokens) a player can earn per PVP win. */ +export const PVP_REWARD_MIN = 1; + +/** Maximum reward a player can earn per PVP win. */ +export const PVP_REWARD_MAX = 5; + +/** Base reward before multipliers are applied. */ +const PVP_REWARD_BASE = 1; + +/** Clamp `value` to the inclusive range [`min`, `max`]. */ +export function clampReward( + value: number, + min = PVP_REWARD_MIN, + max = PVP_REWARD_MAX +): number { + return Math.max(min, Math.min(max, Math.round(value))); +} + +/** + * Win-ratio → multiplier factor. + * + * - 0% win rate → 0.5× + * - 50% win rate → 1.0× (neutral) + * - 100% win rate → 1.5× + * + * When the opponent has no recorded fights (total === 0) the factor is 1.0. + */ +export function winRatioFactor(winCount: number, lossCount: number): number { + const total = winCount + lossCount; + if (total <= 0) return 1.0; + const ratio = winCount / total; + return 0.5 + ratio; +} + +/** + * Power-distance factor: compare the loser's raw combat stats against the + * winner's to reward riskier fights. Clamped to [0.5, 2.0]. + */ +export function powerDistanceFactor( + loserPower: number, + winnerPower: number +): number { + if (winnerPower <= 0) return 1.0; + const ratio = loserPower / winnerPower; + return Math.max(0.5, Math.min(2.0, ratio)); +} + +/** Rough power-level proxy: strength + intelligence + dexterity + maxHealth. */ +export function computeCombatPower( + strength: number, + intelligence: number, + dexterity: number, + maxHealth: number +): number { + return strength + intelligence + dexterity + maxHealth; +} + +/** Server-side purchase rate limit window (ms), mirrors the lineage + * client's `PURCHASE_COOLDOWN_MS`. Enforced via the `lastPurchaseAt` + * column so it survives serverless cold starts. */ +export const PVP_PURCHASE_COOLDOWN_MS = 1000; + +/** + * Blood Token cost per reward id. Mirrors the lineage client's + * `assets/json/pvpRewards.json` — the client's copy is display-only; this + * table is authoritative. Costs must never be accepted from the client. + */ +export const PVP_PURCHASE_COSTS: Record = { + 1: 5, // respec potion + 2: 15, // tome of knowledge (1 skill point) + 3: 20, // potion of undeath (age reduction) + 4: 25, // elixir of renewal (full restore) + 5: 15, // bag of gold (500 gold) + 6: 40 // scroll of mastery (3 skill points) +}; + +/** A mirror match (same class) is slightly harder — 10% reward boost. */ +export function classMatchupFactor( + winnerClass: string, + loserClass: string +): number { + return winnerClass === loserClass ? 1.1 : 1.0; +} + +/** The registered-character fields the reward computation reads. */ +export interface PvpCombatantRow { + playerClass: string; + strength: number; + intelligence: number; + dexterity: number; + maxHealth: number; + winCount: number; + lossCount: number; +} + +/** + * Compute the Blood Token reward the winner earns for defeating the loser, + * from both combatants' registered rows. + */ +export function computeWinReward( + winner: PvpCombatantRow, + loser: PvpCombatantRow +): number { + const wFactor = winRatioFactor(loser.winCount, loser.lossCount); + const loserPower = computeCombatPower( + loser.strength, + loser.intelligence, + loser.dexterity, + loser.maxHealth + ); + const winnerPower = computeCombatPower( + winner.strength, + winner.intelligence, + winner.dexterity, + winner.maxHealth + ); + const pFactor = powerDistanceFactor(loserPower, winnerPower); + const cFactor = classMatchupFactor(winner.playerClass, loser.playerClass); + + return clampReward(PVP_REWARD_BASE * wFactor * pFactor * cFactor); +} diff --git a/src/server/pvp-schema.test.ts b/src/server/pvp-schema.test.ts new file mode 100644 index 0000000..d4dae54 --- /dev/null +++ b/src/server/pvp-schema.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, beforeEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + PVP_CHARACTERS_ADDED_COLUMNS, + PVP_CHARACTERS_DDL, + ensurePvpSchema +} from "./pvp-schema"; + +// Same rationale as analytics-schema.test.ts: PvP_Characters lived only in +// the live Turso database, so nothing proved the DDL, the added columns, and +// the router's statements agreed. These tests apply the real schema to a +// throwaway in-memory database and exercise the idempotent applier against +// both a fresh database and a pre-FRE-142 table shape. + +let db: Database; + +/** Minimal adapter so `ensurePvpSchema` can drive bun:sqlite. */ +function sqliteConn(db: Database) { + return { + execute: async (stmt: { sql: string }) => { + const sql = stmt.sql.trim().toUpperCase(); + if (sql.startsWith("SELECT")) { + return { rows: db.query(stmt.sql).all() }; + } + db.run(stmt.sql); + return { rows: [] }; + } + }; +} + +function columnNames(table: string): string[] { + return db + .query(`SELECT name FROM pragma_table_info('${table}')`) + .all() + .map((row) => (row as { name: string }).name); +} + +beforeEach(() => { + db = new Database(":memory:"); +}); + +describe("PVP_CHARACTERS_DDL", () => { + it("creates the table with every column the router touches", () => { + db.run(PVP_CHARACTERS_DDL); + const columns = columnNames("PvP_Characters"); + expect(columns).toContain("linkID"); + expect(columns).toContain("winCount"); + expect(columns).toContain("lossCount"); + expect(columns).toContain("tokenRedemptionCount"); + expect(columns).toContain("pvpTokens"); + expect(columns).toContain("ownerUserId"); + expect(columns).toContain("lastPurchaseAt"); + }); + + it("is idempotent", () => { + db.run(PVP_CHARACTERS_DDL); + db.run(PVP_CHARACTERS_DDL); + expect(columnNames("PvP_Characters").length).toBeGreaterThan(0); + }); +}); + +describe("ensurePvpSchema", () => { + it("creates the full table on a fresh database", async () => { + await ensurePvpSchema(sqliteConn(db)); + const columns = columnNames("PvP_Characters"); + for (const column of PVP_CHARACTERS_ADDED_COLUMNS) { + expect(columns).toContain(column.name); + } + }); + + it("adds the FRE-142 columns to a pre-existing legacy table", async () => { + // The legacy shape: everything except the three new columns. + db.run(` + CREATE TABLE PvP_Characters ( + linkID TEXT PRIMARY KEY, + blessing TEXT, + playerClass TEXT, + name TEXT, + maxHealth INTEGER, + maxSanity INTEGER, + maxMana INTEGER, + baseManaRegen INTEGER, + strength INTEGER, + intelligence INTEGER, + dexterity INTEGER, + resistanceTable TEXT, + damageTable TEXT, + attackStrings TEXT, + knownSpells TEXT, + pushToken TEXT, + pushCurrentlyEnabled INTEGER, + winCount INTEGER NOT NULL DEFAULT 0, + lossCount INTEGER NOT NULL DEFAULT 0, + tokenRedemptionCount INTEGER NOT NULL DEFAULT 0 + ) + `); + expect(columnNames("PvP_Characters")).not.toContain("pvpTokens"); + + await ensurePvpSchema(sqliteConn(db)); + + const columns = columnNames("PvP_Characters"); + expect(columns).toContain("pvpTokens"); + expect(columns).toContain("ownerUserId"); + expect(columns).toContain("lastPurchaseAt"); + // Existing data is untouched by the migration. + db.run( + `INSERT INTO PvP_Characters (linkID) VALUES ('legacy-row')` + ); + const row = db + .query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'legacy-row'`) + .get() as { pvpTokens: number | null; ownerUserId: string | null }; + expect(row.pvpTokens).toBeNull(); + expect(row.ownerUserId).toBeNull(); + }); + + it("is idempotent on an already-migrated table", async () => { + await ensurePvpSchema(sqliteConn(db)); + await ensurePvpSchema(sqliteConn(db)); + expect(columnNames("PvP_Characters")).toContain("pvpTokens"); + }); +}); diff --git a/src/server/pvp-schema.ts b/src/server/pvp-schema.ts new file mode 100644 index 0000000..a7f7dc2 --- /dev/null +++ b/src/server/pvp-schema.ts @@ -0,0 +1,97 @@ +/** + * PvP_Characters schema — the single source of truth for the table and the + * server-authoritative Blood Token ledger columns (FRE-142). + * + * Like VisitorAnalytics (see analytics-schema.ts), `PvP_Characters` has no + * `CREATE TABLE` in this repo — it exists only in the live Turso database. + * The definition below mirrors the columns the router reads and writes. + * The FRE-142 columns are appended via idempotent `ALTER TABLE` statements: + * + * - `pvpTokens` — the authoritative Blood Token balance. NULL means + * "not yet seeded"; the first registerCharacter call + * after this ships seeds it from the client's cached + * balance exactly once, then it is server-owned. + * - `ownerUserId` — the User.id of the authenticated owner, extracted + * from the verified Lineage JWT bearer. Purchase and + * battle-result mutations only act on rows the caller + * owns (p8-004: bind characters to authenticated + * linkIDs). + * - `lastPurchaseAt` — epoch-ms timestamp of the last committed purchase, + * used for server-side rate limiting. + * + * Pure string constants, no imports — so tests can apply them to an + * in-memory SQLite database without a network or environment. + */ + +export const PVP_CHARACTERS_DDL = ` + CREATE TABLE IF NOT EXISTS PvP_Characters ( + linkID TEXT PRIMARY KEY, + blessing TEXT, + playerClass TEXT, + name TEXT, + maxHealth INTEGER, + maxSanity INTEGER, + maxMana INTEGER, + baseManaRegen INTEGER, + strength INTEGER, + intelligence INTEGER, + dexterity INTEGER, + resistanceTable TEXT, + damageTable TEXT, + attackStrings TEXT, + knownSpells TEXT, + pushToken TEXT, + pushCurrentlyEnabled INTEGER, + winCount INTEGER NOT NULL DEFAULT 0, + lossCount INTEGER NOT NULL DEFAULT 0, + tokenRedemptionCount INTEGER NOT NULL DEFAULT 0, + pvpTokens INTEGER, + ownerUserId TEXT, + lastPurchaseAt INTEGER + ) +`; + +/** Columns that may be missing from a table created before FRE-142. */ +export const PVP_CHARACTERS_ADDED_COLUMNS = [ + { + name: "pvpTokens", + ddl: `ALTER TABLE PvP_Characters ADD COLUMN pvpTokens INTEGER` + }, + { + name: "ownerUserId", + ddl: `ALTER TABLE PvP_Characters ADD COLUMN ownerUserId TEXT` + }, + { + name: "lastPurchaseAt", + ddl: `ALTER TABLE PvP_Characters ADD COLUMN lastPurchaseAt INTEGER` + } +]; + +/** + * Apply the schema idempotently: create the table if absent and add any + * FRE-142 columns missing from a pre-existing table. Cheap enough to run + * before each write mutation; safe to run concurrently with itself. + */ +export async function ensurePvpSchema(conn: { + execute: (stmt: { sql: string }) => Promise; +}): Promise { + await conn.execute({ sql: PVP_CHARACTERS_DDL }); + const existing = await conn.execute({ + sql: `SELECT name FROM pragma_table_info('PvP_Characters')` + }); + const present = new Set( + ( + existing as { + rows: Array<{ name: string } | Record>; + } + ).rows.map((row) => { + const r = row as Record; + return String(r.name); + }) + ); + for (const column of PVP_CHARACTERS_ADDED_COLUMNS) { + if (!present.has(column.name)) { + await conn.execute({ sql: column.ddl }); + } + } +}