Add game-simulation package with multi-run balance testing, fix stalled-pipeline trap
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

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>
This commit is contained in:
2026-04-26 06:11:26 -04:00
parent 283c7c7932
commit 102e05c8ba
51 changed files with 4294 additions and 132 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ 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 { getResearchBonuses, resetResearchBonusCache } from './systems/researchBonuses';
export type { ResearchBonuses } from './systems/researchBonuses';
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
export { onModelDeployed } from './systems/market/obsolescenceSystem';
@@ -38,17 +38,18 @@ export function processCompetitors(state: GameState): CompetitorState {
const updated = { ...rival };
// Freshness decay each tick
updated.modelFreshness = Math.max(0, updated.modelFreshness - FRESHNESS_DECAY_RATE);
// Developer ecosystem growth based on personality
const ecoGrowth = rival.personality.openSourceTendency * 0.1 + rival.personality.marketingFocus * 0.05;
updated.developerEcosystemScore = Math.min(100,
updated.developerEcosystemScore + ecoGrowth * 0.01,
);
// Catch-up: if any market share < threshold, cut prices
const minShare = Math.min(...Object.values(updated.marketShares));
const shares = Object.values(updated.marketShares);
let minShare = shares[0];
for (let i = 1; i < shares.length; i++) {
if (shares[i] < minShare) minShare = shares[i];
}
if (minShare < COMPETITOR_CATCHUP_SHARE_THRESHOLD) {
updated.pricingStrategy = {
...updated.pricingStrategy,
@@ -61,7 +62,6 @@ export function processCompetitors(state: GameState): CompetitorState {
return updated;
}
// Milestone reached — capability jump + model release
const { personality } = rival;
const capGrowth = (2 + personality.researchFocus * 5 + personality.riskTolerance * 3) *
(1 + tick * 0.00005);
@@ -84,7 +84,6 @@ export function processCompetitors(state: GameState): CompetitorState {
const modelIdx = Math.floor(updated.estimatedCapability / 10);
updated.latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
// Model release resets freshness
updated.modelFreshness = 1.0;
updated.lastModelReleaseTick = tick;
@@ -96,11 +95,12 @@ export function processCompetitors(state: GameState): CompetitorState {
return updated;
});
const allCaps = [
...rivals.filter(r => r.status === 'active').map(r => r.estimatedCapability),
state.models.bestDeployedModelScore,
];
const industryBenchmark = allCaps.length > 0 ? Math.max(...allCaps) : 0;
let industryBenchmark = state.models.bestDeployedModelScore;
for (const r of rivals) {
if (r.status === 'active' && r.estimatedCapability > industryBenchmark) {
industryBenchmark = r.estimatedCapability;
}
}
return { rivals, industryBenchmark };
}
@@ -358,22 +358,19 @@ function processNetworkTick(
repairSpeedBonus: number,
hotStandbyTicks: number,
redundancyBonus: number,
): { switchRepairCosts: number; notifications: TickNotification[]; dirty: boolean } {
): { switchRepairCosts: number; notifications: TickNotification[]; dirtyDCs: Set<string> } {
const notifications: TickNotification[] = [];
let switchRepairCosts = 0;
let dirty = false;
const dirtyDCs = new Set<string>();
const healthyByTier: Partial<Record<SwitchTier, NetworkSwitch[]>> = {};
const repairing: NetworkSwitch[] = [];
const failed: NetworkSwitch[] = [];
for (const sw of Object.values(registry)) {
if (sw.status === 'healthy') {
(healthyByTier[sw.tier] ??= []).push(sw);
} else if (sw.status === 'repairing') {
repairing.push(sw);
} else if (sw.status === 'failed') {
failed.push(sw);
}
}
@@ -397,9 +394,9 @@ function processNetworkTick(
sw.repairProgress = 0;
sw.repairTotal = repairTime;
newlyFailed.push(sw);
if (sw.dcId) dirtyDCs.add(sw.dcId);
switchRepairCosts += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION;
}
dirty = true;
}
}
@@ -409,13 +406,14 @@ function processNetworkTick(
sw.status = 'healthy';
sw.repairProgress = 0;
sw.repairTotal = 0;
dirty = true;
if (sw.dcId) dirtyDCs.add(sw.dcId);
}
}
if (dirty) {
if (dirtyDCs.size > 0) {
for (const sw of Object.values(registry)) {
if (sw.uplinkIds.length === 0) continue;
if (sw.dcId && !dirtyDCs.has(sw.dcId)) continue;
let active = 0;
for (const upId of sw.uplinkIds) {
if (registry[upId]?.status === 'healthy') active++;
@@ -435,7 +433,7 @@ function processNetworkTick(
}
}
return { switchRepairCosts, notifications, dirty };
return { switchRepairCosts, notifications, dirtyDCs };
}
// --- Interconnect Training Multiplier ---
@@ -478,16 +476,13 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0;
const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0;
// Clone switch registry for mutable operations this tick
const registry: Record<string, NetworkSwitch> = {};
for (const [id, sw] of Object.entries(state.infrastructure.switchRegistry)) {
registry[id] = { ...sw, uplinkIds: [...sw.uplinkIds], downlinkIds: [...sw.downlinkIds] };
}
// Mutate registry in-place — infrastructure returns a new state anyway
const registry = state.infrastructure.switchRegistry;
// Process network failures/repairs globally
const netResult = processNetworkTick(registry, networkResearchBonus, opsEff, repairSpeedBonus, hotStandbyTicks, redundancyBonus);
repairCosts += netResult.switchRepairCosts;
notifications.push(...netResult.notifications);
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
let totalFlops = 0;
let totalTrainingFlops = 0;
@@ -671,8 +666,8 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
repairCosts += dcRepairCosts;
// Recompute DC network summary after failures/repairs
if (netResult.dirty && networkSummary.switchIds.length > 0) {
// Recompute DC network summary after failures/repairs (only if this DC's switches changed)
if (netResult.dirtyDCs.has(dc.id) && networkSummary.switchIds.length > 0) {
networkSummary = buildDCSummary(
networkSummary.switchIds, networkSummary.networkRackCount, registry,
);
@@ -70,7 +70,7 @@ export function processEnterprisePipeline(
const activeContracts = [...ent.activeContracts];
const effectiveSales = salesHeadcount > 0
? Math.min(1, salesHeadcount * salesEffectiveness / Math.max(1, pipeline.length))
? Math.min(2, salesHeadcount * salesEffectiveness * 0.2)
: 0;
// --- Lead generation ---
@@ -129,7 +129,8 @@ export function processEnterprisePipeline(
let transitionProb = baseRate * effectiveSales;
if (lead.stage === 'qualification') {
transitionProb *= modelCapability >= lead.requiredCapability ? 1 : 0.1;
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;
@@ -68,13 +68,18 @@ function buildModelFleet(
): ModelServingSlot[] {
const slots: ModelServingSlot[] = [];
const deployedBases = modelsState.baseModels.filter(m => m.isDeployed);
const deployedVariants: { variant: ModelVariant; baseModel: BaseModel }[] = [];
const deployedBases: BaseModel[] = [];
const baseModelById = new Map<string, BaseModel>();
for (const m of modelsState.baseModels) {
if (m.isDeployed) deployedBases.push(m);
baseModelById.set(m.id, m);
}
const deployedVariants: { variant: ModelVariant; baseModel: BaseModel }[] = [];
for (const family of modelsState.families) {
for (const variant of family.variants) {
if (!variant.isDeployed) continue;
const base = modelsState.baseModels.find(m => m.id === variant.baseModelId);
const base = baseModelById.get(variant.baseModelId);
if (base) deployedVariants.push({ variant, baseModel: base });
}
}
@@ -173,7 +178,9 @@ function serveFromFleet(
let degraded = 0;
let qualityWeightedSum = 0;
const bestQuality = fleet.length > 0 ? Math.max(...fleet.map(s => s.qualityScore)) : 1;
let bestQuality = 0;
for (const s of fleet) { if (s.qualityScore > bestQuality) bestQuality = s.qualityScore; }
if (bestQuality === 0) bestQuality = 1;
const degradationActive = policy.autoDegradation.enabled && overallUtilization > policy.autoDegradation.triggerThreshold;
for (const slot of fleet) {
@@ -92,13 +92,6 @@ function computeAttractiveness(
return Math.max(0.01, score);
}
function softmaxShares(scores: number[]): number[] {
const maxScore = Math.max(...scores);
const exps = scores.map(s => Math.exp((s - maxScore) * SHARE_TEMPERATURE));
const sumExp = exps.reduce((a, b) => a + b, 0);
return exps.map(e => e / sumExp);
}
export function computeMarketShares(
tam: TotalAddressableMarket,
participants: ParticipantProfile[],
@@ -106,30 +99,48 @@ export function computeMarketShares(
): TotalAddressableMarket {
const segments = { ...tam.segments };
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
const n = participants.length;
const scores = new Array<number>(n);
const targetShares = new Array<number>(n);
for (const segId of segmentIds) {
const seg = segments[segId];
const scores = participants.map(p => computeAttractiveness(p, segId, qualityBaseline));
const targetShares = softmaxShares(scores);
for (let i = 0; i < n; i++) {
scores[i] = computeAttractiveness(participants[i], segId, qualityBaseline);
}
// Inline softmax
let maxScore = scores[0];
for (let i = 1; i < n; i++) { if (scores[i] > maxScore) maxScore = scores[i]; }
let sumExp = 0;
for (let i = 0; i < n; i++) {
targetShares[i] = Math.exp((scores[i] - maxScore) * SHARE_TEMPERATURE);
sumExp += targetShares[i];
}
for (let i = 0; i < n; i++) { targetShares[i] /= sumExp; }
const oldShareMap = new Map<string, MarketShareEntry>();
for (const entry of seg.shares) {
oldShareMap.set(entry.playerId, entry);
}
const newShares: MarketShareEntry[] = participants.map((p, i) => {
const newShares: MarketShareEntry[] = new Array(n);
let totalShare = 0;
for (let i = 0; i < n; i++) {
const p = participants[i];
const old = oldShareMap.get(p.id);
const oldShare = old?.sharePercent ?? 0;
const migratedShare = oldShare + (targetShares[i] - oldShare) * SHARE_MIGRATION_SPEED;
return {
totalShare += migratedShare;
newShares[i] = {
playerId: p.id,
sharePercent: migratedShare,
customers: Math.floor(migratedShare * seg.totalSize),
customers: 0,
attractivenessScore: scores[i],
};
});
}
const totalShare = newShares.reduce((s, e) => s + e.sharePercent, 0);
if (totalShare > 0) {
for (const entry of newShares) {
entry.sharePercent /= totalShare;
@@ -152,10 +163,7 @@ export function updateTAMGrowth(tam: TotalAddressableMarket, era: Era): TotalAdd
const seg = segments[segId];
const base = baseSizes[segId];
const grown = seg.totalSize + seg.totalSize * TAM_GROWTH_PER_TICK;
segments[segId] = {
...seg,
totalSize: Math.max(base, grown),
};
segments[segId] = { ...seg, totalSize: Math.max(base, grown) };
}
return { segments };
+21 -15
View File
@@ -18,6 +18,7 @@ import {
QUANTIZATION_CONFIGS,
POINT_RELEASE_CAPABILITY_GAIN,
SIZE_TIER_LABELS,
MODEL_BASE_SAFETY,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
@@ -44,12 +45,12 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
const engineerBoost = state.talent.departments.engineering.headcount *
state.talent.departments.engineering.effectiveness;
const trainingResearchBonus = researchBonuses?.trainingSpeedBonus ?? 0;
const speedMultiplier = (1 + (researcherBoost + engineerBoost) * 0.05) * (1 + trainingResearchBonus);
const speedMultiplier = (1 + (researcherBoost + engineerBoost) * 0.15) * (1 + trainingResearchBonus);
const updatedPipelines: TrainingPipeline[] = [];
for (const pipeline of state.models.activeTrainingPipelines) {
if (pipeline.status !== 'active') {
if (pipeline.status !== 'active' && pipeline.status !== 'stalled') {
updatedPipelines.push(pipeline);
continue;
}
@@ -58,12 +59,12 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
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' });
updatedPipelines.push(pipeline.status === 'stalled' ? pipeline : { ...pipeline, status: 'stalled' });
continue;
}
const effectiveFlops = totalTrainingFlops * pipeline.allocatedComputeFraction;
let updated = { ...pipeline, events: [...pipeline.events] };
let updated = { ...pipeline, status: 'active' as TrainingPipeline['status'], events: [...pipeline.events] };
if (pipeline.currentStage === 'pretraining') {
const stage = { ...pipeline.stages.pretraining };
@@ -155,16 +156,21 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
const updatedEvalJobs = processEvalJobs(state);
const allDeployed = [
...baseModels.filter(m => m.isDeployed),
...families.flatMap(f => f.variants.filter(v => v.isDeployed)),
];
const bestDeployedModelScore = allDeployed.reduce((best, m) =>
Math.max(best, 'rawCapability' in m ? m.rawCapability : computeVariantScore(m)), 0);
const bestDeployedSafetyScore = allDeployed.reduce((best, m) =>
Math.max(best, m.safetyProfile.overallSafety), 0);
let bestDeployedModelScore = 0;
let bestDeployedSafetyScore = 0;
for (const m of baseModels) {
if (!m.isDeployed) continue;
if (m.rawCapability > bestDeployedModelScore) bestDeployedModelScore = m.rawCapability;
if (m.safetyProfile.overallSafety > bestDeployedSafetyScore) bestDeployedSafetyScore = m.safetyProfile.overallSafety;
}
for (const f of families) {
for (const v of f.variants) {
if (!v.isDeployed) continue;
const score = computeVariantScore(v);
if (score > bestDeployedModelScore) bestDeployedModelScore = score;
if (v.safetyProfile.overallSafety > bestDeployedSafetyScore) bestDeployedSafetyScore = v.safetyProfile.overallSafety;
}
}
return {
modelsState: {
@@ -375,7 +381,7 @@ function createBaseModel(
}
const safetyResearchBonus = researchBonuses?.safetyBonus ?? 0;
let overallSafety = Math.min(100, 30 + safetyResearchBonus + Math.random() * 10);
let overallSafety = Math.min(100, MODEL_BASE_SAFETY + safetyResearchBonus + Math.random() * 10);
let refusalRate = overallSafety > 60 ? 0.1 : 0.03;
if (pipeline.stages.alignment.isComplete) {
@@ -4,6 +4,8 @@ import {
SAFETY_INCIDENT_PROBABILITY_BASE,
SAFETY_INCIDENT_REPUTATION_HIT,
LOW_SAFETY_THRESHOLD,
SAFETY_RECORD_RECOVERY_RATE,
PUBLIC_PERCEPTION_GROWTH_RATE,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
@@ -28,6 +30,10 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
}
}
if (state.models.bestDeployedSafetyScore >= LOW_SAFETY_THRESHOLD && !safetyIncident) {
safetyRecord = Math.min(80, safetyRecord + SAFETY_RECORD_RECOVERY_RATE);
}
const eraIdx = ['startup', 'scaleup', 'bigtech', 'agi'].indexOf(state.meta.currentEra);
const regulatoryPressure = eraIdx * 5;
const safetyResearchCount = state.research.completedResearch
@@ -39,10 +45,10 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
const talentMorale = Object.values(state.talent.departments)
.reduce((sum, d) => sum + d.morale, 0) / 4;
employeeSatisfaction = talentMorale;
employeeSatisfaction = talentMorale * 100;
const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0;
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * 0.1);
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * PUBLIC_PERCEPTION_GROWTH_RATE);
const score = Math.round(
safetyRecord * 0.3 +
@@ -21,7 +21,16 @@ export interface ResearchBonuses {
autoScalingBonus: number;
}
const techTreeById = new Map(TECH_TREE.map(n => [n.id, n]));
let _cachedBonuses: ResearchBonuses | null = null;
let _cachedResearchCount = -1;
export function getResearchBonuses(completedResearch: string[]): ResearchBonuses {
if (_cachedBonuses && completedResearch.length === _cachedResearchCount) {
return _cachedBonuses;
}
const bonuses: ResearchBonuses = {
energyCostReduction: 0,
pipelineSpeedBonus: 0,
@@ -42,7 +51,7 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
};
for (const id of completedResearch) {
const node = TECH_TREE.find(n => n.id === id);
const node = techTreeById.get(id);
if (!node) continue;
for (const effect of node.effects) {
@@ -79,5 +88,12 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
}
}
_cachedBonuses = bonuses;
_cachedResearchCount = completedResearch.length;
return bonuses;
}
export function resetResearchBonusCache(): void {
_cachedBonuses = null;
_cachedResearchCount = -1;
}
+42 -30
View File
@@ -13,6 +13,7 @@ import { checkEraTransition } from './systems/eraSystem';
import { processAchievements } from './systems/achievementSystem';
import { computeValuation } from './systems/fundingSystem';
import { getResearchBonuses } from './systems/researchBonuses';
import { TECH_TREE } from './data/techTree';
export interface TickResult {
state: Partial<GameState>;
@@ -38,10 +39,11 @@ export function processTick(state: GameState): Partial<GameState> {
const infraResult = processInfrastructure(state, researchBonuses);
const infrastructure = infraResult.infrastructure;
notifications.push(...infraResult.notifications);
if (infraResult.notifications.length > 0) notifications.push(...infraResult.notifications);
const stateWithInfra = { ...state, infrastructure };
const modelResult = processModels(stateWithInfra, researchBonuses);
// Build a mutable snapshot that accumulates updates through the tick
const snap: GameState = { ...state, infrastructure };
const modelResult = processModels(snap, researchBonuses);
for (const completed of modelResult.completedModels) {
notifications.push({
@@ -51,17 +53,17 @@ export function processTick(state: GameState): Partial<GameState> {
action: { label: 'Go to Families', page: 'models', modelsTab: 'models' },
});
}
notifications.push(...modelResult.notifications);
if (modelResult.notifications.length > 0) notifications.push(...modelResult.notifications);
const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState };
snap.models = modelResult.modelsState;
const capacity = computeCapacity(state, infrastructure, researchBonuses);
const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses);
const market = processMarket(snap, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses);
const compute = finalizeCompute(capacity, market.totalTokenDemand, state.compute.computeHistory, state.meta.tickCount);
const talent = processTalent(stateWithModels);
const stateWithTalent = { ...stateWithModels, talent };
const researchResult = processResearch(stateWithTalent, compute);
const talent = processTalent(snap);
snap.talent = talent;
const researchResult = processResearch(snap, compute);
if (researchResult.researchCompleted) {
notifications.push({
@@ -69,9 +71,22 @@ export function processTick(state: GameState): Partial<GameState> {
message: `${researchResult.researchCompleted} has been unlocked!`,
type: 'success',
});
const completedNode = TECH_TREE.find(n => n.id === researchResult.researchCompleted);
if (completedNode) {
for (const effect of completedNode.effects) {
if (effect.type === 'unlock_product_line') {
if (effect.target === 'code-assistant') {
market.marketState.codeAssistant = { ...market.marketState.codeAssistant, isUnlocked: true };
} else if (effect.target === 'agents-platform') {
market.marketState.agentsPlatform = { ...market.marketState.agentsPlatform, isUnlocked: true };
}
}
}
}
}
const reputationResult = processReputation(stateWithTalent, researchBonuses);
const reputationResult = processReputation(snap, researchBonuses);
const { _safetyIncident, ...reputation } = reputationResult;
if (_safetyIncident) {
notifications.push({
@@ -90,9 +105,9 @@ export function processTick(state: GameState): Partial<GameState> {
);
}
const extraCosts = infraResult.repairCosts + modelResult.legalCosts;
const economy = processEconomy(stateWithTalent, market, infrastructure, extraCosts);
const data = processData(stateWithTalent);
const competitors = processCompetitors(stateWithTalent);
const economy = processEconomy(snap, market, infrastructure, extraCosts);
const data = processData(snap);
const competitors = processCompetitors(snap);
const tickCount = state.meta.tickCount + 1;
@@ -103,7 +118,11 @@ export function processTick(state: GameState): Partial<GameState> {
totalPlayTime: state.meta.totalPlayTime + 1,
};
const newEra = checkEraTransition({ ...stateWithTalent, economy, reputation, research: researchResult.research });
snap.economy = economy;
snap.reputation = reputation;
snap.research = researchResult.research;
const newEra = checkEraTransition(snap);
if (newEra) {
meta = { ...meta, currentEra: newEra };
notifications.push({
@@ -113,29 +132,22 @@ export function processTick(state: GameState): Partial<GameState> {
});
}
const valuation = computeValuation({ ...stateWithTalent, economy, reputation, research: researchResult.research });
const valuation = computeValuation(snap);
const updatedEconomy = {
...economy,
funding: { ...economy.funding, valuation },
};
const stateForAchievements: GameState = {
...stateWithTalent,
meta,
economy: updatedEconomy,
infrastructure,
compute,
research: researchResult.research,
models: modelResult.modelsState,
market: market.marketState,
reputation,
data,
competitors,
achievements: state.achievements,
};
snap.meta = meta;
snap.economy = updatedEconomy;
snap.compute = compute;
snap.models = modelResult.modelsState;
snap.market = market.marketState;
snap.data = data;
snap.competitors = competitors;
const achievementResult = cachedAchievementDefs
? processAchievements(stateForAchievements, cachedAchievementDefs)
? processAchievements(snap, cachedAchievementDefs)
: { achievements: state.achievements, newAchievements: [] };
for (const name of achievementResult.newAchievements) {