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).
117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|