Game balance audit: wire research effects, rework capability formula, fix dead systems
CI / build-and-push (push) Successful in 32s

- Create researchBonuses utility to aggregate tech tree effects into all game systems
  (infrastructure energy costs, compute efficiency, training speed, model capability, reputation)
- Rework model capability from sqrt(compute) to 4-pillar formula (params + compute + data + research)
- Make context window affect benchmarks and inference speed
- Add MoE tradeoffs: 1.5x VRAM, 0.8x training speed
- Enforce research point costs as a gate for unlocking research
- Add real consequences to data contamination events (reputation hit, legal costs)
- Scale talent costs from $0.03 to $5/tick per headcount
- Scale compliance costs 100x to be meaningful
- Rework competitor acquisition: cheaper but grants headcount, RP, and reputation
- Remove dead code: sfxVolume, autoSaveInterval, notificationsEnabled,
  FAST_FORWARD_BATCH_SIZE, CHINCHILLA_OPTIMAL_RATIO

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 09:36:31 -04:00
parent 8d650fefae
commit 00e790591e
14 changed files with 205 additions and 54 deletions
+2
View File
@@ -2,6 +2,8 @@ export { GameEngine } from './engine';
export { processTick, setAchievementDefinitions } from './tick';
export type { TickNotification } from './tick';
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
export { getResearchBonuses } from './systems/researchBonuses';
export type { ResearchBonuses } from './systems/researchBonuses';
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
export { canRaiseFunding, getNextFundingRound, computeValuation } from './systems/fundingSystem';
export { TECH_TREE } from './data/techTree';
@@ -1,5 +1,6 @@
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
import { FLOPS_TO_TOKENS_MULTIPLIER } from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
export interface CapacityResult {
totalFlops: number;
@@ -13,20 +14,19 @@ export interface CapacityResult {
tokensPerSecondCapacity: number;
}
export function computeCapacity(state: GameState, infrastructure: InfrastructureState): CapacityResult {
export function computeCapacity(state: GameState, infrastructure: InfrastructureState, researchBonuses?: ResearchBonuses): CapacityResult {
const { totalTrainingFlops, totalInferenceFlops, totalVramGB } = infrastructure;
const trainingAllocation = state.compute.trainingAllocation;
const inferenceAllocation = 1 - trainingAllocation;
// Training hardware can do inference at ~50% efficiency
// Inference hardware can do training at ~30% efficiency (no NVLink, poor scaling)
const effectiveTrainingFlops =
totalTrainingFlops * trainingAllocation +
totalInferenceFlops * trainingAllocation * 0.3;
const inferenceBoost = 1 + (researchBonuses?.tokensPerFlopBonus ?? 0) + (researchBonuses?.inferenceEfficiencyBonus ?? 0);
const effectiveInferenceFlops =
totalInferenceFlops * inferenceAllocation +
totalTrainingFlops * inferenceAllocation * 0.5;
(totalInferenceFlops * inferenceAllocation +
totalTrainingFlops * inferenceAllocation * 0.5) * inferenceBoost;
const tokensPerSecondCapacity = effectiveInferenceFlops * FLOPS_TO_TOKENS_MULTIPLIER;
@@ -24,6 +24,7 @@ import {
estimateNetworkSlots,
} from '@ai-tycoon/shared';
import type { TickNotification } from '../tick';
import type { ResearchBonuses } from './researchBonuses';
export interface InfraTickResult {
infrastructure: InfrastructureState;
@@ -463,7 +464,7 @@ function computeInterconnectMultiplier(
// --- Main Infrastructure Tick ---
export function processInfrastructure(state: GameState): InfraTickResult {
export function processInfrastructure(state: GameState, researchBonuses?: ResearchBonuses): InfraTickResult {
const notifications: TickNotification[] = [];
let repairCosts = 0;
@@ -587,7 +588,9 @@ export function processInfrastructure(state: GameState): InfraTickResult {
continue;
}
const speed = stageSpeed(cohort.stage, engEff, opsEff);
const baseSpeed = stageSpeed(cohort.stage, engEff, opsEff);
const pipelineBonus = cohort.stage !== 'repair' ? (researchBonuses?.pipelineSpeedBonus ?? 0) : 0;
const speed = baseSpeed * (1 + pipelineBonus);
const newProgress = cohort.stageProgress + speed;
if (newProgress < cohort.stageTotal) {
@@ -728,8 +731,9 @@ export function processInfrastructure(state: GameState): InfraTickResult {
}
const pue = COOLING_TYPE_CONFIGS[dc.coolingType].pueMultiplier;
const energyReduction = researchBonuses?.energyCostReduction ?? 0;
const energyCostPerTick = (tierConfig.baseEnergyCostPerTick + usedPowerKW * BASE_ENERGY_COST_PER_FLOP)
* location.energyCostMultiplier * pue;
* location.energyCostMultiplier * pue * (1 - energyReduction);
const maintenanceCostPerTick = totalRacksInDc * BASE_MAINTENANCE_PER_RACK;
const currentUptime = totalRacksInDc > 0 ? effectiveComputeRacks / totalRacksInDc : 1;
+60 -23
View File
@@ -21,16 +21,21 @@ import {
DISTILLATION_BASE_RETENTION,
QUANTIZATION_TICKS,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
export interface ModelTickResult {
modelsState: ModelsState;
completedModels: BaseModel[];
notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' }[];
notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' | 'danger' }[];
reputationHit: number;
legalCosts: number;
}
export function processModels(state: GameState): ModelTickResult {
export function processModels(state: GameState, researchBonuses?: ResearchBonuses): ModelTickResult {
const completedModels: BaseModel[] = [];
const notifications: ModelTickResult['notifications'] = [];
let totalReputationHit = 0;
let totalLegalCosts = 0;
let baseModels = [...state.models.baseModels];
let families = [...state.models.families];
@@ -40,7 +45,8 @@ export function processModels(state: GameState): ModelTickResult {
state.talent.departments.research.effectiveness;
const engineerBoost = state.talent.departments.engineering.headcount *
state.talent.departments.engineering.effectiveness;
const speedMultiplier = 1 + (researcherBoost + engineerBoost) * 0.05;
const trainingResearchBonus = researchBonuses?.trainingSpeedBonus ?? 0;
const speedMultiplier = (1 + (researcherBoost + engineerBoost) * 0.05) * (1 + trainingResearchBonus);
const updatedPipelines: TrainingPipeline[] = [];
@@ -51,7 +57,8 @@ export function processModels(state: GameState): ModelTickResult {
}
const generation = families.find(f => f.id === pipeline.familyId)?.generation ?? 1;
const requiredVram = VRAM_REQUIREMENTS_BY_GENERATION[generation] ?? 0;
const moeVramMultiplier = pipeline.architecture.type === 'moe' ? 1.5 : 1.0;
const requiredVram = (VRAM_REQUIREMENTS_BY_GENERATION[generation] ?? 0) * moeVramMultiplier;
if (requiredVram > 0 && state.compute.totalVramGB < requiredVram) {
updatedPipelines.push({ ...pipeline, status: 'stalled' });
continue;
@@ -62,7 +69,8 @@ export function processModels(state: GameState): ModelTickResult {
if (pipeline.currentStage === 'pretraining') {
const stage = { ...pipeline.stages.pretraining };
const newProgress = stage.progressTicks + speedMultiplier;
const moeSpeedPenalty = pipeline.architecture.type === 'moe' ? 0.8 : 1.0;
const newProgress = stage.progressTicks + speedMultiplier * moeSpeedPenalty;
const events = generateTrainingEvents(pipeline, state);
let tickDelay = 0;
@@ -81,7 +89,10 @@ export function processModels(state: GameState): ModelTickResult {
tickDelay += event.impact.ticksDelayed ?? 0;
notifications.push({ title: 'Hardware Failure', message: `${pipeline.modelName}: GPU failure during training. Recovering from checkpoint.`, type: 'warning' });
} else if (event.type === 'data_contamination') {
notifications.push({ title: 'Data Contamination', message: `${pipeline.modelName}: Copyright concerns detected in training data.`, type: 'warning' });
totalReputationHit += event.impact.reputationHit ?? 0;
totalLegalCosts += event.impact.legalCost ?? 0;
const costStr = event.impact.legalCost ? ` Legal costs: $${(event.impact.legalCost).toLocaleString()}` : '';
notifications.push({ title: 'Data Contamination', message: `${pipeline.modelName}: Copyright concerns detected in training data.${costStr}`, type: 'danger' });
}
}
@@ -101,7 +112,7 @@ export function processModels(state: GameState): ModelTickResult {
updated.currentStage = 'alignment';
notifications.push({ title: 'Pre-training Complete', message: `${pipeline.modelName}: Moving to alignment.`, type: 'info' });
} else {
const model = createBaseModel(updated, state);
const model = createBaseModel(updated, state, researchBonuses);
baseModels = [...baseModels, model];
families = families.map(f =>
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
@@ -123,7 +134,7 @@ export function processModels(state: GameState): ModelTickResult {
updated.currentStage = 'alignment';
notifications.push({ title: 'SFT Complete', message: `${pipeline.modelName}: Moving to alignment.`, type: 'info' });
} else {
const model = createBaseModel(updated, state);
const model = createBaseModel(updated, state, researchBonuses);
baseModels = [...baseModels, model];
families = families.map(f =>
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
@@ -141,7 +152,7 @@ export function processModels(state: GameState): ModelTickResult {
stage.isComplete = true;
stage.progressTicks = stage.totalTicks;
const model = createBaseModel(updated, state);
const model = createBaseModel(updated, state, researchBonuses);
baseModels = [...baseModels, model];
families = families.map(f =>
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
@@ -195,6 +206,8 @@ export function processModels(state: GameState): ModelTickResult {
},
completedModels,
notifications,
reputationHit: totalReputationHit,
legalCosts: totalLegalCosts,
};
}
@@ -262,12 +275,13 @@ function generateTrainingEvents(pipeline: TrainingPipeline, state: GameState): T
? state.data.ownedDatasets.reduce((sum, d) => sum + d.legalRisk, 0) / state.data.ownedDatasets.length
: 0;
if (Math.random() < baseProbability * (hasDataPipeline ? 0.25 : 0.5) * avgLegalRisk) {
const legalCost = 5000 + Math.floor(Math.random() * 15000);
events.push({
id: uuid(), type: 'data_contamination', tick: state.meta.tickCount,
severity: 'moderate',
severity: avgLegalRisk > 0.4 ? 'major' : 'moderate',
description: 'Copyright holders identified content in training data.',
resolved: true,
impact: {},
impact: { reputationHit: 5, legalCost },
});
}
@@ -288,36 +302,61 @@ function generateTrainingEvents(pipeline: TrainingPipeline, state: GameState): T
function createBaseModel(
pipeline: TrainingPipeline,
state: GameState,
researchBonuses?: ResearchBonuses,
): BaseModel {
const { architecture, dataMix } = pipeline;
const compute = pipeline.stages.pretraining.computeAllocated;
const dataTokens = pipeline.stages.pretraining.targetTokens;
const params = architecture.totalParameters;
const computeFactor = Math.sqrt(compute) * 5;
const dataFactor = Math.log10(1 + dataTokens / 1e8) * 10;
const researchBonus = state.research.completedResearch.length * 3;
const efficiencyBonus = state.research.completedResearch.filter(r => r.includes('efficiency')).length * 5;
// Pillar 1: Parameters (0-30) — larger models have higher ceiling
const paramFactor = Math.min(30, Math.log2(1 + params) * 4.5);
let rawCapability = Math.min(95, computeFactor + dataFactor + researchBonus + efficiencyBonus);
// Pillar 2: Compute (0-25) — compute relative to parameter count (Chinchilla scaling)
const computePerParam = compute / Math.max(1, params);
const computeFactor = Math.min(25, Math.sqrt(computePerParam) * 8);
// Pillar 3: Data (0-20) — token count with quality multiplier
const dataQualityMultiplier = 1 + (researchBonuses?.dataQualityBonus ?? 0);
const dataFactor = Math.min(20, Math.log10(1 + dataTokens / 1e8) * 8 * dataQualityMultiplier);
// Pillar 4: Research (0-20) — accumulated research knowledge
const capabilityResearchBonus = researchBonuses?.globalCapabilityBonus ?? 0;
const researchFactor = Math.min(20, capabilityResearchBonus + state.research.completedResearch.length * 0.5);
let rawCapability = Math.min(95, paramFactor + computeFactor + dataFactor + researchFactor);
if (architecture.type === 'moe') {
rawCapability = Math.min(98, rawCapability * MOE_CAPABILITY_MULTIPLIER);
}
// MoE tradeoff: total params need full VRAM even though only active params run
// This is enforced in the UI/store when checking VRAM requirements
const researcherQuality = state.talent.departments.research.effectiveness;
const contextBonus = Math.log2(Math.max(1, architecture.contextWindow / 4)) * 3;
const contextPenalty = Math.max(0, Math.log2(architecture.contextWindow / 8)) * 2;
const capabilities: ModelCapabilities = {
reasoning: clamp(rawCapability * (0.6 + dataMix.scientific * 0.5 + dataMix.code * 0.3) * (1 + researcherQuality * 0.2)),
coding: clamp(rawCapability * (0.5 + dataMix.code * 1.0)),
creative: clamp(rawCapability * (0.4 + dataMix.books * 0.6 + dataMix.conversation * 0.3)),
math: clamp(rawCapability * (0.3 + dataMix.scientific * 0.7 + dataMix.code * 0.2)),
knowledge: clamp(rawCapability * (0.5 + dataMix.web * 0.3 + dataMix.books * 0.3)),
knowledge: clamp(rawCapability * (0.5 + dataMix.web * 0.3 + dataMix.books * 0.3) + contextBonus * 0.3),
multimodal: clamp(rawCapability * (dataMix.images * 0.5 + dataMix.video * 0.4 + dataMix.audio * 0.2)),
agents: clamp(rawCapability * (0.2 + dataMix.code * 0.3 + dataMix.conversation * 0.2)),
speed: Math.max(1, 100 - architecture.totalParameters * 0.3 + efficiencyBonus * 2 + (architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER * 10 : 0)),
agents: clamp(rawCapability * (0.2 + dataMix.code * 0.3 + dataMix.conversation * 0.2) + contextBonus * 0.5),
speed: Math.max(1, 100 - params * 0.3 - contextPenalty + (researchBonuses?.inferenceEfficiencyBonus ?? 0) * 20 + (architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER * 10 : 0)),
contextUtilization: Math.min(100, architecture.contextWindow * 0.4),
};
if (researchBonuses) {
capabilities.reasoning = clamp(capabilities.reasoning + researchBonuses.reasoningBonus);
capabilities.coding = clamp(capabilities.coding + researchBonuses.codingBonus);
capabilities.creative = clamp(capabilities.creative + researchBonuses.creativeBonus);
capabilities.multimodal = clamp(capabilities.multimodal + researchBonuses.multimodalBonus);
capabilities.agents = clamp(capabilities.agents + researchBonuses.agentsBonus);
}
const breakthroughBonuses: Partial<Record<keyof ModelCapabilities, number>> = {};
for (const event of pipeline.events) {
if ((event.type === 'breakthrough' || event.type === 'emergent_capability') && event.impact.capabilityDomain && event.impact.capabilityBonus) {
@@ -347,10 +386,8 @@ function createBaseModel(
}
}
const safetyResearch = state.research.completedResearch.filter(
r => r.includes('alignment') || r.includes('interpretability') || r.includes('constitutional'),
).length;
let overallSafety = Math.min(100, 30 + safetyResearch * 15 + Math.random() * 10);
const safetyResearchBonus = researchBonuses?.safetyBonus ?? 0;
let overallSafety = Math.min(100, 30 + safetyResearchBonus + Math.random() * 10);
let refusalRate = overallSafety > 60 ? 0.1 : 0.03;
if (pipeline.stages.alignment?.isComplete) {
@@ -5,13 +5,14 @@ import {
SAFETY_INCIDENT_REPUTATION_HIT,
LOW_SAFETY_THRESHOLD,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
export interface ReputationTickResult {
reputation: ReputationState;
safetyIncident: boolean;
}
export function processReputation(state: GameState): ReputationState & { _safetyIncident?: boolean } {
export function processReputation(state: GameState, researchBonuses?: ResearchBonuses): ReputationState & { _safetyIncident?: boolean } {
let { safetyRecord, publicPerception, employeeSatisfaction, regulatoryStanding } = state.reputation;
let safetyIncident = false;
@@ -40,6 +41,9 @@ export function processReputation(state: GameState): ReputationState & { _safety
.reduce((sum, d) => sum + d.morale, 0) / 4;
employeeSatisfaction = talentMorale;
const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0;
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * 0.1);
const score = Math.round(
safetyRecord * 0.3 +
publicPerception * 0.3 +
@@ -0,0 +1,80 @@
import { TECH_TREE } from '../data/techTree';
export interface ResearchBonuses {
energyCostReduction: number;
pipelineSpeedBonus: number;
trainingSpeedBonus: number;
inferenceEfficiencyBonus: number;
tokensPerFlopBonus: number;
dataQualityBonus: number;
sdkCoverageBonus: number;
globalCapabilityBonus: number;
reasoningBonus: number;
codingBonus: number;
creativeBonus: number;
multimodalBonus: number;
agentsBonus: number;
reputationBonus: number;
safetyBonus: number;
}
export function getResearchBonuses(completedResearch: string[]): ResearchBonuses {
const bonuses: ResearchBonuses = {
energyCostReduction: 0,
pipelineSpeedBonus: 0,
trainingSpeedBonus: 0,
inferenceEfficiencyBonus: 0,
tokensPerFlopBonus: 0,
dataQualityBonus: 0,
sdkCoverageBonus: 0,
globalCapabilityBonus: 0,
reasoningBonus: 0,
codingBonus: 0,
creativeBonus: 0,
multimodalBonus: 0,
agentsBonus: 0,
reputationBonus: 0,
safetyBonus: 0,
};
for (const id of completedResearch) {
const node = TECH_TREE.find(n => n.id === id);
if (!node) continue;
for (const effect of node.effects) {
switch (effect.type) {
case 'efficiency_boost':
switch (effect.target) {
case 'training_speed': bonuses.trainingSpeedBonus += effect.value; break;
case 'inference': bonuses.inferenceEfficiencyBonus += effect.value; break;
case 'tokens_per_flop': bonuses.tokensPerFlopBonus += effect.value; break;
case 'pipeline_speed': bonuses.pipelineSpeedBonus += effect.value; break;
case 'data_quality': bonuses.dataQualityBonus += effect.value; break;
case 'sdk_coverage': bonuses.sdkCoverageBonus += effect.value; break;
}
break;
case 'capability_boost':
switch (effect.target) {
case 'all': bonuses.globalCapabilityBonus += effect.value; break;
case 'reasoning': bonuses.reasoningBonus += effect.value; break;
case 'coding': bonuses.codingBonus += effect.value; break;
case 'creative': bonuses.creativeBonus += effect.value; break;
case 'multimodal': bonuses.multimodalBonus += effect.value; break;
case 'agents': bonuses.agentsBonus += effect.value; break;
case 'reputation': bonuses.reputationBonus += effect.value; break;
}
break;
case 'cost_reduction':
if (effect.target === 'energy') bonuses.energyCostReduction += effect.value;
break;
case 'safety_boost':
bonuses.safetyBonus += effect.value;
break;
}
}
}
return bonuses;
}
@@ -46,6 +46,7 @@ export function getAvailableResearch(state: GameState): typeof TECH_TREE {
if (state.research.activeResearch?.researchId === node.id) return false;
if (eraOrder.indexOf(node.era) > currentEraIdx) return false;
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
if (node.cost.researchPoints > state.research.researchPoints) return false;
return true;
});
}
@@ -1,6 +1,6 @@
import type { GameState, TalentState } from '@ai-tycoon/shared';
const SALARY_PER_HEADCOUNT_PER_TICK = 0.03;
const SALARY_PER_HEADCOUNT_PER_TICK = 5;
export function processTalent(state: GameState): TalentState {
const departments = { ...state.talent.departments };
@@ -8,11 +8,11 @@ export function processTalent(state: GameState): TalentState {
let totalSalary = 0;
for (const [id, dept] of Object.entries(departments)) {
totalSalary += dept.headcount * SALARY_PER_HEADCOUNT_PER_TICK;
totalSalary += dept.budget / 2592000;
totalSalary += dept.budget * 0.01;
}
for (const hire of state.talent.keyHires) {
totalSalary += hire.salary / 2592000;
totalSalary += hire.salary;
}
return {
+17 -5
View File
@@ -12,6 +12,7 @@ import { processData } from './systems/dataSystem';
import { checkEraTransition } from './systems/eraSystem';
import { processAchievements } from './systems/achievementSystem';
import { computeValuation } from './systems/fundingSystem';
import { getResearchBonuses } from './systems/researchBonuses';
export interface TickResult {
state: Partial<GameState>;
@@ -32,13 +33,14 @@ export function setAchievementDefinitions(defs: AchievementDefinition[]) {
export function processTick(state: GameState): Partial<GameState> {
const notifications: TickNotification[] = [];
const researchBonuses = getResearchBonuses(state.research.completedResearch);
const infraResult = processInfrastructure(state);
const infraResult = processInfrastructure(state, researchBonuses);
const infrastructure = infraResult.infrastructure;
notifications.push(...infraResult.notifications);
const stateWithInfra = { ...state, infrastructure };
const modelResult = processModels(stateWithInfra);
const modelResult = processModels(stateWithInfra, researchBonuses);
for (const completed of modelResult.completedModels) {
notifications.push({
@@ -51,7 +53,7 @@ export function processTick(state: GameState): Partial<GameState> {
const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState };
const capacity = computeCapacity(state, infrastructure);
const capacity = computeCapacity(state, infrastructure, researchBonuses);
const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity);
const compute = finalizeCompute(capacity, market.totalTokenDemand);
@@ -67,7 +69,7 @@ export function processTick(state: GameState): Partial<GameState> {
});
}
const reputationResult = processReputation(stateWithTalent);
const reputationResult = processReputation(stateWithTalent, researchBonuses);
const { _safetyIncident, ...reputation } = reputationResult;
if (_safetyIncident) {
notifications.push({
@@ -76,7 +78,17 @@ export function processTick(state: GameState): Partial<GameState> {
type: 'danger',
});
}
const economy = processEconomy(stateWithTalent, market, infrastructure, infraResult.repairCosts);
if (modelResult.reputationHit > 0) {
reputation.publicPerception = Math.max(0, reputation.publicPerception - modelResult.reputationHit);
reputation.score = Math.round(
reputation.safetyRecord * 0.3 +
reputation.publicPerception * 0.3 +
reputation.employeeSatisfaction * 0.2 +
reputation.regulatoryStanding * 0.2,
);
}
const extraCosts = infraResult.repairCosts + modelResult.legalCosts;
const economy = processEconomy(stateWithTalent, market, infrastructure, extraCosts);
const data = processData(stateWithTalent);
const competitors = processCompetitors(stateWithTalent);