Files
idle_clicker/public/js/engine/engine.js
T

193 lines
6.9 KiB
JavaScript

// 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).
import { computeModifiers } from './effects.js';
import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.js';
const MAX_STEP_SECONDS = 1;
// ---------------------------------------------------------------- conditions
export function isUnlocked(state, condition = {}) {
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) 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) {
const { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
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.batchSize,
);
return {
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),
};
}
// ------------------------------------------------------------------- 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;
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). */
export function generatorQuote(state, ctx, id, amount) {
const g = ctx.generatorsById.get(id);
const owned = state.generators[id] ?? 0;
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(g.baseCost, 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) {
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;
// 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;
}
/** Advance the simulation by `seconds`. Returns what happened during that time. */
export function advance(state, ctx, seconds, rng = Math.random) {
const totals = { produced: 0, sold: 0, revenue: 0 };
let left = Math.max(0, seconds);
while (left > 1e-9) {
const dt = Math.min(left, MAX_STEP_SECONDS);
step(state, ctx, dt, rng, totals);
left -= dt;
}
return totals;
}
/**
* Catch up on the time the game was closed. Deterministic (no price noise)
* and discounted by `offline.efficiency`. 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 seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
const totals = advance(state, ctx, seconds, () => 0.5);
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
}