Files
freno-dev/src/server/pvp-rewards.ts
Michael Freno 7108f77bf9 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).
2026-10-06 10:56:29 -04:00

137 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}