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,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);
});
});