Overhaul market system with shared TAM competition, multi-tier pricing, enterprise pipeline, and developer ecosystem
CI / build-and-push (push) Successful in 42s
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user