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:
@@ -1,11 +1,6 @@
|
||||
import type { Competitor } from '@ai-tycoon/shared';
|
||||
|
||||
/**
|
||||
* Initial rival AI companies that compete with the player from the start.
|
||||
* Names are fictional parodies -- any resemblance to real companies is purely satirical.
|
||||
*/
|
||||
export const INITIAL_RIVALS: Competitor[] = [
|
||||
// ── Safety-first lab (Anthropic parody) ──────────────────────────────
|
||||
{
|
||||
id: 'competitor_prometheus',
|
||||
name: 'Prometheus AI',
|
||||
@@ -26,9 +21,23 @@ export const INITIAL_RIVALS: Competitor[] = [
|
||||
latestModelName: 'Aegis-1',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
products: {
|
||||
hasFreeTier: true,
|
||||
chatPrice: 25,
|
||||
apiInputPrice: 1.5,
|
||||
apiOutputPrice: 4.0,
|
||||
hasCodeAssistant: false,
|
||||
codeAssistantPrice: 0,
|
||||
hasAgentsPlatform: false,
|
||||
agentsPlatformPrice: 0,
|
||||
},
|
||||
pricingStrategy: { aggressiveness: 0.2, premiumPositioning: 0.7 },
|
||||
modelFreshness: 0.8,
|
||||
lastModelReleaseTick: 0,
|
||||
developerEcosystemScore: 25,
|
||||
marketShares: { consumer: 0.15, developer: 0.20, enterprise: 0.10, government: 0.05 },
|
||||
},
|
||||
|
||||
// ── Move-fast startup (xAI / Musk parody) ────────────────────────────
|
||||
{
|
||||
id: 'competitor_nexus',
|
||||
name: 'Nexus Labs',
|
||||
@@ -49,9 +58,23 @@ export const INITIAL_RIVALS: Competitor[] = [
|
||||
latestModelName: 'Blitz-0.9',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
products: {
|
||||
hasFreeTier: true,
|
||||
chatPrice: 15,
|
||||
apiInputPrice: 0.8,
|
||||
apiOutputPrice: 2.0,
|
||||
hasCodeAssistant: false,
|
||||
codeAssistantPrice: 0,
|
||||
hasAgentsPlatform: false,
|
||||
agentsPlatformPrice: 0,
|
||||
},
|
||||
pricingStrategy: { aggressiveness: 0.8, premiumPositioning: 0.2 },
|
||||
modelFreshness: 0.7,
|
||||
lastModelReleaseTick: 0,
|
||||
developerEcosystemScore: 30,
|
||||
marketShares: { consumer: 0.20, developer: 0.25, enterprise: 0.05, government: 0.02 },
|
||||
},
|
||||
|
||||
// ── Big-tech giant (Google parody) ────────────────────────────────────
|
||||
{
|
||||
id: 'competitor_titan',
|
||||
name: 'Titan Computing',
|
||||
@@ -72,5 +95,20 @@ export const INITIAL_RIVALS: Competitor[] = [
|
||||
latestModelName: 'Colossus 2.0',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
products: {
|
||||
hasFreeTier: true,
|
||||
chatPrice: 20,
|
||||
apiInputPrice: 1.0,
|
||||
apiOutputPrice: 3.0,
|
||||
hasCodeAssistant: false,
|
||||
codeAssistantPrice: 0,
|
||||
hasAgentsPlatform: false,
|
||||
agentsPlatformPrice: 0,
|
||||
},
|
||||
pricingStrategy: { aggressiveness: 0.5, premiumPositioning: 0.5 },
|
||||
modelFreshness: 0.9,
|
||||
lastModelReleaseTick: 0,
|
||||
developerEcosystemScore: 45,
|
||||
marketShares: { consumer: 0.35, developer: 0.30, enterprise: 0.40, government: 0.50 },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EnterpriseSegment } from '@ai-tycoon/shared';
|
||||
|
||||
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
|
||||
startup: [
|
||||
'PixelForge', 'NovaByte', 'CloudSpark', 'DataLeap', 'VectorVault',
|
||||
'SynthWave AI', 'CodePilot', 'BrightLoop', 'Innova Labs', 'ScaleKit',
|
||||
'DeepRoot', 'FluxPoint', 'MindBridge', 'HyperNode', 'NeuralSeed',
|
||||
'LatticeAI', 'StreamForge', 'QuantumLeaf', 'VeloCity AI', 'ArcStack',
|
||||
],
|
||||
'mid-market': [
|
||||
'Meridian Health', 'Cascade Logistics', 'PrimeRetail Group', 'Atlas Financial',
|
||||
'TerraForm Solutions', 'NorthStar Analytics', 'BridgePoint Media', 'Keystone Legal',
|
||||
'Orion Pharmaceuticals', 'Summit Education', 'ClearView Insurance', 'Horizon Dynamics',
|
||||
'PeakWare Systems', 'CorePath Consulting', 'BlueStar Manufacturing', 'Vertex Commerce',
|
||||
'RiverStone Banking', 'Zenith Telecom', 'IronGate Security', 'ElmBridge Tech',
|
||||
],
|
||||
enterprise: [
|
||||
'GlobalBank Corp', 'TransContinental Airlines', 'MegaRetail Holdings', 'United Telecom',
|
||||
'Pinnacle Pharmaceuticals', 'Imperial Energy', 'Vanguard Insurance', 'Sterling Automotive',
|
||||
'Apex Media Group', 'Continental Healthcare', 'Sovereign Financial', 'Atlas Industries',
|
||||
'Dominion Logistics', 'Pacific Semiconductor', 'NorthAm Rail Systems', 'OceanGate Maritime',
|
||||
'Crown Broadcasting', 'Titan Manufacturing', 'Citadel Consulting', 'Granite Resources',
|
||||
],
|
||||
government: [
|
||||
'Dept. of National Intelligence', 'Federal Research Authority', 'National Health Service AI',
|
||||
'Defense Advanced Projects', 'Central Tax Administration', 'State Education Bureau',
|
||||
'Environmental Protection AI', 'National Cybersecurity Center', 'Federal Aviation Systems',
|
||||
'Dept. of Energy Computing', 'National Weather Service', 'Census Analytics Division',
|
||||
'Judicial Analytics Office', 'Immigration Processing AI', 'Veterans Affairs Digital',
|
||||
],
|
||||
};
|
||||
@@ -379,6 +379,60 @@ export const TECH_TREE: ResearchNode[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// === MARKET / PRODUCTS ===
|
||||
{
|
||||
id: 'code-assistant-product',
|
||||
name: 'Code Assistant Product',
|
||||
description: 'Launch an AI code assistant product for developers. Requires Code Generation research.',
|
||||
era: 'scaleup',
|
||||
category: 'specialization',
|
||||
branch: 'coding',
|
||||
prerequisites: ['code-generation'],
|
||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
||||
effects: [{ type: 'unlock_product_line', target: 'code-assistant', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'developer-relations',
|
||||
name: 'Developer Relations',
|
||||
description: 'Invest in developer community building. Unlocks dev-rel budget allocation and boosts API adoption.',
|
||||
era: 'startup',
|
||||
category: 'efficiency',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 3, ticks: 45 },
|
||||
effects: [{ type: 'unlock_feature', target: 'developer-relations', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'enterprise-sales',
|
||||
name: 'Enterprise Sales',
|
||||
description: 'Build a formal enterprise sales pipeline. Unlocks enterprise lead generation and contract management.',
|
||||
era: 'startup',
|
||||
category: 'efficiency',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 3, ticks: 45 },
|
||||
effects: [{ type: 'unlock_feature', target: 'enterprise-sales', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'sdk-platform',
|
||||
name: 'SDK Platform',
|
||||
description: 'Comprehensive SDK and tooling platform. Significantly boosts developer ecosystem growth.',
|
||||
era: 'scaleup',
|
||||
category: 'efficiency',
|
||||
prerequisites: ['developer-relations'],
|
||||
cost: { researchPoints: 2, compute: 15, ticks: 120 },
|
||||
effects: [{ type: 'efficiency_boost', target: 'sdk_coverage', value: 0.3 }],
|
||||
},
|
||||
{
|
||||
id: 'agents-platform-product',
|
||||
name: 'Agents Platform Product',
|
||||
description: 'Launch an enterprise AI agents platform. Requires Agentic Architecture research.',
|
||||
era: 'bigtech',
|
||||
category: 'specialization',
|
||||
branch: 'agents',
|
||||
prerequisites: ['agentic-architecture'],
|
||||
cost: { researchPoints: 4, compute: 60, ticks: 300 },
|
||||
effects: [{ type: 'unlock_product_line', target: 'agents-platform', value: 1 }],
|
||||
},
|
||||
|
||||
// === DATA ===
|
||||
{
|
||||
id: 'data-pipeline',
|
||||
|
||||
@@ -1,44 +1,99 @@
|
||||
import type { GameState, CompetitorState } from '@ai-tycoon/shared';
|
||||
import type { GameState, CompetitorState, Competitor } from '@ai-tycoon/shared';
|
||||
import {
|
||||
COMPETITOR_PRODUCT_THRESHOLDS,
|
||||
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
|
||||
COMPETITOR_CATCHUP_PRICE_CUT,
|
||||
FRESHNESS_DECAY_RATE,
|
||||
} from '@ai-tycoon/shared';
|
||||
|
||||
function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
|
||||
const cap = rival.estimatedCapability;
|
||||
const p = rival.products;
|
||||
const pricing = rival.pricingStrategy;
|
||||
|
||||
const baseChatPrice = 20 * (1 + pricing.premiumPositioning * 0.5 - pricing.aggressiveness * 0.3);
|
||||
const baseApiOut = 3.0 * (1 + pricing.premiumPositioning * 0.3 - pricing.aggressiveness * 0.4);
|
||||
|
||||
return {
|
||||
hasFreeTier: cap >= COMPETITOR_PRODUCT_THRESHOLDS.freeTierAndChat,
|
||||
chatPrice: cap >= COMPETITOR_PRODUCT_THRESHOLDS.freeTierAndChat
|
||||
? Math.max(5, baseChatPrice) : p.chatPrice,
|
||||
apiInputPrice: cap >= COMPETITOR_PRODUCT_THRESHOLDS.apiAndCodeAssistant
|
||||
? Math.max(0.2, baseApiOut * 0.33) : p.apiInputPrice,
|
||||
apiOutputPrice: cap >= COMPETITOR_PRODUCT_THRESHOLDS.apiAndCodeAssistant
|
||||
? Math.max(0.5, baseApiOut) : p.apiOutputPrice,
|
||||
hasCodeAssistant: cap >= COMPETITOR_PRODUCT_THRESHOLDS.apiAndCodeAssistant,
|
||||
codeAssistantPrice: cap >= COMPETITOR_PRODUCT_THRESHOLDS.apiAndCodeAssistant
|
||||
? Math.max(10, 20 * (1 - pricing.aggressiveness * 0.3)) : 0,
|
||||
hasAgentsPlatform: cap >= COMPETITOR_PRODUCT_THRESHOLDS.agentsPlatform,
|
||||
agentsPlatformPrice: cap >= COMPETITOR_PRODUCT_THRESHOLDS.agentsPlatform
|
||||
? Math.max(50, 100 * (1 - pricing.aggressiveness * 0.2)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function processCompetitors(state: GameState): CompetitorState {
|
||||
const tick = state.meta.tickCount;
|
||||
const rivals = state.competitors.rivals.map(rival => {
|
||||
if (rival.status !== 'active') return rival;
|
||||
|
||||
if (tick < rival.nextMilestoneAtTick) return rival;
|
||||
const updated = { ...rival };
|
||||
|
||||
// Freshness decay each tick
|
||||
updated.modelFreshness = Math.max(0, updated.modelFreshness - FRESHNESS_DECAY_RATE);
|
||||
|
||||
// Developer ecosystem growth based on personality
|
||||
const ecoGrowth = rival.personality.openSourceTendency * 0.1 + rival.personality.marketingFocus * 0.05;
|
||||
updated.developerEcosystemScore = Math.min(100,
|
||||
updated.developerEcosystemScore + ecoGrowth * 0.01,
|
||||
);
|
||||
|
||||
// Catch-up: if any market share < threshold, cut prices
|
||||
const minShare = Math.min(...Object.values(updated.marketShares));
|
||||
if (minShare < COMPETITOR_CATCHUP_SHARE_THRESHOLD) {
|
||||
updated.pricingStrategy = {
|
||||
...updated.pricingStrategy,
|
||||
aggressiveness: Math.min(1, updated.pricingStrategy.aggressiveness + COMPETITOR_CATCHUP_PRICE_CUT * 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
if (tick < rival.nextMilestoneAtTick) {
|
||||
updated.products = updateCompetitorProducts(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Milestone reached — capability jump + model release
|
||||
const { personality } = rival;
|
||||
const capGrowth = (2 + personality.researchFocus * 5 + personality.riskTolerance * 3) *
|
||||
(1 + tick * 0.00005);
|
||||
const revenueGrowth = rival.estimatedRevenue * (0.02 + personality.marketingFocus * 0.03);
|
||||
const userGrowth = rival.estimatedUsers * (0.01 + personality.marketingFocus * 0.02);
|
||||
|
||||
const newCapability = Math.min(95, rival.estimatedCapability + capGrowth);
|
||||
const newRevenue = rival.estimatedRevenue + revenueGrowth + 50;
|
||||
const newUsers = rival.estimatedUsers + userGrowth + 100;
|
||||
updated.estimatedCapability = Math.min(95, rival.estimatedCapability + capGrowth);
|
||||
updated.estimatedRevenue = rival.estimatedRevenue + revenueGrowth + 50;
|
||||
updated.estimatedUsers = Math.floor(rival.estimatedUsers + userGrowth + 100);
|
||||
|
||||
const repChange = personality.safetyFocus > 0.6
|
||||
? 1
|
||||
: personality.riskTolerance > 0.7 ? -1 : 0;
|
||||
updated.reputation = Math.min(100, Math.max(0, rival.reputation + repChange));
|
||||
|
||||
const modelNames = [
|
||||
'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon',
|
||||
'Nova', 'Quantum', 'Nexus', 'Apex', 'Zenith',
|
||||
];
|
||||
const modelIdx = Math.floor(newCapability / 10);
|
||||
const latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
|
||||
const modelIdx = Math.floor(updated.estimatedCapability / 10);
|
||||
updated.latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
|
||||
|
||||
// Model release resets freshness
|
||||
updated.modelFreshness = 1.0;
|
||||
updated.lastModelReleaseTick = tick;
|
||||
|
||||
updated.products = updateCompetitorProducts(updated);
|
||||
|
||||
const milestoneInterval = 200 + Math.floor(Math.random() * 200);
|
||||
updated.nextMilestoneAtTick = tick + milestoneInterval;
|
||||
|
||||
return {
|
||||
...rival,
|
||||
estimatedCapability: newCapability,
|
||||
estimatedRevenue: newRevenue,
|
||||
estimatedUsers: Math.floor(newUsers),
|
||||
reputation: Math.min(100, Math.max(0, rival.reputation + repChange)),
|
||||
latestModelName,
|
||||
nextMilestoneAtTick: tick + milestoneInterval,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
|
||||
const allCaps = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GameState, DataState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processData(state: GameState): DataState {
|
||||
const subscribers = state.market.consumers.totalSubscribers;
|
||||
const subscribers = state.market.consumerTiers.totalUsers;
|
||||
const userDataRate = subscribers * 0.5;
|
||||
|
||||
const partnershipTokens = state.data.partnerships.reduce((sum, p) => sum + p.tokensPerTick, 0);
|
||||
|
||||
@@ -26,7 +26,8 @@ export function processEconomy(
|
||||
const eraIdx = ['startup', 'scaleup', 'bigtech', 'agi'].indexOf(state.meta.currentEra);
|
||||
const complianceCost = bestCapability > 30 ? bestCapability * REGULATION_COMPLIANCE_PER_CAPABILITY * (1 + eraIdx * 0.5) / 100 : 0;
|
||||
|
||||
const expenses = infraExpenses + talentExpenses + dataExpenses + complianceCost + extraCosts;
|
||||
const devRelExpenses = state.market.developerEcosystem.devRelSpending;
|
||||
const expenses = infraExpenses + talentExpenses + dataExpenses + complianceCost + devRelExpenses + extraCosts;
|
||||
|
||||
const money = state.economy.money + revenue - expenses;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function canRaiseFunding(state: GameState): { canRaise: boolean; nextRoun
|
||||
if (reqs.minRevenue && state.economy.totalRevenue < reqs.minRevenue) {
|
||||
return { canRaise: false, nextRound, reason: `Need $${reqs.minRevenue.toLocaleString()} total revenue` };
|
||||
}
|
||||
if (reqs.minUsers && state.market.consumers.totalSubscribers < reqs.minUsers) {
|
||||
if (reqs.minUsers && state.market.consumerTiers.totalUsers < reqs.minUsers) {
|
||||
return { canRaise: false, nextRound, reason: `Need ${reqs.minUsers.toLocaleString()} subscribers` };
|
||||
}
|
||||
if (reqs.minReputation && state.reputation.score < reqs.minReputation) {
|
||||
@@ -34,7 +34,7 @@ export function canRaiseFunding(state: GameState): { canRaise: boolean; nextRoun
|
||||
|
||||
export function computeValuation(state: GameState): number {
|
||||
const revenueMultiple = state.economy.revenuePerTick * 86400 * 365;
|
||||
const subscriberValue = state.market.consumers.totalSubscribers * 500;
|
||||
const subscriberValue = state.market.consumerTiers.totalUsers * 500;
|
||||
const capabilityValue = Math.pow(state.models.bestDeployedModelScore, 2) * 1000;
|
||||
return Math.max(100_000, revenueMultiple * 10 + subscriberValue + capabilityValue);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,194 +1,8 @@
|
||||
import type { GameState, MarketState, BenchmarkResult } from '@ai-tycoon/shared';
|
||||
import {
|
||||
CONSUMER_BASE_GROWTH,
|
||||
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
|
||||
CONSUMER_BASE_CHURN,
|
||||
CONSUMER_TOKENS_PER_SUBSCRIBER,
|
||||
API_TOKENS_PER_REQUEST,
|
||||
OPEN_SOURCE_REVENUE_PENALTY,
|
||||
OPEN_SOURCE_TALENT_ATTRACTION,
|
||||
MARKET_SIZE_CAP,
|
||||
NETWORK_DEGRADATION,
|
||||
MARKET_CAP_QUALITY_BONUS,
|
||||
MARKET_CAP_REPUTATION_BONUS,
|
||||
OVERLOAD_PENALTY_EXPONENT,
|
||||
} from '@ai-tycoon/shared';
|
||||
import { BENCHMARKS } from '../data/benchmarks';
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { processMarketV2 } from './market/index';
|
||||
|
||||
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 processMarket(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;
|
||||
const chatProduct = state.models.productLines.find(p => p.type === 'chat-product');
|
||||
const textApi = state.models.productLines.find(p => p.type === 'text-api');
|
||||
|
||||
// --- Consumer market (subscription product) ---
|
||||
const consumers = { ...state.market.consumers };
|
||||
let subscriptionRevenue = 0;
|
||||
|
||||
if (chatProduct?.isActive && modelQuality > 0) {
|
||||
const price = chatProduct.pricing.subscriptionPrice;
|
||||
const fairPrice = 20 + modelQuality * 80;
|
||||
const priceRatio = price / Math.max(1, fairPrice);
|
||||
const priceAttractiveness = Math.max(0, Math.min(1, 1 - (priceRatio - 1) * 0.8));
|
||||
|
||||
// --- Logistic growth with era-based market cap ---
|
||||
const eraCapBase = MARKET_SIZE_CAP[state.meta.currentEra] ?? 100_000_000;
|
||||
const effectiveCap = eraCapBase
|
||||
* (1 + modelQuality * MARKET_CAP_QUALITY_BONUS)
|
||||
* (1 + (state.reputation.score / 100) * MARKET_CAP_REPUTATION_BONUS);
|
||||
|
||||
const saturationFactor = Math.max(0, 1 - consumers.totalSubscribers / effectiveCap);
|
||||
|
||||
const growthRate = (CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER)
|
||||
* priceAttractiveness * saturationFactor;
|
||||
const priceChurnMultiplier = priceRatio > 1 ? 1 + (priceRatio - 1) * 3 : 1;
|
||||
const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction) * 2) * priceChurnMultiplier;
|
||||
|
||||
consumers.growthRatePerTick = growthRate;
|
||||
consumers.churnRatePerTick = churnRate;
|
||||
|
||||
const newSubs = consumers.totalSubscribers * growthRate;
|
||||
const lostSubs = consumers.totalSubscribers * churnRate;
|
||||
consumers.totalSubscribers = Math.max(0, Math.min(
|
||||
effectiveCap,
|
||||
consumers.totalSubscribers + newSubs - lostSubs,
|
||||
));
|
||||
|
||||
if (consumers.totalSubscribers < 100 && modelQuality > 0.1 && priceRatio < 3) {
|
||||
consumers.totalSubscribers += 5 + modelQuality * 20;
|
||||
}
|
||||
|
||||
// --- Satisfaction from demand/capacity ratio (current tick) ---
|
||||
const consumerDemand = consumers.totalSubscribers * CONSUMER_TOKENS_PER_SUBSCRIBER;
|
||||
let demandCapacityRatio: number;
|
||||
if (currentTickCapacity > 0) {
|
||||
demandCapacityRatio = consumerDemand / currentTickCapacity;
|
||||
} else {
|
||||
demandCapacityRatio = consumerDemand > 0 ? 10 : 0;
|
||||
}
|
||||
|
||||
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 networkLatencyPenalty = state.infrastructure.networkLatencyPenalty *
|
||||
NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
|
||||
consumers.satisfaction = Math.min(1, Math.max(0,
|
||||
0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty - networkLatencyPenalty,
|
||||
));
|
||||
|
||||
consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
||||
|
||||
subscriptionRevenue = consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 86400);
|
||||
|
||||
// --- Overload policy ---
|
||||
const policy = state.market.overloadPolicy;
|
||||
if (policy.degradeQualityUnderLoad && demandCapacityRatio > 0.85) {
|
||||
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.02);
|
||||
}
|
||||
if (policy.prioritizeEnterprise && demandCapacityRatio > 0.9) {
|
||||
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
// --- B2B API market ---
|
||||
const enterprise = { ...state.market.enterprise };
|
||||
let apiRevenue = 0;
|
||||
let organicApiTokens = 0;
|
||||
|
||||
if (textApi?.isActive && modelQuality > 0) {
|
||||
const reputationFactor = state.reputation.score / 100;
|
||||
const qualityFactor = modelQuality;
|
||||
const priceFactor = Math.max(0.1, 1 - (textApi.pricing.outputTokenPrice / 20));
|
||||
|
||||
organicApiTokens = Math.floor(
|
||||
qualityFactor * reputationFactor * priceFactor * 500 * (1 + state.meta.tickCount * 0.0001),
|
||||
);
|
||||
|
||||
let contractTokens = 0;
|
||||
for (const contract of enterprise.activeContracts) {
|
||||
contractTokens += contract.tokensPerTick;
|
||||
apiRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken;
|
||||
}
|
||||
|
||||
apiRevenue += (organicApiTokens / 1_000_000) * textApi.pricing.outputTokenPrice;
|
||||
enterprise.totalApiCallsPerTick = (organicApiTokens + contractTokens) / API_TOKENS_PER_REQUEST;
|
||||
}
|
||||
|
||||
const totalTokenDemand = organicApiTokens +
|
||||
consumers.totalSubscribers * CONSUMER_TOKENS_PER_SUBSCRIBER +
|
||||
enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0);
|
||||
|
||||
// --- Open source effects ---
|
||||
const openSourceCount = state.market.openSourcedModels.length;
|
||||
if (openSourceCount > 0) {
|
||||
const growthBoost = 1 + openSourceCount * OPEN_SOURCE_TALENT_ATTRACTION;
|
||||
consumers.totalSubscribers *= growthBoost > 1 ? 1 + (growthBoost - 1) * 0.01 : 1;
|
||||
apiRevenue *= 1 - openSourceCount * OPEN_SOURCE_REVENUE_PENALTY * 0.3;
|
||||
|
||||
const eraCapBase = MARKET_SIZE_CAP[state.meta.currentEra] ?? 100_000_000;
|
||||
const effectiveCap = eraCapBase
|
||||
* (1 + modelQuality * MARKET_CAP_QUALITY_BONUS)
|
||||
* (1 + (state.reputation.score / 100) * MARKET_CAP_REPUTATION_BONUS);
|
||||
consumers.totalSubscribers = Math.min(effectiveCap, consumers.totalSubscribers);
|
||||
}
|
||||
|
||||
const subscriberHistory = [...(state.market.subscriberHistory || [])];
|
||||
if (state.meta.tickCount % 60 === 0) {
|
||||
subscriberHistory.push({ tick: state.meta.tickCount, subscribers: consumers.totalSubscribers });
|
||||
if (subscriberHistory.length > 500) subscriberHistory.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
marketState: {
|
||||
...state.market,
|
||||
consumers,
|
||||
enterprise,
|
||||
subscriberHistory,
|
||||
},
|
||||
apiRevenue: Math.max(0, apiRevenue),
|
||||
subscriptionRevenue,
|
||||
totalTokenDemand,
|
||||
};
|
||||
export type { MarketTickResult } from './market/index';
|
||||
|
||||
export function processMarket(state: GameState, currentTickCapacity: number) {
|
||||
return processMarketV2(state, currentTickCapacity);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user