Fix consumer subscription pricing exploit with perceived-value-based elasticity
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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ import {
|
|||||||
NETWORK_DEGRADATION,
|
NETWORK_DEGRADATION,
|
||||||
REJECTION_CHURN_MULTIPLIER,
|
REJECTION_CHURN_MULTIPLIER,
|
||||||
QUEUE_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';
|
} from '@ai-tycoon/shared';
|
||||||
|
|
||||||
export interface ConsumerTickResult {
|
export interface ConsumerTickResult {
|
||||||
@@ -16,10 +22,23 @@ export interface ConsumerTickResult {
|
|||||||
totalConsumerTokenDemand: 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(
|
export function processConsumerTiers(
|
||||||
tiers: ConsumerTierState,
|
tiers: ConsumerTierState,
|
||||||
playerConsumerCustomers: number,
|
playerConsumerCustomers: number,
|
||||||
modelQuality: number,
|
modelQuality: number,
|
||||||
|
reputation: number,
|
||||||
seasonalConsumerMultiplier: number,
|
seasonalConsumerMultiplier: number,
|
||||||
networkLatencyPenalty: number,
|
networkLatencyPenalty: number,
|
||||||
consumerPaidMetrics: TierServingMetrics,
|
consumerPaidMetrics: TierServingMetrics,
|
||||||
@@ -57,6 +76,7 @@ export function processConsumerTiers(
|
|||||||
team: 'pro',
|
team: 'pro',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Pass 1: Conversions only (no churn yet) ---
|
||||||
for (const id of CONSUMER_TIER_ORDER) {
|
for (const id of CONSUMER_TIER_ORDER) {
|
||||||
if (id === 'free') continue;
|
if (id === 'free') continue;
|
||||||
const tier = updated.tiers[id];
|
const tier = updated.tiers[id];
|
||||||
@@ -69,22 +89,26 @@ export function processConsumerTiers(
|
|||||||
|
|
||||||
const conversionKey = `${prevId}->${id}`;
|
const conversionKey = `${prevId}->${id}`;
|
||||||
const baseRate = CONVERSION_RATES[conversionKey] ?? 0;
|
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;
|
const converting = prevTier.userCount * convRate;
|
||||||
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
|
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
|
||||||
tier.userCount += converting;
|
tier.userCount += converting;
|
||||||
tier.conversionRateFromBelow = convRate;
|
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 totalUsers = 0;
|
||||||
let subscriptionRevenue = 0;
|
let subscriptionRevenue = 0;
|
||||||
let totalTokenDemand = 0;
|
let totalTokenDemand = 0;
|
||||||
@@ -98,6 +122,7 @@ export function processConsumerTiers(
|
|||||||
|
|
||||||
updated.totalUsers = totalUsers;
|
updated.totalUsers = totalUsers;
|
||||||
|
|
||||||
|
// --- Serving penalties & serving-based extra churn ---
|
||||||
const paidDemand = consumerPaidMetrics.demandTokens;
|
const paidDemand = consumerPaidMetrics.demandTokens;
|
||||||
const freeDemand = consumerFreeMetrics.demandTokens;
|
const freeDemand = consumerFreeMetrics.demandTokens;
|
||||||
const totalDemand = paidDemand + freeDemand;
|
const totalDemand = paidDemand + freeDemand;
|
||||||
@@ -140,6 +165,7 @@ export function processConsumerTiers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Price-aware satisfaction ---
|
||||||
let headroomBonus = 0;
|
let headroomBonus = 0;
|
||||||
if (totalDemand > 0) {
|
if (totalDemand > 0) {
|
||||||
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
|
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
|
||||||
@@ -152,10 +178,63 @@ export function processConsumerTiers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
|
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,
|
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;
|
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export function processMarketV2(
|
|||||||
state.market.consumerTiers,
|
state.market.consumerTiers,
|
||||||
playerConsumerCustomers,
|
playerConsumerCustomers,
|
||||||
modelQuality,
|
modelQuality,
|
||||||
|
state.reputation.score,
|
||||||
seasonal.multipliers.consumer,
|
seasonal.multipliers.consumer,
|
||||||
state.infrastructure.networkLatencyPenalty,
|
state.infrastructure.networkLatencyPenalty,
|
||||||
sm.tierMetrics['consumer-paid'],
|
sm.tierMetrics['consumer-paid'],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
ATTRACTIVENESS_WEIGHTS,
|
ATTRACTIVENESS_WEIGHTS,
|
||||||
OBSOLESCENCE_PENALTY_WEIGHT,
|
OBSOLESCENCE_PENALTY_WEIGHT,
|
||||||
NEW_MODEL_BOOST_VALUE,
|
NEW_MODEL_BOOST_VALUE,
|
||||||
|
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
|
||||||
|
PERCEIVED_VALUE_REPUTATION_RANGE,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
|
|
||||||
export interface ParticipantProfile {
|
export interface ParticipantProfile {
|
||||||
@@ -27,6 +29,16 @@ export interface ParticipantProfile {
|
|||||||
hasFreeTier: boolean;
|
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(
|
export function buildPlayerProfile(
|
||||||
qualityScore: number,
|
qualityScore: number,
|
||||||
chatPrice: number,
|
chatPrice: number,
|
||||||
@@ -36,8 +48,7 @@ export function buildPlayerProfile(
|
|||||||
obsolescence: ObsolescenceState,
|
obsolescence: ObsolescenceState,
|
||||||
hasFreeTier: boolean,
|
hasFreeTier: boolean,
|
||||||
): ParticipantProfile {
|
): ParticipantProfile {
|
||||||
const avgPrice = (chatPrice + apiOutputPrice) / 2;
|
const priceScore = computeTamPriceScore(chatPrice, qualityScore, reputation);
|
||||||
const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100));
|
|
||||||
|
|
||||||
let freshness = obsolescence.playerModelFreshness;
|
let freshness = obsolescence.playerModelFreshness;
|
||||||
if (obsolescence.newModelBoostRemaining > 0) {
|
if (obsolescence.newModelBoostRemaining > 0) {
|
||||||
@@ -56,8 +67,8 @@ export function buildPlayerProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCompetitorProfile(c: Competitor): ParticipantProfile {
|
export function buildCompetitorProfile(c: Competitor): ParticipantProfile {
|
||||||
const avgPrice = (c.products.chatPrice + c.products.apiOutputPrice) / 2;
|
const compQuality = Math.min(1, c.estimatedCapability / 100);
|
||||||
const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100));
|
const priceScore = computeTamPriceScore(c.products.chatPrice, compQuality, c.reputation);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
|
|||||||
@@ -873,6 +873,21 @@ export const TIER_CHURN_RATES: Record<ConsumerTierId, number> = {
|
|||||||
|
|
||||||
export const FREE_TIER_ADOPTION_RATE = 0.10;
|
export const FREE_TIER_ADOPTION_RATE = 0.10;
|
||||||
|
|
||||||
|
// --- Consumer Perceived Value ---
|
||||||
|
|
||||||
|
export const CONSUMER_TIER_BASE_PERCEIVED_VALUE: Record<ConsumerTierId, number> = {
|
||||||
|
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 ---
|
// --- API Tier Defaults ---
|
||||||
|
|
||||||
export const API_TIER_DEFAULTS: Record<ApiTierId, { monthlyFee: number; inputPrice: number; outputPrice: number; rateLimit: number }> = {
|
export const API_TIER_DEFAULTS: Record<ApiTierId, { monthlyFee: number; inputPrice: number; outputPrice: number; rateLimit: number }> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user