forked from Mike/freno-dev
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:
8
src/routes/api/lineage/pvp/purchase.ts
Normal file
8
src/routes/api/lineage/pvp/purchase.ts
Normal 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);
|
||||
370
src/server/api/routers/lineage/pvp-ledger.test.ts
Normal file
370
src/server/api/routers/lineage/pvp-ledger.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
395
src/server/api/routers/lineage/pvp-ledger.ts
Normal file
395
src/server/api/routers/lineage/pvp-ledger.ts
Normal 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" };
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
116
src/server/pvp-rewards.test.ts
Normal file
116
src/server/pvp-rewards.test.ts
Normal 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
136
src/server/pvp-rewards.ts
Normal 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);
|
||||
}
|
||||
121
src/server/pvp-schema.test.ts
Normal file
121
src/server/pvp-schema.test.ts
Normal 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
97
src/server/pvp-schema.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user