Files
AIHostingTycoon/packages/game-engine/src/systems/market/enterprisePipeline.ts
T
josh 102e05c8ba
Balance Check / balance-simulation (push) Failing after 11m32s
Balance Check / multi-run-balance (push) Failing after 23m46s
CI / build-and-push (push) Successful in 1m20s
Add game-simulation package with multi-run balance testing, fix stalled-pipeline trap
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>
2026-04-26 06:11:26 -04:00

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,
};
}