mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 22:05:09 +00:00
fix(shop): rework pricing calculation with budget-capped upgrade costs and per-roll multiplier
This commit is contained in:
parent
e80c0deb79
commit
7db3230af4
2 changed files with 72 additions and 59 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue