feat: First iteration, multi themes, I18N

This commit is contained in:
gauvainboiche
2026-10-08 11:58:18 +02:00
parent 9240fa3c3c
commit aa9721ac2a
53 changed files with 3363 additions and 1 deletions
+27
View File
@@ -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 };
}
+38
View File
@@ -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;
}
+179
View File
@@ -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) };
}
+37
View File
@@ -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;
}
+70
View File
@@ -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,
);
}