192 lines
6.8 KiB
JavaScript
192 lines
6.8 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,
|
|
);
|
|
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) };
|
|
}
|