fix(shop): rework pricing calculation with budget-capped upgrade costs and per-roll multiplier

This commit is contained in:
End Nightshade 2026-02-22 13:14:12 -07:00 committed by Nathan
parent e80c0deb79
commit 7db3230af4
2 changed files with 72 additions and 59 deletions

View file

@ -8,7 +8,7 @@ import { userBonusesTable } from "../schemas/users";
export const PHI = (1 + Math.sqrt(5)) / 2;
export const MULTIPLIER = 10;
export const SCRAPS_PER_HOUR = PHI * MULTIPLIER;
export const DOLLARS_PER_HOUR = 5;
export const DOLLARS_PER_HOUR = 4;
export const SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR;
export const TIER_MULTIPLIERS: Record<number, number> = {
@ -29,6 +29,8 @@ export interface ShopItemPricing {
export function calculateShopItemPricing(
monetaryValue: number,
stockCount: number,
upgradeBudgetMultiplier?: number,
perRollMultiplier?: number,
): ShopItemPricing {
const price = Math.round(monetaryValue * SCRAPS_PER_DOLLAR);
@ -45,41 +47,56 @@ export function calculateShopItemPricing(
),
);
// Roll cost scales with effective probability (see calculateRollCost),
// so exploitation is naturally prevented at every upgrade level.
const rollCost = Math.max(1, Math.round(price * (baseProbability / 100)));
// Per-roll multiplier applied to base roll cost (admin-configurable per item)
const perRollMult = perRollMultiplier ?? 1;
// Total budget = 3.0x price
// Upgrade budget = 3.0x price - rollCost
const upgradeBudget = Math.max(0, price * 3.0 - rollCost);
// Roll cost at base probability (apply per-roll multiplier)
const rollCost = Math.max(
1,
Math.round(price * (baseProbability / 100) * perRollMult),
);
// Use a start+decay upgrade cost model:
// Use provided upgradeBudgetMultiplier if present; otherwise fall back to UPGRADE_MAX_BUDGET_MULTIPLIER
const maxBudgetMultiplier =
upgradeBudgetMultiplier ?? UPGRADE_MAX_BUDGET_MULTIPLIER;
const maxBudget = Math.max(0, Math.floor(price * maxBudgetMultiplier));
// Number of upgrades needed to go from baseProbability to 100%
const probabilityGap = 100 - baseProbability;
// Boost amount: how much each upgrade increases probability
// Target ~10-20 upgrades for expensive items, fewer for cheap
const targetUpgrades = Math.max(
5,
Math.min(20, Math.ceil(monetaryValue / 5)),
);
const boostAmount = Math.max(1, Math.round(probabilityGap / targetUpgrades));
// Actual number of upgrades needed to reach 100%
const actualUpgrades = Math.ceil(probabilityGap / boostAmount);
const costMultiplier = 110; // 1.10x per upgrade (stored as percentage)
const multiplierDecimal = costMultiplier / 100;
// Base upgrade cost is a fixed fraction of price (start percent)
let baseUpgradeCost = Math.max(1, Math.floor(price * UPGRADE_START_PERCENT));
const costMultiplier = Math.round(UPGRADE_DECAY * 100); // store decay as percentage (e.g. 1.05 -> 105)
// Calculate base cost from budget using geometric series
let baseUpgradeCost: number;
if (actualUpgrades <= 0 || upgradeBudget <= 0) {
baseUpgradeCost = Math.round(price * 0.05) || 1;
} else {
// Sum of geometric series: base * (r^n - 1) / (r - 1)
const seriesSum =
(Math.pow(multiplierDecimal, actualUpgrades) - 1) /
(multiplierDecimal - 1);
baseUpgradeCost = Math.max(1, Math.round(upgradeBudget / seriesSum));
// If cumulative upgrades would exceed maxBudget, scale down baseUpgradeCost until it fits
// Compute cumulative with decay series until actualUpgrades levels or budget exceeded
function computeCumulative(base: number): number {
let cum = 0;
let next = base;
for (let i = 0; i < actualUpgrades; i++) {
cum += Math.floor(next);
next = next / UPGRADE_DECAY;
if (cum > maxBudget) break;
}
return cum;
}
if (maxBudget > 0) {
// If current cumulative exceeds budget, reduce baseUpgradeCost iteratively (small loop)
let cum = computeCumulative(baseUpgradeCost);
let attempts = 0;
while (cum > maxBudget && baseUpgradeCost > 1 && attempts < 200) {
baseUpgradeCost = Math.max(1, baseUpgradeCost - 1);
cum = computeCumulative(baseUpgradeCost);
attempts++;
}
}
return {
@ -96,25 +113,36 @@ export function calculateRollCost(
effectiveProbability: number,
rollCostOverride?: number | null,
baseProbability?: number,
perRollMultiplier?: number,
): number {
if (rollCostOverride != null && rollCostOverride > 0) {
return rollCostOverride;
}
// Roll cost = baseProbability% of item price. Does not scale with upgrades.
// Escalation (applied at call site) makes repeated rolls more expensive.
const baseProb = baseProbability ?? effectiveProbability;
return Math.max(1, Math.round(basePrice * (baseProb / 100)));
// Use provided baseProbability if caller wants the roll cost fixed to base;
// otherwise use effectiveProbability. Also apply an optional per-roll multiplier.
const probToUse = baseProbability ?? effectiveProbability;
const multiplier = perRollMultiplier ?? 1;
return Math.max(1, Math.round(basePrice * (probToUse / 100) * multiplier));
}
const UPGRADE_START_PERCENT = 0.25;
const UPGRADE_DECAY = 1.05;
const UPGRADE_MAX_BUDGET_MULTIPLIER = 3;
export function getUpgradeCost(price: number, upgradeCount: number, actualSpent?: number): number | null {
export function getUpgradeCost(
price: number,
upgradeCount: number,
actualSpent?: number,
): number | null {
const maxBudget = price * UPGRADE_MAX_BUDGET_MULTIPLIER;
const cumulative = actualSpent ?? 0;
if (cumulative >= maxBudget) return null;
const nextCost = Math.max(1, Math.floor(price * UPGRADE_START_PERCENT / Math.pow(UPGRADE_DECAY, upgradeCount)));
const nextCost = Math.max(
1,
Math.floor(
(price * UPGRADE_START_PERCENT) / Math.pow(UPGRADE_DECAY, upgradeCount),
),
);
if (cumulative + nextCost > maxBudget) {
const remaining = Math.floor(maxBudget - cumulative);
return remaining > 0 ? remaining : null;

View file

@ -42,48 +42,33 @@ export function computeItemPricing(
/** scraps per dollar used */
scrapsPerDollar: number;
} {
const price = Math.max(1, Math.round(dollarCost * SCRAPS_PER_DOLLAR));
// Delegate core pricing calculation to the canonical helper so admin previews
// and bulk recalculations stay consistent with live logic.
const pricing = calculateShopItemPricing(dollarCost, stockCount);
let prob: number;
if (
const price = pricing.price;
// If caller provided an explicit baseProbability, prefer it for displayed values,
// otherwise use the computed baseProbability from the canonical helper.
const prob =
baseProbability !== undefined &&
baseProbability >= 1 &&
baseProbability <= 100
) {
prob = Math.round(baseProbability);
} else {
// Auto: rarer for expensive items, more common for cheap/plentiful ones
const priceRarityFactor = Math.max(0, 1 - dollarCost / 100);
const stockRarityFactor = Math.min(1, stockCount / 20);
prob = Math.max(
1,
Math.min(
80,
Math.round((priceRarityFactor * 0.4 + stockRarityFactor * 0.6) * 80),
),
);
}
? Math.round(baseProbability)
: pricing.baseProbability;
const rollCost = calculateRollCost(price, prob, undefined, prob);
const threshold = computeRollThreshold(prob);
const expectedRollsAtBase = threshold > 0 ? Math.round((100 / threshold) * 10) / 10 : Infinity;
const expectedRollsAtBase =
threshold > 0 ? Math.round((100 / threshold) * 10) / 10 : Infinity;
const expectedSpendAtBase = Math.round(rollCost * expectedRollsAtBase);
const probabilityGap = 100 - prob;
const targetUpgrades = Math.max(5, Math.min(20, Math.ceil(dollarCost / 5)));
const boostAmount = Math.max(1, Math.round(probabilityGap / targetUpgrades));
// Upgrades start at 25% of item price and decay by 1.05x per level
const baseUpgradeCost = Math.max(1, Math.floor(price * 0.25));
const costMultiplier = 105; // stored as percentage, used as decay divisor
return {
price,
baseProbability: prob,
baseUpgradeCost,
costMultiplier,
boostAmount,
baseUpgradeCost: pricing.baseUpgradeCost,
costMultiplier: pricing.costMultiplier,
boostAmount: pricing.boostAmount,
rollCost,
expectedRollsAtBase,
expectedSpendAtBase,