feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { EFFECT_TYPES } from './effects.js';
|
||||
|
||||
/**
|
||||
* Bundle the static game data (data/*.json) with lookup tables and validate it,
|
||||
* so a typo in a JSON file fails loudly at startup instead of mid-game.
|
||||
*/
|
||||
export function buildContext({ rules, generators, upgrades }) {
|
||||
const generatorsById = new Map(generators.map((g) => [g.id, g]));
|
||||
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
|
||||
|
||||
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
|
||||
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
|
||||
|
||||
for (const u of upgrades) {
|
||||
if (!EFFECT_TYPES.includes(u.effect?.type)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
|
||||
}
|
||||
if (u.effect.target && !generatorsById.has(u.effect.target)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
|
||||
}
|
||||
if (u.requires && !upgradesById.has(u.requires)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return { rules, generators, upgrades, generatorsById, upgradesById };
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
// Upgrade effects. To add a new effect type: register it here, add its
|
||||
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
|
||||
|
||||
export function newModifiers() {
|
||||
return {
|
||||
click: 1,
|
||||
generators: {},
|
||||
global: 1,
|
||||
demand: 1,
|
||||
value: 1,
|
||||
materialEfficiency: 1,
|
||||
batchDiscount: 1,
|
||||
autoBuyMaterial: false,
|
||||
};
|
||||
}
|
||||
|
||||
const APPLY = {
|
||||
click_mult: (m, e) => void (m.click *= e.value),
|
||||
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
|
||||
global_mult: (m, e) => void (m.global *= e.value),
|
||||
demand_mult: (m, e) => void (m.demand *= e.value),
|
||||
value_mult: (m, e) => void (m.value *= e.value),
|
||||
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
|
||||
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
|
||||
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
|
||||
};
|
||||
|
||||
export const EFFECT_TYPES = Object.keys(APPLY);
|
||||
|
||||
/** Fold every owned upgrade into a single set of multipliers. */
|
||||
export function computeModifiers(ownedIds, upgradesById) {
|
||||
const m = newModifiers();
|
||||
for (const id of ownedIds) {
|
||||
const effect = upgradesById.get(id)?.effect;
|
||||
if (effect) APPLY[effect.type]?.(m, effect);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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, 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;
|
||||
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: rules.materialMarket.batchSize,
|
||||
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount),
|
||||
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;
|
||||
const shock = 1 + (rng() - 0.5) * 2 * m.volatility;
|
||||
const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion;
|
||||
state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor);
|
||||
}
|
||||
}
|
||||
|
||||
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 when the stock runs low.
|
||||
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) {
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Pure math helpers. No state, no DOM.
|
||||
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Price of the next item when `owned` items are already owned. */
|
||||
export function unitCost(base, growth, owned) {
|
||||
return base * growth ** owned;
|
||||
}
|
||||
|
||||
/** Total price of buying `count` more items (geometric series, rounded up). */
|
||||
export function bulkCost(base, growth, owned, count) {
|
||||
if (count <= 0) return 0;
|
||||
return Math.ceil((unitCost(base, growth, owned) * (growth ** count - 1)) / (growth - 1));
|
||||
}
|
||||
|
||||
/** How many items `budget` can pay for, robust against float error. */
|
||||
export function maxAffordable(base, growth, owned, budget) {
|
||||
const first = unitCost(base, growth, owned);
|
||||
if (Number.isNaN(budget) || budget < first) return 0;
|
||||
let n = Math.floor(Math.log((budget * (growth - 1)) / first + 1) / Math.log(growth));
|
||||
while (n > 0 && bulkCost(base, growth, owned, n) > budget) n--;
|
||||
while (bulkCost(base, growth, owned, n + 1) <= budget) n++;
|
||||
return Math.max(n, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Units customers want to buy per second at `price`.
|
||||
* Demand is maxed at or below the fair price and drops steeply above it, so
|
||||
* extra production is only worth selling at a higher price while supply is
|
||||
* short; to sell more at the fair price, the player needs more demand.
|
||||
*/
|
||||
export function demandPerSecond(market, price, multiplier) {
|
||||
const overpriced = Math.max(1, price / market.fairPrice);
|
||||
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { clamp } from './formulas.js';
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
const MAX_NUMBER = 1e300;
|
||||
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
|
||||
const MAX_MARKETING = 300;
|
||||
|
||||
export function createState(ctx, now = Date.now()) {
|
||||
const { start, market } = ctx.rules;
|
||||
return {
|
||||
v: STATE_VERSION,
|
||||
units: start.units,
|
||||
funds: start.funds,
|
||||
material: start.material,
|
||||
totalProduced: 0,
|
||||
totalSold: 0,
|
||||
price: start.price ?? market.basePrice,
|
||||
marketing: 0,
|
||||
materialFactor: 1,
|
||||
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
|
||||
upgrades: [],
|
||||
savedAt: now,
|
||||
materialTimer: 0, // transient, never saved
|
||||
};
|
||||
}
|
||||
|
||||
function num(value, fallback, min = 0, max = MAX_NUMBER) {
|
||||
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
|
||||
}
|
||||
|
||||
function int(value, max) {
|
||||
return Math.floor(num(value, 0, 0, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn untrusted data (cookie, imported file) into a valid state.
|
||||
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
|
||||
*/
|
||||
export function sanitizeState(raw, ctx, now = Date.now()) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const { market, materialMarket } = ctx.rules;
|
||||
const base = createState(ctx, now);
|
||||
|
||||
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
|
||||
return {
|
||||
...base,
|
||||
units: num(raw.units, base.units),
|
||||
funds: num(raw.funds, base.funds),
|
||||
material: num(raw.material, base.material),
|
||||
totalProduced: num(raw.totalProduced, 0),
|
||||
totalSold: num(raw.totalSold, 0),
|
||||
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
|
||||
marketing: int(raw.marketing, MAX_MARKETING),
|
||||
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
|
||||
generators: Object.fromEntries(
|
||||
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
|
||||
),
|
||||
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
|
||||
savedAt: num(raw.savedAt, now, 0, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
|
||||
export function serializeState(state) {
|
||||
const { materialTimer, ...persisted } = state;
|
||||
return JSON.stringify(persisted, (_key, value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user