Overhaul market system with shared TAM competition, multi-tier pricing, enterprise pipeline, and developer ecosystem
CI / build-and-push (push) Successful in 42s

Replaces the simplified single-subscriber market with a full competitive simulation:
shared TAM with softmax market shares across 4 segments, multi-tier consumer
subscriptions (Free/Plus/Pro/Team) and API tiers (Free/PAYG/Scale/Enterprise),
enterprise sales pipeline (Lead→Qualification→POC→Negotiation→Active→Renewal)
with SLA tracking, developer ecosystem flywheel, technology obsolescence pressure,
seasonal demand cycles, and two new product lines (Code Assistant, AI Agents Platform).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 08:30:24 -04:00
parent 4c1c0e9ff2
commit 09a5cb69a7
34 changed files with 2851 additions and 408 deletions
@@ -0,0 +1,97 @@
import type { ApiTierState, ApiTierId, DeveloperEcosystem } from '@ai-tycoon/shared';
import {
API_TIER_ORDER,
API_CONVERSION_RATES,
API_TIER_CHURN_RATES,
API_TOKENS_PER_DEVELOPER_PER_TICK,
} from '@ai-tycoon/shared';
export interface ApiTickResult {
apiTiers: ApiTierState;
apiRevenue: number;
totalApiTokenDemand: number;
}
export function processApiTiers(
tiers: ApiTierState,
playerDevCustomers: number,
modelQuality: number,
seasonalApiMultiplier: number,
ecosystem: DeveloperEcosystem,
): ApiTickResult {
const updated: ApiTierState = {
tiers: { ...tiers.tiers },
totalDevelopers: 0,
totalTokensPerTick: 0,
};
for (const id of API_TIER_ORDER) {
updated.tiers[id] = { ...tiers.tiers[id], config: { ...tiers.tiers[id].config } };
}
if (modelQuality <= 0) {
return { apiTiers: updated, apiRevenue: 0, totalApiTokenDemand: 0 };
}
const targetFreeDevelopers = playerDevCustomers * 0.1 * seasonalApiMultiplier;
const freeGrowth = (targetFreeDevelopers - updated.tiers.free.developerCount) * 0.03;
updated.tiers.free.developerCount = Math.max(0, updated.tiers.free.developerCount + freeGrowth);
const freeChurn = updated.tiers.free.developerCount * API_TIER_CHURN_RATES.free;
updated.tiers.free.developerCount = Math.max(0, updated.tiers.free.developerCount - freeChurn);
updated.tiers.free.churnRate = API_TIER_CHURN_RATES.free;
const prevTierMap: Record<ApiTierId, ApiTierId | null> = {
free: null,
payg: 'free',
scale: 'payg',
'enterprise-api': 'scale',
};
for (const id of API_TIER_ORDER) {
if (id === 'free') continue;
const tier = updated.tiers[id];
if (!tier.config.isActive) continue;
const prevId = prevTierMap[id];
if (!prevId) continue;
const prevTier = updated.tiers[prevId];
const convKey = `${prevId}->${id}`;
const baseRate = API_CONVERSION_RATES[convKey] ?? 0;
const ecosystemBoost = 1 + ecosystem.ecosystemScore / 200;
const convRate = baseRate * Math.max(0.1, modelQuality) * ecosystemBoost * seasonalApiMultiplier;
const converting = prevTier.developerCount * convRate;
prevTier.developerCount = Math.max(0, prevTier.developerCount - converting);
tier.developerCount += converting;
tier.churnRate = API_TIER_CHURN_RATES[id];
const churned = tier.developerCount * tier.churnRate;
tier.developerCount = Math.max(0, tier.developerCount - churned);
}
let totalDevelopers = 0;
let totalTokens = 0;
let apiRevenue = 0;
for (const id of API_TIER_ORDER) {
const tier = updated.tiers[id];
totalDevelopers += tier.developerCount;
const tokensPerDev = API_TOKENS_PER_DEVELOPER_PER_TICK[id];
tier.tokensPerTick = tier.developerCount * tokensPerDev;
totalTokens += tier.tokensPerTick;
apiRevenue += tier.developerCount * (tier.config.monthlyFee / 86400);
apiRevenue += (tier.tokensPerTick / 1_000_000) * tier.config.outputTokenPrice;
}
updated.totalDevelopers = totalDevelopers;
updated.totalTokensPerTick = totalTokens;
return {
apiTiers: updated,
apiRevenue: Math.max(0, apiRevenue),
totalApiTokenDemand: totalTokens,
};
}
@@ -0,0 +1,127 @@
import type { ConsumerTierState, ConsumerTierId } from '@ai-tycoon/shared';
import {
CONSUMER_TIER_ORDER,
CONVERSION_RATES,
TIER_CHURN_RATES,
FREE_TIER_ADOPTION_RATE,
CONSUMER_TOKENS_PER_SUBSCRIBER,
OVERLOAD_PENALTY_EXPONENT,
NETWORK_DEGRADATION,
} from '@ai-tycoon/shared';
export interface ConsumerTickResult {
consumerTiers: ConsumerTierState;
subscriptionRevenue: number;
totalConsumerTokenDemand: number;
}
export function processConsumerTiers(
tiers: ConsumerTierState,
playerConsumerCustomers: number,
modelQuality: number,
seasonalConsumerMultiplier: number,
demandCapacityRatio: number,
networkLatencyPenalty: number,
overloadPolicy: { degradeQualityUnderLoad: boolean; prioritizeEnterprise: boolean },
): 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',
};
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 priceAttr = tier.config.price > 0
? Math.max(0.1, 1 - tier.config.price / 100)
: 1;
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);
}
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;
let headroomBonus = 0;
let overloadPenalty = 0;
if (demandCapacityRatio <= 1) {
headroomBonus = (1 - demandCapacityRatio) * 0.2;
} else {
overloadPenalty = Math.min(1, Math.pow(demandCapacityRatio - 1, OVERLOAD_PENALTY_EXPONENT));
}
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
updated.satisfaction = Math.min(1, Math.max(0,
0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty - netLatencyPenalty,
));
if (overloadPolicy.degradeQualityUnderLoad && demandCapacityRatio > 0.85) {
updated.satisfaction = Math.max(0, updated.satisfaction - 0.02);
}
if (overloadPolicy.prioritizeEnterprise && demandCapacityRatio > 0.9) {
updated.satisfaction = Math.max(0, updated.satisfaction - 0.01);
}
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
return {
consumerTiers: updated,
subscriptionRevenue,
totalConsumerTokenDemand: totalTokenDemand,
};
}
@@ -0,0 +1,69 @@
import type { DeveloperEcosystem } from '@ai-tycoon/shared';
import {
BASE_DEV_GROWTH,
FREE_TIER_DEV_MULTIPLIER,
OPEN_SOURCE_DEV_BOOST,
DEV_REL_EFFECTIVENESS,
SDK_GROWTH_BONUS,
DEV_ECOSYSTEM_WEIGHTS,
STARTUP_ADOPTION_PER_DEV,
ENTERPRISE_REFERRAL_PER_STARTUP,
TAM_BASE_SIZES,
} from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared';
export function processDeveloperEcosystem(
eco: DeveloperEcosystem,
openSourceCount: number,
apiFreeTierDevs: number,
apiTotalDevs: number,
engineeringHeadcount: number,
era: Era,
): DeveloperEcosystem {
const updated = { ...eco };
const growthRate =
BASE_DEV_GROWTH +
apiFreeTierDevs * FREE_TIER_DEV_MULTIPLIER +
openSourceCount * OPEN_SOURCE_DEV_BOOST +
updated.devRelSpending * DEV_REL_EFFECTIVENESS +
updated.sdkCoverage * SDK_GROWTH_BONUS;
updated.communityGrowthRate = growthRate;
updated.communitySize = Math.max(0, updated.communitySize + updated.communitySize * growthRate);
if (updated.communitySize < 10 && apiTotalDevs > 0) {
updated.communitySize += 1 + apiTotalDevs * 0.1;
}
updated.activeDevelopers = apiTotalDevs;
updated.openSourceContributions = openSourceCount;
const sdkTarget = Math.min(1, engineeringHeadcount / 50);
updated.sdkCoverage += (sdkTarget - updated.sdkCoverage) * 0.005;
updated.sdkCoverage = Math.min(1, Math.max(0, updated.sdkCoverage));
const docTarget = Math.min(1, updated.devRelSpending / 500);
updated.documentationQuality += (docTarget - updated.documentationQuality) * 0.003;
updated.documentationQuality = Math.min(1, Math.max(0, updated.documentationQuality));
const eraCap = TAM_BASE_SIZES[era].developer;
const communityNorm = Math.min(1, updated.communitySize / Math.max(1, eraCap * 0.1));
const activeRatio = updated.communitySize > 0
? Math.min(1, updated.activeDevelopers / updated.communitySize)
: 0;
const osNorm = Math.min(1, openSourceCount / 5);
updated.ecosystemScore = (
DEV_ECOSYSTEM_WEIGHTS.communitySize * communityNorm +
DEV_ECOSYSTEM_WEIGHTS.activeRatio * activeRatio +
DEV_ECOSYSTEM_WEIGHTS.sdkCoverage * updated.sdkCoverage +
DEV_ECOSYSTEM_WEIGHTS.docQuality * updated.documentationQuality +
DEV_ECOSYSTEM_WEIGHTS.openSource * osNorm
) * 100;
updated.startupsAdopted = Math.floor(updated.activeDevelopers * STARTUP_ADOPTION_PER_DEV);
updated.enterpriseReferrals = Math.floor(updated.startupsAdopted * ENTERPRISE_REFERRAL_PER_STARTUP);
return updated;
}
@@ -0,0 +1,229 @@
import type {
EnterpriseState,
EnterpriseLead,
EnterpriseContract,
EnterpriseSegment,
EnterprisePipelineStage,
DeveloperEcosystem,
} from '@ai-tycoon/shared';
import {
BASE_LEAD_RATE,
LEAD_EXPIRY_TICKS,
PIPELINE_STAGE_TIMEOUTS,
PIPELINE_TRANSITION_RATES,
SLA_PENALTY_FRACTION,
CONTRACT_DURATION_BY_SEGMENT,
ENTERPRISE_DEAL_VALUES,
ENTERPRISE_SLA_REQUIREMENTS,
ENTERPRISE_CAPABILITY_REQUIREMENTS,
ENTERPRISE_TOKENS_PER_TICK,
} from '@ai-tycoon/shared';
import { ENTERPRISE_NAMES } from '../../data/enterpriseNames';
let leadIdCounter = 0;
function generateLeadId(): string {
return `lead_${Date.now()}_${++leadIdCounter}`;
}
function randomInRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function pickSegment(reputation: number): EnterpriseSegment {
const roll = Math.random();
if (reputation > 70 && roll < 0.15) return 'government';
if (reputation > 50 && roll < 0.35) return 'enterprise';
if (roll < 0.6) return 'mid-market';
return 'startup';
}
function pickCompanyName(segment: EnterpriseSegment, existingNames: Set<string>): string {
const pool = ENTERPRISE_NAMES[segment];
const available = pool.filter(n => !existingNames.has(n));
if (available.length === 0) return `${segment}-client-${Math.floor(Math.random() * 9999)}`;
return available[Math.floor(Math.random() * available.length)];
}
export interface EnterprisePipelineResult {
enterprise: EnterpriseState;
contractRevenue: number;
slaPenalties: number;
contractTokenDemand: number;
}
export function processEnterprisePipeline(
ent: EnterpriseState,
reputation: number,
modelCapability: number,
safetyScore: number,
salesHeadcount: number,
salesEffectiveness: number,
devEcosystem: DeveloperEcosystem,
seasonalEntMultiplier: number,
currentTick: number,
demandCapacityRatio: number,
): EnterprisePipelineResult {
const pipeline = [...ent.pipeline];
const activeContracts = [...ent.activeContracts];
const effectiveSales = salesHeadcount > 0
? Math.min(1, salesHeadcount * salesEffectiveness / Math.max(1, pipeline.length))
: 0;
// --- Lead generation ---
const leadRate = BASE_LEAD_RATE
* (1 + reputation / 100)
* (1 + devEcosystem.startupsAdopted * 0.001)
* (effectiveSales > 0 ? effectiveSales : 0.1)
* seasonalEntMultiplier;
if (Math.random() < leadRate && pipeline.length < 20) {
const existingNames = new Set([
...pipeline.map(l => l.companyName),
...activeContracts.map(c => c.customerName),
]);
const segment = pickSegment(reputation);
const vals = ENTERPRISE_DEAL_VALUES[segment];
const toks = ENTERPRISE_TOKENS_PER_TICK[segment];
pipeline.push({
id: generateLeadId(),
companyName: pickCompanyName(segment, existingNames),
segment,
stage: 'lead',
enteredStageAtTick: currentTick,
dealValue: randomInRange(vals.min, vals.max),
tokensPerTick: randomInRange(toks.min, toks.max),
requiredCapability: ENTERPRISE_CAPABILITY_REQUIREMENTS[segment],
requiredSlaUptime: ENTERPRISE_SLA_REQUIREMENTS[segment],
requiredSafetyScore: segment === 'government' ? 60 : 30,
winProbability: 0.5,
expiresAtTick: currentTick + LEAD_EXPIRY_TICKS,
});
}
// --- Pipeline progression ---
const stageOrder: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
const nextStageMap: Record<EnterprisePipelineStage, EnterprisePipelineStage | 'active'> = {
lead: 'qualification',
qualification: 'poc',
poc: 'negotiation',
negotiation: 'active',
};
const survivingLeads: EnterpriseLead[] = [];
const newContracts: EnterpriseContract[] = [];
for (const lead of pipeline) {
if (currentTick > lead.expiresAtTick) continue;
const timeout = PIPELINE_STAGE_TIMEOUTS[lead.stage];
if (currentTick - lead.enteredStageAtTick > timeout) continue;
const transKey = `${lead.stage}->${nextStageMap[lead.stage]}`;
const baseRate = PIPELINE_TRANSITION_RATES[transKey] ?? 0;
let transitionProb = baseRate * effectiveSales;
if (lead.stage === 'qualification') {
transitionProb *= modelCapability >= lead.requiredCapability ? 1 : 0.1;
} else if (lead.stage === 'poc') {
transitionProb *= Math.max(0.2, 1 - Math.max(0, demandCapacityRatio - 0.9) * 5);
} else if (lead.stage === 'negotiation') {
transitionProb *= Math.max(0.3, 1 - (lead.dealValue / 10_000_000) * 0.5);
}
if (lead.stage === 'qualification' && safetyScore < lead.requiredSafetyScore) {
transitionProb *= 0.2;
}
if (Math.random() < transitionProb) {
const nextStage = nextStageMap[lead.stage];
if (nextStage === 'active') {
const duration = CONTRACT_DURATION_BY_SEGMENT[lead.segment];
const pricePerMToken = (lead.dealValue / duration) / (lead.tokensPerTick / 1_000_000);
newContracts.push({
id: lead.id,
customerName: lead.companyName,
segment: lead.segment,
tokensPerTick: lead.tokensPerTick,
pricePerMToken: Math.max(0.1, pricePerMToken),
slaUptime: lead.requiredSlaUptime,
startTick: currentTick,
durationTicks: duration,
satisfaction: 0.7,
renewalProbability: 0.5,
slaViolations: 0,
slaPenaltiesPaid: 0,
uptimeTicks: 0,
totalTicks: 0,
});
} else {
survivingLeads.push({
...lead,
stage: nextStage,
enteredStageAtTick: currentTick,
});
}
} else {
survivingLeads.push(lead);
}
}
// --- Active contracts: SLA, satisfaction, renewal ---
let contractRevenue = 0;
let slaPenalties = 0;
let contractTokenDemand = 0;
const survivingContracts: EnterpriseContract[] = [];
for (const contract of [...activeContracts, ...newContracts]) {
const updated = { ...contract };
updated.totalTicks++;
if (demandCapacityRatio <= (1 / updated.slaUptime)) {
updated.uptimeTicks++;
} else {
updated.slaViolations++;
const penalty = updated.pricePerMToken * (updated.tokensPerTick / 1_000_000) * SLA_PENALTY_FRACTION;
slaPenalties += penalty;
updated.slaPenaltiesPaid += penalty;
updated.satisfaction = Math.max(0, updated.satisfaction - 0.005);
}
if (updated.totalTicks > 0 && updated.slaViolations === 0) {
updated.satisfaction = Math.min(1, updated.satisfaction + 0.001);
}
const tickRevenue = (updated.tokensPerTick / 1_000_000) * updated.pricePerMToken;
contractRevenue += tickRevenue;
contractTokenDemand += updated.tokensPerTick;
if (currentTick >= updated.startTick + updated.durationTicks) {
const renewalProb = updated.satisfaction * 0.6 + 0.3 - (updated.slaViolations * 0.01);
if (Math.random() < Math.max(0, renewalProb)) {
updated.startTick = currentTick;
updated.totalTicks = 0;
updated.uptimeTicks = 0;
updated.slaViolations = 0;
updated.slaPenaltiesPaid = 0;
survivingContracts.push(updated);
}
} else {
survivingContracts.push(updated);
}
}
return {
enterprise: {
...ent,
pipeline: survivingLeads,
activeContracts: survivingContracts,
totalApiCallsPerTick: contractTokenDemand / ent.averageTokensPerCall,
leadGenerationRate: leadRate,
},
contractRevenue,
slaPenalties,
contractTokenDemand,
};
}
@@ -0,0 +1,230 @@
import type { GameState, MarketState, BenchmarkResult, Competitor } from '@ai-tycoon/shared';
import { CONSUMER_TOKENS_PER_SUBSCRIBER } from '@ai-tycoon/shared';
import { BENCHMARKS } from '../../data/benchmarks';
import { computeSeasonal } from './seasonalSystem';
import { updateObsolescence } from './obsolescenceSystem';
import { buildPlayerProfile, buildCompetitorProfile, computeMarketShares, updateTAMGrowth } from './tamSystem';
import { processConsumerTiers } from './consumerTierSystem';
import { processApiTiers } from './apiTierSystem';
import { processProductLines } from './productLines';
import { processDeveloperEcosystem } from './developerEcosystem';
import { processEnterprisePipeline } from './enterprisePipeline';
export interface MarketTickResult {
marketState: MarketState;
apiRevenue: number;
subscriptionRevenue: number;
totalTokenDemand: number;
}
function getSegmentQuality(
segment: 'consumer' | 'enterprise' | 'developer' | 'research',
benchmarkResults: BenchmarkResult[],
fallbackScore: number,
): number {
if (benchmarkResults.length === 0) return fallbackScore / 100;
const bestByBenchmark = new Map<string, number>();
for (const r of benchmarkResults) {
const prev = bestByBenchmark.get(r.benchmarkId) ?? 0;
if (r.score > prev) bestByBenchmark.set(r.benchmarkId, r.score);
}
let weightedSum = 0;
let totalWeight = 0;
for (const bench of BENCHMARKS) {
const score = bestByBenchmark.get(bench.id);
if (score == null) continue;
const weight = bench.marketRelevance[segment];
weightedSum += (score / 100) * weight;
totalWeight += weight;
}
if (totalWeight === 0) return fallbackScore / 100;
return weightedSum / totalWeight;
}
export function processMarketV2(state: GameState, currentTickCapacity: number): MarketTickResult {
const consumerQuality = getSegmentQuality('consumer', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const enterpriseQuality = getSegmentQuality('enterprise', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const modelQuality = state.models.benchmarkResults.length > 0
? (consumerQuality + enterpriseQuality) / 2
: state.models.bestDeployedModelScore / 100;
// --- Seasonal ---
const seasonal = computeSeasonal(state.meta.tickCount);
// --- Obsolescence ---
const obsolescence = updateObsolescence(
state.market.obsolescence,
state.meta.currentEra,
state.meta.tickCount,
);
// --- Developer Ecosystem ---
const freeApiDevs = state.market.apiTiers.tiers.free.developerCount;
const totalApiDevs = state.market.apiTiers.totalDevelopers;
const engineeringCount = state.talent.departments.engineering.headcount;
const devEcosystem = processDeveloperEcosystem(
state.market.developerEcosystem,
state.market.openSourcedModels.length,
freeApiDevs,
totalApiDevs,
engineeringCount,
state.meta.currentEra,
);
// --- TAM & Market Shares ---
const chatProduct = state.models.productLines.find(p => p.type === 'chat-product');
const textApi = state.models.productLines.find(p => p.type === 'text-api');
const chatPrice = chatProduct?.pricing.subscriptionPrice ?? 20;
const apiOutPrice = textApi?.pricing.outputTokenPrice ?? 3;
const hasAnyFreeTier = state.market.consumerTiers.tiers.free.config.isActive
|| state.market.apiTiers.tiers.free.config.isActive;
const playerProfile = buildPlayerProfile(
modelQuality,
chatPrice,
apiOutPrice,
state.reputation.score,
devEcosystem,
obsolescence,
hasAnyFreeTier,
);
const activeRivals = state.competitors.rivals.filter(r => r.status === 'active');
const competitorProfiles = activeRivals.map(buildCompetitorProfile);
const allProfiles = [playerProfile, ...competitorProfiles];
let tam = updateTAMGrowth(state.market.tam, state.meta.currentEra);
tam = computeMarketShares(tam, allProfiles, obsolescence.marketQualityBaseline);
const playerConsumerCustomers = tam.segments.consumer.shares.find(s => s.playerId === 'player')?.customers ?? 0;
const playerDevCustomers = tam.segments.developer.shares.find(s => s.playerId === 'player')?.customers ?? 0;
const playerEntCustomers = tam.segments.enterprise.shares.find(s => s.playerId === 'player')?.customers ?? 0;
// --- Consumer Tiers ---
const consumerDemandEstimate = state.market.consumerTiers.totalUsers * CONSUMER_TOKENS_PER_SUBSCRIBER;
const demandCapacityRatio = currentTickCapacity > 0
? consumerDemandEstimate / currentTickCapacity
: consumerDemandEstimate > 0 ? 10 : 0;
const consumerResult = processConsumerTiers(
state.market.consumerTiers,
playerConsumerCustomers,
modelQuality,
seasonal.multipliers.consumer,
demandCapacityRatio,
state.infrastructure.networkLatencyPenalty,
state.market.overloadPolicy,
);
// --- API Tiers ---
const apiResult = processApiTiers(
state.market.apiTiers,
playerDevCustomers,
modelQuality,
seasonal.multipliers.api,
devEcosystem,
);
// --- Product Lines ---
const productResult = processProductLines(
state.market.codeAssistant,
state.market.agentsPlatform,
state.models.benchmarkResults,
playerDevCustomers,
playerEntCustomers,
seasonal.multipliers.consumer,
seasonal.multipliers.enterprise,
);
// --- Enterprise Pipeline ---
const salesDept = state.talent.departments.sales;
const salesHeadcount = salesDept.headcount;
const salesEffectiveness = salesDept.effectiveness;
const enterpriseResult = processEnterprisePipeline(
state.market.enterprise,
state.reputation.score,
state.models.bestDeployedModelScore,
state.models.bestDeployedSafetyScore,
salesHeadcount,
salesEffectiveness,
devEcosystem,
seasonal.multipliers.enterprise,
state.meta.tickCount,
demandCapacityRatio,
);
// --- Aggregate revenue ---
const subscriptionRevenue = consumerResult.subscriptionRevenue
+ productResult.codeAssistantRevenue
+ productResult.agentsPlatformRevenue;
const apiRevenue = apiResult.apiRevenue
+ enterpriseResult.contractRevenue
- enterpriseResult.slaPenalties;
const totalTokenDemand = consumerResult.totalConsumerTokenDemand
+ apiResult.totalApiTokenDemand
+ enterpriseResult.contractTokenDemand
+ productResult.codeAssistantTokenDemand
+ productResult.agentsPlatformTokenDemand;
// --- Subscriber history ---
const subscriberHistory = [...(state.market.subscriberHistory || [])];
if (state.meta.tickCount % 60 === 0) {
subscriberHistory.push({ tick: state.meta.tickCount, subscribers: consumerResult.consumerTiers.totalUsers });
if (subscriberHistory.length > 500) subscriberHistory.shift();
}
// --- Open source effects ---
const openSourceCount = state.market.openSourcedModels.length;
if (openSourceCount > 0) {
const revenueReduction = openSourceCount * 0.10 * 0.3;
const adjustedApiRevenue = apiRevenue * (1 - revenueReduction);
return {
marketState: {
...state.market,
tam,
consumerTiers: consumerResult.consumerTiers,
apiTiers: apiResult.apiTiers,
codeAssistant: productResult.codeAssistant,
agentsPlatform: productResult.agentsPlatform,
enterprise: enterpriseResult.enterprise,
developerEcosystem: devEcosystem,
seasonalPhase: seasonal.phase,
seasonalMultiplier: seasonal.multipliers.consumer,
obsolescence,
subscriberHistory,
},
apiRevenue: Math.max(0, adjustedApiRevenue),
subscriptionRevenue,
totalTokenDemand,
};
}
return {
marketState: {
...state.market,
tam,
consumerTiers: consumerResult.consumerTiers,
apiTiers: apiResult.apiTiers,
codeAssistant: productResult.codeAssistant,
agentsPlatform: productResult.agentsPlatform,
enterprise: enterpriseResult.enterprise,
developerEcosystem: devEcosystem,
seasonalPhase: seasonal.phase,
seasonalMultiplier: seasonal.multipliers.consumer,
obsolescence,
subscriberHistory,
},
apiRevenue: Math.max(0, apiRevenue),
subscriptionRevenue,
totalTokenDemand,
};
}
@@ -0,0 +1,39 @@
import type { ObsolescenceState, Era } from '@ai-tycoon/shared';
import {
OBSOLESCENCE_BASELINE_GROWTH,
OBSOLESCENCE_ERA_ACCELERATOR,
FRESHNESS_DECAY_RATE,
NEW_MODEL_BOOST_TICKS,
} from '@ai-tycoon/shared';
export function updateObsolescence(
obs: ObsolescenceState,
era: Era,
currentTick: number,
): ObsolescenceState {
const accelerator = OBSOLESCENCE_ERA_ACCELERATOR[era];
const newBaseline = obs.marketQualityBaseline + OBSOLESCENCE_BASELINE_GROWTH * accelerator;
const ticksSinceRelease = currentTick - obs.lastModelReleaseTick;
const freshness = obs.lastModelReleaseTick > 0
? Math.max(0, 1 - ticksSinceRelease * FRESHNESS_DECAY_RATE)
: 0;
const boostRemaining = Math.max(0, obs.newModelBoostRemaining - 1);
return {
...obs,
marketQualityBaseline: newBaseline,
playerModelFreshness: freshness,
newModelBoostRemaining: boostRemaining,
};
}
export function onModelDeployed(obs: ObsolescenceState, tick: number): ObsolescenceState {
return {
...obs,
playerModelFreshness: 1.0,
lastModelReleaseTick: tick,
newModelBoostRemaining: NEW_MODEL_BOOST_TICKS,
};
}
@@ -0,0 +1,100 @@
import type { CodeAssistantState, AgentsPlatformState, BenchmarkResult } from '@ai-tycoon/shared';
import {
CODE_ASSISTANT_MIN_CODING_SCORE,
CODE_ASSISTANT_BASE_ADOPTION_RATE,
CODE_ASSISTANT_CHURN_RATE,
AGENTS_PLATFORM_MIN_AGENTS_SCORE,
AGENTS_PLATFORM_BASE_ADOPTION_RATE,
AGENTS_PLATFORM_CHURN_RATE,
} from '@ai-tycoon/shared';
import { BENCHMARKS } from '../../data/benchmarks';
function getBenchmarkScore(benchmarkId: string, results: BenchmarkResult[]): number {
let best = 0;
for (const r of results) {
if (r.benchmarkId === benchmarkId && r.score > best) best = r.score;
}
return best;
}
function getCodingScore(results: BenchmarkResult[]): number {
const codeBench = BENCHMARKS.find(b => b.id === 'codeforce');
if (!codeBench) return 0;
return getBenchmarkScore(codeBench.id, results);
}
function getAgentsScore(results: BenchmarkResult[]): number {
const agentBench = BENCHMARKS.find(b => b.id === 'agentarena');
if (!agentBench) return 0;
return getBenchmarkScore(agentBench.id, results);
}
export interface ProductLineResult {
codeAssistant: CodeAssistantState;
agentsPlatform: AgentsPlatformState;
codeAssistantRevenue: number;
agentsPlatformRevenue: number;
codeAssistantTokenDemand: number;
agentsPlatformTokenDemand: number;
}
export function processProductLines(
ca: CodeAssistantState,
ap: AgentsPlatformState,
benchmarkResults: BenchmarkResult[],
playerDevCustomers: number,
playerEntCustomers: number,
seasonalConsumerMult: number,
seasonalEntMult: number,
): ProductLineResult {
const updatedCA = { ...ca };
const updatedAP = { ...ap };
let caRevenue = 0;
let apRevenue = 0;
// --- Code Assistant ---
updatedCA.qualityScore = getCodingScore(benchmarkResults);
if (updatedCA.isUnlocked && updatedCA.isActive && updatedCA.qualityScore >= CODE_ASSISTANT_MIN_CODING_SCORE) {
const qualityFactor = updatedCA.qualityScore / 100;
const priceAttr = Math.max(0.1, 1 - updatedCA.pricePerSeat / 50);
const targetSeats = playerDevCustomers * 0.05 * qualityFactor;
const growth = CODE_ASSISTANT_BASE_ADOPTION_RATE * qualityFactor * priceAttr * seasonalConsumerMult;
const churn = CODE_ASSISTANT_CHURN_RATE * (1 + (1 - qualityFactor) * 2);
updatedCA.seats = Math.max(0, updatedCA.seats + updatedCA.seats * growth - updatedCA.seats * churn);
if (updatedCA.seats < 10 && targetSeats > 10) {
updatedCA.seats += targetSeats * 0.01;
}
updatedCA.satisfaction = Math.min(1, 0.3 + qualityFactor * 0.5);
caRevenue = updatedCA.seats * (updatedCA.pricePerSeat / 86400);
}
// --- Agents Platform ---
updatedAP.qualityScore = getAgentsScore(benchmarkResults);
if (updatedAP.isUnlocked && updatedAP.isActive && updatedAP.qualityScore >= AGENTS_PLATFORM_MIN_AGENTS_SCORE) {
const qualityFactor = updatedAP.qualityScore / 100;
const priceAttr = Math.max(0.1, 1 - updatedAP.pricePerSeat / 250);
const targetSeats = playerEntCustomers * 0.02 * qualityFactor;
const growth = AGENTS_PLATFORM_BASE_ADOPTION_RATE * qualityFactor * priceAttr * seasonalEntMult;
const churn = AGENTS_PLATFORM_CHURN_RATE * (1 + (1 - qualityFactor) * 2);
updatedAP.seats = Math.max(0, updatedAP.seats + updatedAP.seats * growth - updatedAP.seats * churn);
if (updatedAP.seats < 5 && targetSeats > 5) {
updatedAP.seats += targetSeats * 0.01;
}
updatedAP.satisfaction = Math.min(1, 0.3 + qualityFactor * 0.5);
apRevenue = updatedAP.seats * (updatedAP.pricePerSeat / 86400);
}
const caTokenDemand = updatedCA.seats * 2;
const apTokenDemand = updatedAP.seats * 10;
return {
codeAssistant: updatedCA,
agentsPlatform: updatedAP,
codeAssistantRevenue: caRevenue,
agentsPlatformRevenue: apRevenue,
codeAssistantTokenDemand: caTokenDemand,
agentsPlatformTokenDemand: apTokenDemand,
};
}
@@ -0,0 +1,25 @@
import type { SeasonalPhase } from '@ai-tycoon/shared';
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@ai-tycoon/shared';
export interface SeasonalResult {
phase: SeasonalPhase;
multipliers: { consumer: number; api: number; enterprise: number };
}
const PHASES: SeasonalPhase[] = ['q1', 'q2', 'q3', 'q4'];
export function computeSeasonal(tickCount: number): SeasonalResult {
const positionInCycle = tickCount % SEASONAL_CYCLE_TICKS;
const quarterLength = SEASONAL_CYCLE_TICKS / 4;
const phaseIndex = Math.min(3, Math.floor(positionInCycle / quarterLength));
const phase = PHASES[phaseIndex];
const raw = SEASONAL_MULTIPLIERS[phase];
return {
phase,
multipliers: {
consumer: raw.consumer,
api: raw.api,
enterprise: raw.enterprise,
},
};
}
@@ -0,0 +1,190 @@
import type {
TotalAddressableMarket,
TAMSegmentId,
MarketShareEntry,
Competitor,
Era,
ObsolescenceState,
DeveloperEcosystem,
} from '@ai-tycoon/shared';
import {
TAM_BASE_SIZES,
TAM_GROWTH_PER_TICK,
SHARE_TEMPERATURE,
SHARE_MIGRATION_SPEED,
ATTRACTIVENESS_WEIGHTS,
OBSOLESCENCE_PENALTY_WEIGHT,
NEW_MODEL_BOOST_VALUE,
} from '@ai-tycoon/shared';
export interface ParticipantProfile {
id: string;
qualityScore: number;
priceScore: number;
reputation: number;
ecosystemScore: number;
freshness: number;
hasFreeTier: boolean;
}
export function buildPlayerProfile(
qualityScore: number,
chatPrice: number,
apiOutputPrice: number,
reputation: number,
ecosystem: DeveloperEcosystem,
obsolescence: ObsolescenceState,
hasFreeTier: boolean,
): ParticipantProfile {
const avgPrice = (chatPrice + apiOutputPrice) / 2;
const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100));
let freshness = obsolescence.playerModelFreshness;
if (obsolescence.newModelBoostRemaining > 0) {
freshness = Math.min(1, freshness + NEW_MODEL_BOOST_VALUE);
}
return {
id: 'player',
qualityScore: Math.min(1, qualityScore),
priceScore,
reputation,
ecosystemScore: ecosystem.ecosystemScore,
freshness,
hasFreeTier,
};
}
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));
return {
id: c.id,
qualityScore: Math.min(1, c.estimatedCapability / 100),
priceScore,
reputation: c.reputation,
ecosystemScore: c.developerEcosystemScore,
freshness: c.modelFreshness,
hasFreeTier: c.products.hasFreeTier,
};
}
function computeAttractiveness(
p: ParticipantProfile,
segment: TAMSegmentId,
qualityBaseline: number,
): number {
const w = ATTRACTIVENESS_WEIGHTS[segment];
let score =
w.quality * p.qualityScore +
w.price * p.priceScore +
w.reputation * (p.reputation / 100) +
w.ecosystem * (p.ecosystemScore / 100) +
w.freshness * p.freshness +
w.freeTier * (p.hasFreeTier ? 1 : 0);
const qualityGap = qualityBaseline / 100 - p.qualityScore;
if (qualityGap > 0) {
score -= qualityGap * OBSOLESCENCE_PENALTY_WEIGHT;
}
return Math.max(0.01, score);
}
function softmaxShares(scores: number[]): number[] {
const maxScore = Math.max(...scores);
const exps = scores.map(s => Math.exp((s - maxScore) * SHARE_TEMPERATURE));
const sumExp = exps.reduce((a, b) => a + b, 0);
return exps.map(e => e / sumExp);
}
export function computeMarketShares(
tam: TotalAddressableMarket,
participants: ParticipantProfile[],
qualityBaseline: number,
): TotalAddressableMarket {
const segments = { ...tam.segments };
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
for (const segId of segmentIds) {
const seg = segments[segId];
const scores = participants.map(p => computeAttractiveness(p, segId, qualityBaseline));
const targetShares = softmaxShares(scores);
const oldShareMap = new Map<string, MarketShareEntry>();
for (const entry of seg.shares) {
oldShareMap.set(entry.playerId, entry);
}
const newShares: MarketShareEntry[] = participants.map((p, i) => {
const old = oldShareMap.get(p.id);
const oldShare = old?.sharePercent ?? 0;
const migratedShare = oldShare + (targetShares[i] - oldShare) * SHARE_MIGRATION_SPEED;
return {
playerId: p.id,
sharePercent: migratedShare,
customers: Math.floor(migratedShare * seg.totalSize),
attractivenessScore: scores[i],
};
});
const totalShare = newShares.reduce((s, e) => s + e.sharePercent, 0);
if (totalShare > 0) {
for (const entry of newShares) {
entry.sharePercent /= totalShare;
entry.customers = Math.floor(entry.sharePercent * seg.totalSize);
}
}
segments[segId] = { ...seg, shares: newShares };
}
return { segments };
}
export function updateTAMGrowth(tam: TotalAddressableMarket, era: Era): TotalAddressableMarket {
const baseSizes = TAM_BASE_SIZES[era];
const segments = { ...tam.segments };
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
for (const segId of segmentIds) {
const seg = segments[segId];
const base = baseSizes[segId];
const grown = seg.totalSize + seg.totalSize * TAM_GROWTH_PER_TICK;
segments[segId] = {
...seg,
totalSize: Math.max(base, grown),
};
}
return { segments };
}
export function initializeTAM(era: Era, competitors: Competitor[]): TotalAddressableMarket {
const baseSizes = TAM_BASE_SIZES[era];
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
const segments = {} as Record<TAMSegmentId, { totalSize: number; shares: MarketShareEntry[] }>;
for (const segId of segmentIds) {
const shares: MarketShareEntry[] = [
{ playerId: 'player', sharePercent: 0.05, customers: 0, attractivenessScore: 0 },
...competitors.map(c => ({
playerId: c.id,
sharePercent: c.marketShares[segId] ?? 0.1,
customers: 0,
attractivenessScore: 0,
})),
];
const totalShare = shares.reduce((s, e) => s + e.sharePercent, 0);
for (const entry of shares) {
entry.sharePercent /= totalShare;
entry.customers = Math.floor(entry.sharePercent * baseSizes[segId]);
}
segments[segId] = { totalSize: baseSizes[segId], shares };
}
return { segments };
}