df01ac8e35
Churned subscribers no longer generate revenue the tick they leave, and the price churn multiplier cap is raised from 10 to 1000 so astronomical prices empty the subscriber pool in a single tick. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
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<ConsumerTierId, ConsumerTierId | null> = {
|
|
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,
|
|
};
|
|
}
|