102e05c8ba
Adds a full simulation harness (game-simulation package) with greedy/random strategies, 36-metric diagnostics, multi-run orchestration via child processes, and a statistical interpreter. Includes 2.3x engine performance optimizations (research bonus caching, per-DC dirty tracking, reduced allocations in tick pipeline, single-pass loops). Fixes a critical balance bug where training pipelines stalled on insufficient VRAM would permanently block training slots — the engine never re-checked stalled pipelines, and the greedy strategy didn't pre-check VRAM requirements. This caused 20-25% of seeds to get stuck in Scale-up era. All three fixes (engine un-stalling, strategy VRAM pre-check, stalled pipeline cancellation) bring pass rate from 75% to 100% across 20 random seeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
import type {
|
|
EnterpriseState,
|
|
EnterpriseLead,
|
|
EnterpriseContract,
|
|
EnterpriseSegment,
|
|
EnterprisePipelineStage,
|
|
DeveloperEcosystem,
|
|
TierServingMetrics,
|
|
} 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,
|
|
ENTERPRISE_REJECTION_SLA_MULTIPLIER,
|
|
} 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,
|
|
enterpriseServingMetrics: TierServingMetrics,
|
|
): EnterprisePipelineResult {
|
|
const pipeline = [...ent.pipeline];
|
|
const activeContracts = [...ent.activeContracts];
|
|
|
|
const effectiveSales = salesHeadcount > 0
|
|
? Math.min(2, salesHeadcount * salesEffectiveness * 0.2)
|
|
: 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') {
|
|
const capRatio = Math.min(2, modelCapability / Math.max(1, lead.requiredCapability));
|
|
transitionProb *= capRatio > 1 ? capRatio : capRatio * 0.3;
|
|
} else if (lead.stage === 'poc') {
|
|
const entDemand = enterpriseServingMetrics.demandTokens;
|
|
const entRejected = enterpriseServingMetrics.rejectedTokens;
|
|
const rejectRate = entDemand > 0 ? entRejected / entDemand : 0;
|
|
transitionProb *= Math.max(0.2, 1 - rejectRate * 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++;
|
|
|
|
const entDemand = enterpriseServingMetrics.demandTokens;
|
|
const entServed = enterpriseServingMetrics.servedTokens;
|
|
const entRejected = enterpriseServingMetrics.rejectedTokens;
|
|
const servedFraction = entDemand > 0 ? entServed / entDemand : 1;
|
|
const wasRejected = entRejected > 0;
|
|
const qualityMet = enterpriseServingMetrics.avgQualityDelivered >= 0.85;
|
|
|
|
if (servedFraction >= updated.slaUptime && qualityMet && !wasRejected) {
|
|
updated.uptimeTicks++;
|
|
} else {
|
|
updated.slaViolations++;
|
|
const severityMultiplier = wasRejected ? ENTERPRISE_REJECTION_SLA_MULTIPLIER : 1.0;
|
|
const penalty = updated.pricePerMToken * (updated.tokensPerTick / 1_000_000) * SLA_PENALTY_FRACTION * severityMultiplier;
|
|
slaPenalties += penalty;
|
|
updated.slaPenaltiesPaid += penalty;
|
|
updated.satisfaction = Math.max(0, updated.satisfaction - (wasRejected ? 0.01 : 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,
|
|
};
|
|
}
|