247 lines
9.2 KiB
JavaScript
247 lines
9.2 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).
|
|
//
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* What a material purchase gives right now: the whole lot when it is affordable,
|
|
* otherwise as much of it as the funds allow (same unit price). Partial lots
|
|
* make sure a player can never be locked out of buying material.
|
|
*/
|
|
export function materialPurchase(state, d) {
|
|
if (state.funds >= d.batchCost) return { amount: d.batchSize, cost: d.batchCost };
|
|
if (!(state.funds >= 0.01)) return { amount: 0, cost: 0 };
|
|
return { amount: (d.batchSize * state.funds) / d.batchCost, cost: state.funds };
|
|
}
|
|
|
|
export function buyMaterial(state, ctx) {
|
|
const purchase = materialPurchase(state, derive(state, ctx));
|
|
if (purchase.amount <= 0) return false;
|
|
state.funds -= purchase.cost;
|
|
state.material += purchase.amount;
|
|
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 (full lots, at a fair price) while stock is fine,
|
|
// urgent (whatever the funds allow) when nearly out.
|
|
if (d.autoBuyMaterial) {
|
|
const { autoBuyBelowFactor } = ctx.rules.materialMarket;
|
|
const patient =
|
|
state.material < d.batchSize / 2 && state.materialFactor <= autoBuyBelowFactor && state.funds >= d.batchCost;
|
|
if (patient || 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) };
|
|
}
|