Initial scaffold: AI Tycoon monorepo with core game loop
Turborepo monorepo with three packages: - packages/shared: TypeScript types for all 14 game systems + balance constants + formatting utils - packages/game-engine: Pure TS simulation engine with tick processor, economy, infrastructure, compute, research, market, and reputation systems - apps/web: React + Vite + Tailwind + Zustand frontend with sidebar dashboard layout, new game screen, dashboard with charts, infrastructure management, and model training pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ai-tycoon/game-engine",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-tycoon/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-tycoon/tsconfig": "workspace:*",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { processTick } from './tick';
|
||||
|
||||
export interface GameEngineCallbacks {
|
||||
getState: () => GameState;
|
||||
setState: (partial: Partial<GameState>) => void;
|
||||
onTick?: (tickCount: number) => void;
|
||||
onEraChange?: (era: GameState['meta']['currentEra']) => void;
|
||||
}
|
||||
|
||||
export class GameEngine {
|
||||
private callbacks: GameEngineCallbacks;
|
||||
private lastFrameTime = 0;
|
||||
private accumulator = 0;
|
||||
private animFrameId: number | null = null;
|
||||
private tickIntervalMs = 1000;
|
||||
|
||||
constructor(callbacks: GameEngineCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.animFrameId !== null) return;
|
||||
this.lastFrameTime = performance.now();
|
||||
this.accumulator = 0;
|
||||
this.loop(this.lastFrameTime);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSpeed(speed: number): void {
|
||||
this.tickIntervalMs = 1000 / speed;
|
||||
}
|
||||
|
||||
processOfflineTicks(missedTicks: number): { revenue: number; expenses: number; ticksProcessed: number } {
|
||||
let totalRevenue = 0;
|
||||
let totalExpenses = 0;
|
||||
|
||||
for (let i = 0; i < missedTicks; i++) {
|
||||
const state = this.callbacks.getState();
|
||||
const result = processTick(state);
|
||||
this.callbacks.setState(result);
|
||||
totalRevenue += result.economy?.revenuePerTick ?? 0;
|
||||
totalExpenses += result.economy?.expensesPerTick ?? 0;
|
||||
}
|
||||
|
||||
return { revenue: totalRevenue, expenses: totalExpenses, ticksProcessed: missedTicks };
|
||||
}
|
||||
|
||||
private loop = (now: number): void => {
|
||||
const delta = now - this.lastFrameTime;
|
||||
this.lastFrameTime = now;
|
||||
|
||||
const state = this.callbacks.getState();
|
||||
if (!state.meta.isPaused) {
|
||||
this.accumulator += delta;
|
||||
|
||||
let ticksThisFrame = 0;
|
||||
const maxTicksPerFrame = 10;
|
||||
|
||||
while (this.accumulator >= this.tickIntervalMs && ticksThisFrame < maxTicksPerFrame) {
|
||||
const currentState = this.callbacks.getState();
|
||||
const result = processTick(currentState);
|
||||
this.callbacks.setState(result);
|
||||
this.accumulator -= this.tickIntervalMs;
|
||||
ticksThisFrame++;
|
||||
this.callbacks.onTick?.(currentState.meta.tickCount + 1);
|
||||
}
|
||||
|
||||
if (this.accumulator > this.tickIntervalMs * maxTicksPerFrame) {
|
||||
this.accumulator = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.animFrameId = requestAnimationFrame(this.loop);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GameEngine } from './engine';
|
||||
export { processTick } from './tick';
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processCompute(state: GameState, infrastructure: InfrastructureState): ComputeState {
|
||||
const totalFlops = infrastructure.totalFlops;
|
||||
const trainingAllocation = state.compute.trainingAllocation;
|
||||
const inferenceAllocation = 1 - trainingAllocation;
|
||||
|
||||
const inferenceFlops = totalFlops * inferenceAllocation;
|
||||
const tokensPerSecondCapacity = inferenceFlops * 10;
|
||||
|
||||
const tokensPerSecondDemand = state.compute.tokensPerSecondDemand;
|
||||
const inferenceUtilization = tokensPerSecondCapacity > 0
|
||||
? Math.min(1, tokensPerSecondDemand / tokensPerSecondCapacity)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalFlops,
|
||||
trainingAllocation,
|
||||
inferenceAllocation,
|
||||
inferenceUtilization,
|
||||
tokensPerSecondCapacity,
|
||||
tokensPerSecondDemand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
|
||||
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY } from '@ai-tycoon/shared';
|
||||
import type { MarketTickResult } from './marketSystem';
|
||||
|
||||
export function processEconomy(
|
||||
state: GameState,
|
||||
market: MarketTickResult,
|
||||
infrastructure: InfrastructureState,
|
||||
): EconomyState {
|
||||
const revenue = market.apiRevenue + market.subscriptionRevenue;
|
||||
|
||||
const infraExpenses = infrastructure.dataCenters.reduce((sum, dc) => {
|
||||
return sum + dc.energyCostPerTick + dc.maintenanceCostPerTick;
|
||||
}, 0);
|
||||
|
||||
const talentExpenses = state.talent.totalSalaryPerTick;
|
||||
const dataExpenses = state.data.partnerships.reduce((sum, p) => sum + p.costPerTick, 0);
|
||||
const expenses = infraExpenses + talentExpenses + dataExpenses;
|
||||
|
||||
const money = state.economy.money + revenue - expenses;
|
||||
|
||||
const financialHistory = [...state.economy.financialHistory];
|
||||
if (state.meta.tickCount % FINANCIAL_SNAPSHOT_INTERVAL === 0) {
|
||||
financialHistory.push({
|
||||
tick: state.meta.tickCount,
|
||||
money,
|
||||
revenue,
|
||||
expenses,
|
||||
valuation: state.economy.funding.valuation,
|
||||
});
|
||||
if (financialHistory.length > MAX_FINANCIAL_HISTORY) {
|
||||
financialHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state.economy,
|
||||
money: Math.max(0, money),
|
||||
totalRevenue: state.economy.totalRevenue + revenue,
|
||||
totalExpenses: state.economy.totalExpenses + expenses,
|
||||
revenuePerTick: revenue,
|
||||
expensesPerTick: expenses,
|
||||
financialHistory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { GameState, InfrastructureState } from '@ai-tycoon/shared';
|
||||
import {
|
||||
GPU_CONFIGS,
|
||||
LOCATION_CONFIGS,
|
||||
GPU_PRICE_VOLATILITY,
|
||||
GPU_FAILURE_RATE_BASE,
|
||||
REDUNDANCY_FAILURE_REDUCTION,
|
||||
BASE_ENERGY_COST_PER_FLOP,
|
||||
BASE_MAINTENANCE_PER_GPU,
|
||||
} from '@ai-tycoon/shared';
|
||||
import type { GpuType } from '@ai-tycoon/shared';
|
||||
|
||||
export function processInfrastructure(state: GameState): InfrastructureState {
|
||||
const dataCenters = state.infrastructure.dataCenters.map(dc => {
|
||||
const location = LOCATION_CONFIGS[dc.location];
|
||||
|
||||
const gpus = dc.gpus.map(inv => {
|
||||
const failureRate = GPU_FAILURE_RATE_BASE * (1 - dc.redundancyLevel * REDUNDANCY_FAILURE_REDUCTION);
|
||||
let newFailed = inv.failedCount;
|
||||
for (let i = 0; i < inv.healthyCount; i++) {
|
||||
if (Math.random() < failureRate) newFailed++;
|
||||
}
|
||||
const healthyCount = Math.max(0, inv.count - newFailed);
|
||||
return { ...inv, healthyCount, failedCount: newFailed };
|
||||
});
|
||||
|
||||
let totalFlops = 0;
|
||||
let totalPower = 0;
|
||||
let totalGpuCount = 0;
|
||||
for (const inv of gpus) {
|
||||
const config = GPU_CONFIGS[inv.type];
|
||||
totalFlops += inv.healthyCount * config.flopsPerUnit;
|
||||
totalPower += inv.healthyCount * config.basePowerDraw;
|
||||
totalGpuCount += inv.count;
|
||||
}
|
||||
|
||||
const energyCostPerTick = totalPower * BASE_ENERGY_COST_PER_FLOP * location.energyCostMultiplier;
|
||||
const maintenanceCostPerTick = totalGpuCount * BASE_MAINTENANCE_PER_GPU;
|
||||
const currentUptime = totalGpuCount > 0
|
||||
? gpus.reduce((s, inv) => s + inv.healthyCount, 0) / totalGpuCount
|
||||
: 1;
|
||||
|
||||
return { ...dc, gpus, energyCostPerTick, maintenanceCostPerTick, currentUptime };
|
||||
});
|
||||
|
||||
const gpuMarketPrices = { ...state.infrastructure.gpuMarketPrices };
|
||||
for (const gpuType of Object.keys(gpuMarketPrices) as GpuType[]) {
|
||||
const basePrice = GPU_CONFIGS[gpuType].basePrice;
|
||||
const variation = (Math.random() - 0.5) * 2 * GPU_PRICE_VOLATILITY;
|
||||
const currentPrice = gpuMarketPrices[gpuType];
|
||||
const newPrice = currentPrice * (1 + variation);
|
||||
gpuMarketPrices[gpuType] = Math.max(basePrice * 0.7, Math.min(basePrice * 1.5, newPrice));
|
||||
}
|
||||
|
||||
let totalFlops = 0;
|
||||
let totalUptime = 0;
|
||||
let dcCount = 0;
|
||||
for (const dc of dataCenters) {
|
||||
for (const inv of dc.gpus) {
|
||||
totalFlops += inv.healthyCount * GPU_CONFIGS[inv.type].flopsPerUnit;
|
||||
}
|
||||
totalUptime += dc.currentUptime;
|
||||
dcCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
dataCenters,
|
||||
gpuMarketPrices,
|
||||
totalFlops,
|
||||
totalUptime: dcCount > 0 ? totalUptime / dcCount : 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { GameState, MarketState, ComputeState } from '@ai-tycoon/shared';
|
||||
import {
|
||||
CONSUMER_BASE_GROWTH,
|
||||
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
|
||||
CONSUMER_BASE_CHURN,
|
||||
API_TOKENS_PER_REQUEST,
|
||||
} from '@ai-tycoon/shared';
|
||||
|
||||
export interface MarketTickResult {
|
||||
marketState: MarketState;
|
||||
apiRevenue: number;
|
||||
subscriptionRevenue: number;
|
||||
}
|
||||
|
||||
export function processMarket(state: GameState, compute: ComputeState): MarketTickResult {
|
||||
const bestModel = state.models.trainedModels
|
||||
.filter(m => m.isDeployed)
|
||||
.sort((a, b) => b.benchmarkScore - a.benchmarkScore)[0];
|
||||
|
||||
const modelQuality = bestModel ? bestModel.benchmarkScore / 100 : 0;
|
||||
const chatProduct = state.models.productLines.find(p => p.type === 'chat-product');
|
||||
const textApi = state.models.productLines.find(p => p.type === 'text-api');
|
||||
|
||||
const consumers = { ...state.market.consumers };
|
||||
if (chatProduct?.isActive && bestModel) {
|
||||
const growthRate = CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER;
|
||||
const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction));
|
||||
consumers.growthRatePerTick = growthRate;
|
||||
consumers.churnRatePerTick = churnRate;
|
||||
const newSubs = consumers.totalSubscribers * growthRate;
|
||||
const lostSubs = consumers.totalSubscribers * churnRate;
|
||||
consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs);
|
||||
|
||||
if (consumers.totalSubscribers < 10 && modelQuality > 0) {
|
||||
consumers.totalSubscribers += 1;
|
||||
}
|
||||
|
||||
consumers.satisfaction = Math.min(1, Math.max(0,
|
||||
0.3 + modelQuality * 0.5 + (1 - compute.inferenceUtilization) * 0.2,
|
||||
));
|
||||
}
|
||||
|
||||
const subscriptionRevenue = chatProduct?.isActive
|
||||
? consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 30 / 24 / 3600)
|
||||
: 0;
|
||||
|
||||
const enterprise = { ...state.market.enterprise };
|
||||
let apiRevenue = 0;
|
||||
if (textApi?.isActive && bestModel) {
|
||||
let totalTokens = 0;
|
||||
for (const contract of enterprise.activeContracts) {
|
||||
totalTokens += contract.tokensPerTick;
|
||||
apiRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken;
|
||||
}
|
||||
enterprise.totalApiCallsPerTick = totalTokens / API_TOKENS_PER_REQUEST;
|
||||
}
|
||||
|
||||
return {
|
||||
marketState: {
|
||||
...state.market,
|
||||
consumers,
|
||||
enterprise,
|
||||
},
|
||||
apiRevenue,
|
||||
subscriptionRevenue,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GameState, ReputationState } from '@ai-tycoon/shared';
|
||||
import { MAX_REPUTATION_HISTORY } from '@ai-tycoon/shared';
|
||||
|
||||
export function processReputation(state: GameState): ReputationState {
|
||||
const { safetyRecord, publicPerception, employeeSatisfaction, regulatoryStanding } = state.reputation;
|
||||
|
||||
const score = Math.round(
|
||||
safetyRecord * 0.3 +
|
||||
publicPerception * 0.3 +
|
||||
employeeSatisfaction * 0.2 +
|
||||
regulatoryStanding * 0.2,
|
||||
);
|
||||
|
||||
const reputationHistory = [...state.reputation.reputationHistory];
|
||||
if (state.meta.tickCount % 120 === 0) {
|
||||
reputationHistory.push({ tick: state.meta.tickCount, score });
|
||||
if (reputationHistory.length > MAX_REPUTATION_HISTORY) {
|
||||
reputationHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state.reputation,
|
||||
score,
|
||||
reputationHistory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchState {
|
||||
const active = state.research.activeResearch;
|
||||
if (!active) return state.research;
|
||||
|
||||
const researcherBoost = state.talent.departments.research.headcount *
|
||||
state.talent.departments.research.effectiveness;
|
||||
const speedMultiplier = 1 + researcherBoost * 0.1;
|
||||
|
||||
const newProgress = active.progressTicks + speedMultiplier;
|
||||
|
||||
if (newProgress >= active.totalTicks) {
|
||||
return {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state.research,
|
||||
activeResearch: {
|
||||
...active,
|
||||
progressTicks: newProgress,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { processEconomy } from './systems/economySystem';
|
||||
import { processInfrastructure } from './systems/infrastructureSystem';
|
||||
import { processCompute } from './systems/computeSystem';
|
||||
import { processResearch } from './systems/researchSystem';
|
||||
import { processMarket } from './systems/marketSystem';
|
||||
import { processReputation } from './systems/reputationSystem';
|
||||
|
||||
export function processTick(state: GameState): Partial<GameState> {
|
||||
const infrastructure = processInfrastructure(state);
|
||||
const compute = processCompute(state, infrastructure);
|
||||
const research = processResearch(state, compute);
|
||||
const market = processMarket(state, compute);
|
||||
const reputation = processReputation(state);
|
||||
const economy = processEconomy(state, market, infrastructure);
|
||||
|
||||
const tickCount = state.meta.tickCount + 1;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
...state.meta,
|
||||
tickCount,
|
||||
lastTickTimestamp: Date.now(),
|
||||
totalPlayTime: state.meta.totalPlayTime + 1,
|
||||
},
|
||||
economy,
|
||||
infrastructure,
|
||||
compute,
|
||||
research,
|
||||
market: market.marketState,
|
||||
reputation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ai-tycoon/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user