// Game rules. Every function takes the mutable `state` and the static `ctx` // (see context.js) and never touches the DOM, so it runs the same in the // browser and in Node (tests, balance simulations). // // This file holds the production/market loop and ties the other systems // (investments, research, contracts, events, achievements...) into time. import { checkAchievements } from './achievements.js'; import { stepContracts, stepEvents } from './contracts.js'; import { balancedPrice, bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable, } from './formulas.js'; import { stepInvestments } from './investments.js'; import { modifiersFor } from './modifiers.js'; import { rpPerSecond, stepResearch } from './research.js'; const MAX_STEP_SECONDS = 1; // ---------------------------------------------------------------- conditions export function isUnlocked(state, condition = {}) { if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false; if (condition.lifetimeProduced != null && state.lifetimeProduced < condition.lifetimeProduced) return false; if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) { return false; } return true; } export function upgradeVisible(state, upgrade) { if (state.upgrades.includes(upgrade.id)) return false; if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false; return isUnlocked(state, upgrade.unlock); } // ------------------------------------------------------------------- derived /** Everything computed from the state (rates, multipliers, current prices). */ export function derive(state, ctx, mods = modifiersFor(state, ctx)) { const { rules } = ctx; const perGenerator = {}; let rate = 0; for (const g of ctx.generators) { perGenerator[g.id] = g.baseRate * (mods.generators[g.id] ?? 1) * mods.global; rate += perGenerator[g.id] * (state.generators[g.id] ?? 0); } const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing; const batch = materialBatch( rules.materialMarket, state.totalProduced, rate, state.materialFactor, mods.batchDiscount * mods.materialPrice, mods.batchSize, ); return { mods, clickYield: rules.click.yield * mods.click, perGenerator, rate, demandMultiplier, demand: demandPerSecond(rules.market, state.price, demandMultiplier), valueMultiplier: mods.value, materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency, batchSize: batch.size, batchCost: batch.cost, autoBuyMaterial: mods.autoBuyMaterial, marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing), rpRate: rpPerSecond(state, ctx, mods), // Only researched: the price where demand matches output (see formulas.balancedPrice). advisedPrice: mods.priceAdvisor ? clamp( balancedPrice(rules.market, Math.max(rate, 1e-9), demandMultiplier), rules.market.minPrice, rules.market.maxPrice, ) : null, }; } // ------------------------------------------------------------------- actions // Each returns true when the action was performed. export function click(state, ctx) { const d = derive(state, ctx); if (state.material < d.materialPerUnit) return false; const made = Math.min(d.clickYield, state.material / d.materialPerUnit); state.material -= made * d.materialPerUnit; state.units += made; state.totalProduced += made; state.lifetimeProduced += made; return true; } export function buyMaterial(state, ctx) { const d = derive(state, ctx); if (state.funds < d.batchCost) return false; state.funds -= d.batchCost; state.material += d.batchSize; return true; } export function buyMarketing(state, ctx) { const d = derive(state, ctx); if (state.funds < d.marketingCost) return false; state.funds -= d.marketingCost; state.marketing += 1; return true; } /** How many generators the player gets for `amount` ("max" or a number), and what it costs. */ export function generatorQuote(state, ctx, id, amount, mods = modifiersFor(state, ctx)) { const g = ctx.generatorsById.get(id); const owned = state.generators[id] ?? 0; const base = g.baseCost * mods.generatorCost; const count = amount === 'max' ? maxAffordable(base, g.costGrowth, owned, state.funds) : amount; return { count, cost: bulkCost(base, g.costGrowth, owned, count) }; } export function buyGenerator(state, ctx, id, amount = 1) { if (!ctx.generatorsById.has(id)) return false; const { count, cost } = generatorQuote(state, ctx, id, amount); if (count <= 0 || state.funds < cost) return false; state.funds -= cost; state.generators[id] += count; return true; } export function buyUpgrade(state, ctx, id) { const upgrade = ctx.upgradesById.get(id); if (!upgrade || !upgradeVisible(state, upgrade) || state.funds < upgrade.cost) return false; state.funds -= upgrade.cost; state.upgrades.push(id); return true; } export function setPrice(state, ctx, price) { const { minPrice, maxPrice } = ctx.rules.market; if (!Number.isFinite(price)) return false; state.price = Math.round(clamp(price, minPrice, maxPrice) * 100) / 100; return true; } // ---------------------------------------------------------------------- time function stepMaterialMarket(state, ctx, dt, rng) { const m = ctx.rules.materialMarket; state.materialTimer += dt; while (state.materialTimer >= m.updateEverySeconds) { state.materialTimer -= m.updateEverySeconds; let shock = 1 + (rng() - 0.5) * 2 * m.volatility; if (rng() < m.spikeChance) shock *= rng() < 0.5 ? m.spikeSize : 1 / m.spikeSize; // shortage or glut const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion; const next = clamp(pulled * shock, m.minFactor, m.maxFactor); state.materialTrend = Math.sign(next - state.materialFactor); state.materialFactor = next; } } function step(state, ctx, dt, rng, totals, offline) { stepMaterialMarket(state, ctx, dt, rng); const d = derive(state, ctx); // Production, limited by the material in stock. const made = Math.min(d.rate * dt, state.material / d.materialPerUnit); state.material -= made * d.materialPerUnit; state.units += made; state.totalProduced += made; state.lifetimeProduced += made; // Automatic restock: patient while the price is high, urgent when nearly out. if (d.autoBuyMaterial && state.funds >= d.batchCost) { const { autoBuyBelowFactor } = ctx.rules.materialMarket; const low = state.material < d.batchSize / 2 && state.materialFactor <= autoBuyBelowFactor; if (low || state.material < d.batchSize / 10) buyMaterial(state, ctx); } // Customers buy what they want, up to what is in stock. const sold = Math.min(state.units, d.demand * dt); const revenue = sold * state.price * d.valueMultiplier; state.units -= sold; state.funds += revenue; state.totalSold += sold; totals.produced += made; totals.sold += sold; totals.revenue += revenue; // The other systems. stepInvestments(state, ctx, dt, rng, d.mods); stepResearch(state, ctx, dt, d.mods); stepContracts(state, ctx, dt, sold, d, d.mods, rng, offline, totals); stepEvents(state, ctx, dt, d, rng, offline, totals); state.gambleCooldown = Math.max(0, state.gambleCooldown - dt); for (const id of checkAchievements(state, ctx)) totals.notices.push({ kind: 'achievement', id }); } /** * Advance the simulation by `seconds`. Returns what happened during that time, * including `notices` (things worth telling the player). `offline` skips the * parts that need the player present (contracts, events). */ export function advance(state, ctx, seconds, rng = Math.random, { offline = false } = {}) { const totals = { produced: 0, sold: 0, revenue: 0, notices: [] }; let left = Math.max(0, seconds); while (left > 1e-9) { const dt = Math.min(left, MAX_STEP_SECONDS); step(state, ctx, dt, rng, totals, offline); left -= dt; } return totals; } /** * Catch up on the time the game was closed. Deterministic (no price noise), * discounted by the offline efficiency (research and legacy raise it). * Returns null if too short to matter. */ export function simulateOffline(state, ctx, elapsedSeconds) { const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline; if (elapsedSeconds < minSeconds) return null; const effective = Math.min(1, efficiency + modifiersFor(state, ctx).offline); const seconds = Math.min(elapsedSeconds, maxSeconds) * effective; const totals = advance(state, ctx, seconds, () => 0.5, { offline: true }); return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) }; }