Add Week 3 polish and late-game features

VC funding system (seed through IPO with requirements gating), 15
achievements with engine checker, model tuning presets and unlockable
sliders, overload policy controls, open-source mechanic with reputation
boost, enhanced Recharts analytics (subscriber/reputation/revenue vs
expenses charts), M&A acquisition system, sidebar NEW badges on era
transitions, tutorial hints, and wired-up settings toggles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:56:40 -04:00
parent 8ea6c771a1
commit 8a8b49d934
20 changed files with 907 additions and 75 deletions
@@ -0,0 +1,62 @@
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/shared';
export interface AchievementTickResult {
achievements: AchievementState;
newAchievements: string[];
}
const ERA_INDEX: Record<string, number> = { startup: 0, scaleup: 1, bigtech: 2, agi: 3 };
function getFieldValue(state: GameState, field: string): number {
if (field === 'meta._eraIndex') return ERA_INDEX[state.meta.currentEra] ?? 0;
if (field === 'meta._deployedModelCount') return state.models.trainedModels.filter(m => m.isDeployed).length;
if (field === 'infrastructure._totalGpuCount') {
return state.infrastructure.dataCenters.reduce(
(sum, dc) => sum + dc.gpus.reduce((s, g) => s + g.count, 0), 0,
);
}
const parts = field.split('.');
let current: unknown = state;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 0;
current = (current as Record<string, unknown>)[part];
}
return typeof current === 'number' ? current : 0;
}
function checkCondition(state: GameState, def: AchievementDefinition): boolean {
const value = getFieldValue(state, def.condition.field);
switch (def.condition.operator) {
case 'gt': return value > def.condition.value;
case 'gte': return value >= def.condition.value;
case 'eq': return value === def.condition.value;
default: return false;
}
}
export function processAchievements(
state: GameState,
definitions: AchievementDefinition[],
): AchievementTickResult {
if (state.meta.tickCount % 10 !== 0) {
return { achievements: state.achievements, newAchievements: [] };
}
const unlockedIds = new Set(state.achievements.unlocked.map(a => a.id));
const newAchievements: string[] = [];
const unlocked = [...state.achievements.unlocked];
for (const def of definitions) {
if (unlockedIds.has(def.id)) continue;
if (checkCondition(state, def)) {
unlocked.push({ id: def.id, unlockedAtTick: state.meta.tickCount });
newAchievements.push(def.name);
}
}
return {
achievements: { ...state.achievements, unlocked },
newAchievements,
};
}
@@ -0,0 +1,43 @@
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
export function getNextFundingRound(funding: FundingState): FundingRoundType | null {
if (funding.isPublic) return null;
const completedTypes = new Set(funding.completedRounds.map(r => r.type));
for (const type of ROUND_ORDER) {
if (!completedTypes.has(type)) return type;
}
return null;
}
export function canRaiseFunding(state: GameState): { canRaise: boolean; nextRound: FundingRoundType | null; reason?: string } {
const nextRound = getNextFundingRound(state.economy.funding);
if (!nextRound) return { canRaise: false, nextRound: null, reason: 'No more funding rounds available' };
const config = FUNDING_ROUNDS[nextRound];
const reqs = config.requirements;
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) {
return { canRaise: false, nextRound, reason: `Need ${reqs.minUsers.toLocaleString()} subscribers` };
}
if (reqs.minReputation && state.reputation.score < reqs.minReputation) {
return { canRaise: false, nextRound, reason: `Need ${reqs.minReputation} reputation` };
}
return { canRaise: true, nextRound };
}
export function computeValuation(state: GameState): number {
const revenueMultiple = state.economy.revenuePerTick * 86400 * 365;
const subscriberValue = state.market.consumers.totalSubscribers * 500;
const capabilityValue = Math.pow(
Math.max(...state.models.trainedModels.map(m => m.benchmarkScore), 0),
2,
) * 1000;
return Math.max(100_000, revenueMultiple * 10 + subscriberValue + capabilityValue);
}
@@ -4,6 +4,8 @@ import {
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
CONSUMER_BASE_CHURN,
API_TOKENS_PER_REQUEST,
OPEN_SOURCE_REVENUE_PENALTY,
OPEN_SOURCE_TALENT_ATTRACTION,
} from '@ai-tycoon/shared';
export interface MarketTickResult {
@@ -84,13 +86,35 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi
consumers.totalSubscribers * 100 +
enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0);
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 policy = state.market.overloadPolicy;
if (policy.degradeQualityUnderLoad && compute.inferenceUtilization > 0.85) {
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.02);
}
if (policy.prioritizeEnterprise && compute.inferenceUtilization > 0.9) {
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.01);
}
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,
apiRevenue: Math.max(0, apiRevenue),
subscriptionRevenue,
totalTokenDemand,
};