import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@ai-tycoon/shared'; import { CONSUMER_TIER_ORDER, CONVERSION_RATES, TIER_CHURN_RATES, FREE_TIER_ADOPTION_RATE, CONSUMER_TOKENS_PER_SUBSCRIBER, 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 { consumerTiers: ConsumerTierState; subscriptionRevenue: number; 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, consumerFreeMetrics: TierServingMetrics, ): ConsumerTickResult { const updated = { tiers: { ...tiers.tiers }, totalUsers: 0, satisfaction: tiers.satisfaction, viralCoefficient: tiers.viralCoefficient, }; for (const id of CONSUMER_TIER_ORDER) { updated.tiers[id] = { ...tiers.tiers[id], config: { ...tiers.tiers[id].config } }; } if (modelQuality <= 0) { return { consumerTiers: updated, subscriptionRevenue: 0, totalConsumerTokenDemand: 0 }; } const qualityFactor = Math.max(0.1, modelQuality); const freeAdoption = playerConsumerCustomers * FREE_TIER_ADOPTION_RATE * seasonalConsumerMultiplier; const targetFreeUsers = Math.max(updated.tiers.free.userCount, freeAdoption); const freeGrowth = (targetFreeUsers - updated.tiers.free.userCount) * 0.05; updated.tiers.free.userCount = Math.max(0, updated.tiers.free.userCount + freeGrowth); updated.tiers.free.churnRate = TIER_CHURN_RATES.free; const freeChurn = updated.tiers.free.userCount * TIER_CHURN_RATES.free; updated.tiers.free.userCount = Math.max(0, updated.tiers.free.userCount - freeChurn); const prevTierMap: Record = { free: null, plus: 'free', pro: 'plus', 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]; if (!tier.config.isActive) continue; if (modelQuality * 100 < tier.config.requiredModelQuality) continue; const prevId = prevTierMap[id]; if (!prevId) continue; const prevTier = updated.tiers[prevId]; const conversionKey = `${prevId}->${id}`; const baseRate = CONVERSION_RATES[conversionKey] ?? 0; 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; } // --- Serving penalties & serving-based extra churn --- const paidDemand = consumerPaidMetrics.demandTokens; const freeDemand = consumerFreeMetrics.demandTokens; const totalDemand = paidDemand + freeDemand; let servingPenalty = 0; if (totalDemand > 0) { const totalRejected = consumerPaidMetrics.rejectedTokens + consumerFreeMetrics.rejectedTokens; const totalQueued = consumerPaidMetrics.queuedTokens + consumerFreeMetrics.queuedTokens; const rejectedFraction = totalRejected / totalDemand; const queuedFraction = totalQueued / totalDemand; servingPenalty = rejectedFraction * 1.5 + queuedFraction * 0.5; const avgQuality = totalDemand > 0 ? (consumerPaidMetrics.avgQualityDelivered * paidDemand + consumerFreeMetrics.avgQualityDelivered * freeDemand) / totalDemand : modelQuality; const qualityGap = Math.max(0, modelQuality - avgQuality); servingPenalty += qualityGap * 0.8; if (consumerFreeMetrics.rejectedTokens > 0 && freeDemand > 0) { const freeRejectRate = consumerFreeMetrics.rejectedTokens / freeDemand; const extraChurn = updated.tiers.free.userCount * freeRejectRate * 0.01 * REJECTION_CHURN_MULTIPLIER; updated.tiers.free.userCount = Math.max(0, updated.tiers.free.userCount - extraChurn); } if (consumerPaidMetrics.rejectedTokens > 0 && paidDemand > 0) { const paidRejectRate = consumerPaidMetrics.rejectedTokens / paidDemand; for (const id of CONSUMER_TIER_ORDER) { if (id === 'free') continue; const extraChurn = updated.tiers[id].userCount * paidRejectRate * 0.005 * REJECTION_CHURN_MULTIPLIER; updated.tiers[id].userCount = Math.max(0, updated.tiers[id].userCount - extraChurn); } } if (totalQueued > 0) { for (const id of CONSUMER_TIER_ORDER) { const extraChurn = updated.tiers[id].userCount * queuedFraction * 0.002 * QUEUE_CHURN_MULTIPLIER; updated.tiers[id].userCount = Math.max(0, updated.tiers[id].userCount - extraChurn); } } } // --- Price-aware satisfaction --- let headroomBonus = 0; if (totalDemand > 0) { const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens; const servedFraction = totalServed / totalDemand; if (servedFraction > 0.95) { headroomBonus = (servedFraction - 0.95) * 4; } } else { headroomBonus = 0.1; } const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency; 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); } // --- Revenue & token demand (after all churn — cancelled users don't pay) --- let totalUsers = 0; let subscriptionRevenue = 0; let totalTokenDemand = 0; for (const id of CONSUMER_TIER_ORDER) { const tier = updated.tiers[id]; totalUsers += tier.userCount; subscriptionRevenue += tier.userCount * (tier.config.price / 86400); totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER; } updated.totalUsers = totalUsers; updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0; return { consumerTiers: updated, subscriptionRevenue, totalConsumerTokenDemand: totalTokenDemand, }; }