From 63e56dc2299ce770f9e0ae14b8b9aa4725dd7884 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 26 Apr 2026 21:51:03 -0400 Subject: [PATCH] Fix consumer subscription pricing exploit with perceived-value-based elasticity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Players could set astronomical prices and still retain subscribers because price elasticity floored at 10% for any price above $100, satisfaction ignored pricing entirely, and churn had no price component. Introduces perceived value per tier (model quality × reputation), replaces the broken linear formula with sigmoid decay, adds price-aware satisfaction blending, and applies per-tier price-based churn multipliers. Co-Authored-By: Claude Opus 4.6 --- .../src/systems/market/consumerTierSystem.ts | 99 +++++++++++++++++-- .../game-engine/src/systems/market/index.ts | 1 + .../src/systems/market/tamSystem.ts | 19 +++- packages/shared/src/constants/gameBalance.ts | 15 +++ 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/game-engine/src/systems/market/consumerTierSystem.ts b/packages/game-engine/src/systems/market/consumerTierSystem.ts index 8841eae..0d0719a 100644 --- a/packages/game-engine/src/systems/market/consumerTierSystem.ts +++ b/packages/game-engine/src/systems/market/consumerTierSystem.ts @@ -8,6 +8,12 @@ import { NETWORK_DEGRADATION, REJECTION_CHURN_MULTIPLIER, QUEUE_CHURN_MULTIPLIER, + CONSUMER_TIER_BASE_PERCEIVED_VALUE, + PERCEIVED_VALUE_REPUTATION_RANGE, + PRICE_ELASTICITY_STEEPNESS, + PRICE_SATISFACTION_WEIGHT, + PRICE_CHURN_EXPONENT, + PRICE_CHURN_MAX_MULTIPLIER, } from '@ai-tycoon/shared'; export interface ConsumerTickResult { @@ -16,10 +22,23 @@ export interface ConsumerTickResult { totalConsumerTokenDemand: number; } +function computePerceivedValue( + tierId: ConsumerTierId, + modelQuality: number, + reputation: number, +): number { + const baseValue = CONSUMER_TIER_BASE_PERCEIVED_VALUE[tierId]; + if (baseValue <= 0) return 0; + const repRange = PERCEIVED_VALUE_REPUTATION_RANGE; + const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min); + return baseValue * Math.max(0, modelQuality) * repMult; +} + export function processConsumerTiers( tiers: ConsumerTierState, playerConsumerCustomers: number, modelQuality: number, + reputation: number, seasonalConsumerMultiplier: number, networkLatencyPenalty: number, consumerPaidMetrics: TierServingMetrics, @@ -57,6 +76,7 @@ export function processConsumerTiers( team: 'pro', }; + // --- Pass 1: Conversions only (no churn yet) --- for (const id of CONSUMER_TIER_ORDER) { if (id === 'free') continue; const tier = updated.tiers[id]; @@ -69,22 +89,26 @@ export function processConsumerTiers( const conversionKey = `${prevId}->${id}`; const baseRate = CONVERSION_RATES[conversionKey] ?? 0; - const priceAttr = tier.config.price > 0 - ? Math.max(0.1, 1 - tier.config.price / 100) - : 1; - const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier; + const perceivedValue = computePerceivedValue(id, modelQuality, reputation); + let priceAttr: number; + if (tier.config.price <= 0) { + priceAttr = 1; + } else if (perceivedValue <= 0) { + priceAttr = 0; + } else { + const ratio = tier.config.price / perceivedValue; + priceAttr = 1 / (1 + Math.pow(ratio, PRICE_ELASTICITY_STEEPNESS)); + } + + const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier; const converting = prevTier.userCount * convRate; prevTier.userCount = Math.max(0, prevTier.userCount - converting); tier.userCount += converting; tier.conversionRateFromBelow = convRate; - - tier.churnRate = TIER_CHURN_RATES[id]; - const churnMultiplier = 1 + (1 - updated.satisfaction) * 2; - const churned = tier.userCount * tier.churnRate * churnMultiplier; - tier.userCount = Math.max(0, tier.userCount - churned); } + // --- Revenue & token demand --- let totalUsers = 0; let subscriptionRevenue = 0; let totalTokenDemand = 0; @@ -98,6 +122,7 @@ export function processConsumerTiers( updated.totalUsers = totalUsers; + // --- Serving penalties & serving-based extra churn --- const paidDemand = consumerPaidMetrics.demandTokens; const freeDemand = consumerFreeMetrics.demandTokens; const totalDemand = paidDemand + freeDemand; @@ -140,6 +165,7 @@ export function processConsumerTiers( } } + // --- Price-aware satisfaction --- let headroomBonus = 0; if (totalDemand > 0) { const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens; @@ -152,10 +178,63 @@ export function processConsumerTiers( } const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency; - updated.satisfaction = Math.min(1, Math.max(0, + const qualityServingSatisfaction = Math.min(1, Math.max(0, 0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty, )); + let priceSatNumerator = 0; + let priceSatDenominator = 0; + for (const id of CONSUMER_TIER_ORDER) { + if (id === 'free') continue; + const tier = updated.tiers[id]; + if (tier.userCount <= 0) continue; + const pv = computePerceivedValue(id, modelQuality, reputation); + let tierPriceSat: number; + if (tier.config.price <= 0) { + tierPriceSat = 1; + } else if (pv <= 0) { + tierPriceSat = 0; + } else { + tierPriceSat = Math.min(1, pv / tier.config.price); + } + priceSatNumerator += tierPriceSat * tier.userCount; + priceSatDenominator += tier.userCount; + } + const priceSatisfaction = priceSatDenominator > 0 + ? priceSatNumerator / priceSatDenominator + : 1; + + const w = PRICE_SATISFACTION_WEIGHT; + updated.satisfaction = Math.min(1, Math.max(0, + (1 - w) * qualityServingSatisfaction + w * priceSatisfaction, + )); + + // --- Pass 2: Churn (using price-aware satisfaction) --- + for (const id of CONSUMER_TIER_ORDER) { + if (id === 'free') continue; + const tier = updated.tiers[id]; + + tier.churnRate = TIER_CHURN_RATES[id]; + const satisfactionChurnMultiplier = 1 + (1 - updated.satisfaction) * 2; + + const pv = computePerceivedValue(id, modelQuality, reputation); + let priceChurnMultiplier = 1; + if (tier.config.price > 0 && pv > 0) { + const ratio = tier.config.price / pv; + if (ratio > 1) { + priceChurnMultiplier = Math.min( + PRICE_CHURN_MAX_MULTIPLIER, + Math.pow(ratio, PRICE_CHURN_EXPONENT), + ); + } + } else if (tier.config.price > 0 && pv <= 0) { + priceChurnMultiplier = PRICE_CHURN_MAX_MULTIPLIER; + } + + const churned = tier.userCount * tier.churnRate * satisfactionChurnMultiplier * priceChurnMultiplier; + tier.userCount = Math.max(0, tier.userCount - churned); + } + updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0; return { diff --git a/packages/game-engine/src/systems/market/index.ts b/packages/game-engine/src/systems/market/index.ts index 2700970..71db838 100644 --- a/packages/game-engine/src/systems/market/index.ts +++ b/packages/game-engine/src/systems/market/index.ts @@ -188,6 +188,7 @@ export function processMarketV2( state.market.consumerTiers, playerConsumerCustomers, modelQuality, + state.reputation.score, seasonal.multipliers.consumer, state.infrastructure.networkLatencyPenalty, sm.tierMetrics['consumer-paid'], diff --git a/packages/game-engine/src/systems/market/tamSystem.ts b/packages/game-engine/src/systems/market/tamSystem.ts index 7f93887..7bf45fc 100644 --- a/packages/game-engine/src/systems/market/tamSystem.ts +++ b/packages/game-engine/src/systems/market/tamSystem.ts @@ -15,6 +15,8 @@ import { ATTRACTIVENESS_WEIGHTS, OBSOLESCENCE_PENALTY_WEIGHT, NEW_MODEL_BOOST_VALUE, + CONSUMER_TIER_BASE_PERCEIVED_VALUE, + PERCEIVED_VALUE_REPUTATION_RANGE, } from '@ai-tycoon/shared'; export interface ParticipantProfile { @@ -27,6 +29,16 @@ export interface ParticipantProfile { hasFreeTier: boolean; } +function computeTamPriceScore(price: number, qualityScore: number, reputation: number): number { + if (price <= 0) return 1; + const repRange = PERCEIVED_VALUE_REPUTATION_RANGE; + const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min); + const refPV = CONSUMER_TIER_BASE_PERCEIVED_VALUE.plus * qualityScore * repMult; + if (refPV <= 0) return 0; + const ratio = price / refPV; + return 1 / (1 + ratio * ratio); +} + export function buildPlayerProfile( qualityScore: number, chatPrice: number, @@ -36,8 +48,7 @@ export function buildPlayerProfile( obsolescence: ObsolescenceState, hasFreeTier: boolean, ): ParticipantProfile { - const avgPrice = (chatPrice + apiOutputPrice) / 2; - const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100)); + const priceScore = computeTamPriceScore(chatPrice, qualityScore, reputation); let freshness = obsolescence.playerModelFreshness; if (obsolescence.newModelBoostRemaining > 0) { @@ -56,8 +67,8 @@ export function buildPlayerProfile( } export function buildCompetitorProfile(c: Competitor): ParticipantProfile { - const avgPrice = (c.products.chatPrice + c.products.apiOutputPrice) / 2; - const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100)); + const compQuality = Math.min(1, c.estimatedCapability / 100); + const priceScore = computeTamPriceScore(c.products.chatPrice, compQuality, c.reputation); return { id: c.id, diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts index 15bec14..984c5a4 100644 --- a/packages/shared/src/constants/gameBalance.ts +++ b/packages/shared/src/constants/gameBalance.ts @@ -873,6 +873,21 @@ export const TIER_CHURN_RATES: Record = { export const FREE_TIER_ADOPTION_RATE = 0.10; +// --- Consumer Perceived Value --- + +export const CONSUMER_TIER_BASE_PERCEIVED_VALUE: Record = { + free: 0, + plus: 40, + pro: 120, + team: 80, +}; + +export const PERCEIVED_VALUE_REPUTATION_RANGE = { min: 0.5, max: 1.0 }; +export const PRICE_ELASTICITY_STEEPNESS = 3.0; +export const PRICE_SATISFACTION_WEIGHT = 0.3; +export const PRICE_CHURN_EXPONENT = 1.5; +export const PRICE_CHURN_MAX_MULTIPLIER = 10.0; + // --- API Tier Defaults --- export const API_TIER_DEFAULTS: Record = {