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

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