pvp: make the blood token ledger server-authoritative

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).
This commit is contained in:
2026-10-06 10:56:29 -04:00
parent 687df016a6
commit 7108f77bf9
8 changed files with 1357 additions and 130 deletions

View File

@@ -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);

View File

@@ -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<string, unknown>[] };
}
const result = stmt.run(...(args ?? []));
return { rows: [], rowsAffected: Number(result.changes) };
}
};
}
function character(
overrides: Partial<RegisteredCharacter> = {}
): 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<void> {
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");
});
});

View File

@@ -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<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" };
}

View File

@@ -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<string | null> {
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"
});
}
})
});

View File

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

136
src/server/pvp-rewards.ts Normal file
View File

@@ -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<number, number> = {
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);
}

View File

@@ -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");
});
});

97
src/server/pvp-schema.ts Normal file
View File

@@ -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<unknown>;
}): Promise<void> {
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<string, unknown>>;
}
).rows.map((row) => {
const r = row as Record<string, unknown>;
return String(r.name);
})
);
for (const column of PVP_CHARACTERS_ADDED_COLUMNS) {
if (!present.has(column.name)) {
await conn.execute({ sql: column.ddl });
}
}
}