feat: New features (research, invest, gambling, new game+, trophies)

This commit is contained in:
gauvainboiche
2026-10-08 14:03:00 +02:00
parent 1caa4d92b9
commit fc0d5e3ba4
56 changed files with 4332 additions and 249 deletions
+35
View File
@@ -0,0 +1,35 @@
// Achievements: permanent small bonuses for milestones. Each one reads a
// single statistic, so adding one is a line in data/achievements.json (plus
// its title in the core locale files).
export function statValue(state, stat) {
switch (stat) {
case 'lifetimeProduced':
return state.lifetimeProduced;
case 'generatorsOwned':
return Object.values(state.generators).reduce((sum, n) => sum + n, 0);
case 'upgradesOwned':
return state.upgrades.length;
case 'researchOwned':
return state.research.length;
case 'prestiges':
return state.legacy.prestiges;
case 'chips':
return state.chips;
default:
return state.stats[stat] ?? 0; // investProfit, gambleWins, contractsDone, eventsClaimed...
}
}
/** Grant every achievement whose condition is now met. Returns the new ids. */
export function checkAchievements(state, ctx) {
const granted = [];
for (const a of ctx.achievements) {
if (state.achievements.includes(a.id)) continue;
if (statValue(state, a.condition.stat) >= a.condition.min) {
state.achievements.push(a.id);
granted.push(a.id);
}
}
return granted;
}
+64 -16
View File
@@ -1,27 +1,75 @@
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]));
function byId(list, what) {
const map = new Map(list.map((item) => [item.id, item]));
if (map.size !== list.length) throw new Error(`Duplicate ${what} id`);
return map;
}
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
function checkEffect(owner, effect, generatorsById) {
if (!EFFECT_TYPES.includes(effect?.type)) {
throw new Error(`${owner}: unknown effect type "${effect?.type}"`);
}
if (effect.target && !generatorsById.has(effect.target)) {
throw new Error(`${owner}: unknown generator "${effect.target}"`);
}
}
/**
* Bundle the static game data (public/data/*.json) with lookup tables and
* validate it, so a typo in a JSON file fails loudly at startup (and in the
* tests) instead of mid-game.
*/
export function buildContext({
rules,
generators,
upgrades,
research,
legacy,
achievements,
investments,
tabs,
}) {
const generatorsById = byId(generators, 'generator');
const upgradesById = byId(upgrades, 'upgrade');
const researchById = byId(research, 'research');
const perksById = byId(legacy, 'legacy perk');
const achievementsById = byId(achievements, 'achievement');
const societiesById = byId(investments.societies, 'society');
byId(tabs, 'tab');
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}"`);
}
checkEffect(`Upgrade "${u.id}"`, u.effect, generatorsById);
if (u.requires && !upgradesById.has(u.requires)) {
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
}
}
for (const node of research) {
checkEffect(`Research "${node.id}"`, node.effect, generatorsById);
for (const req of node.requires) {
if (!researchById.has(req)) throw new Error(`Research "${node.id}": unknown requirement "${req}"`);
}
}
for (const perk of legacy) checkEffect(`Perk "${perk.id}"`, perk.effect, generatorsById);
for (const a of achievements) checkEffect(`Achievement "${a.id}"`, a.effect, generatorsById);
for (const s of investments.societies) {
if (!investments.tiers[s.tier]) throw new Error(`Society "${s.id}": unknown tier "${s.tier}"`);
}
return { rules, generators, upgrades, generatorsById, upgradesById };
return {
rules,
generators,
upgrades,
research,
legacy,
achievements,
investments,
tabs,
generatorsById,
upgradesById,
researchById,
perksById,
achievementsById,
societiesById,
};
}
+144
View File
@@ -0,0 +1,144 @@
// Contracts and random events: the "active play" layer.
//
// Contracts: up to `maxOffers` offers are on the board; accept one and sell
// `target` units before the time runs out (a bit more than your current sales
// pace, so it takes some effort) for a reward. Events: now and then an
// opportunity appears for a short window and must be claimed; rarer incidents
// (like a price crash for the raw material) just happen.
export function salesEstimate(state, d) {
const rate = Math.max(Math.min(d.rate, d.demand), 1);
return { rate, income: rate * state.price * d.valueMultiplier };
}
/** A random element, safe even if rng() ever returns exactly 1. */
function pick(list, rng) {
return list[Math.min(list.length - 1, Math.floor(rng() * list.length))];
}
// ------------------------------------------------------------------ contracts
function makeOffer(state, ctx, d, mods, rng) {
const cfg = ctx.rules.contracts;
const template = pick(cfg.templates, rng);
const type = pick(cfg.rewards, rng);
const { rate, income } = salesEstimate(state, d);
const factor = template.rewardFactor * mods.contract;
let amount;
if (type === 'funds') amount = income * template.seconds * factor;
else if (type === 'research') amount = Math.max(1, Math.round(cfg.researchPerFactor * factor));
else if (type === 'chips') amount = Math.max(1, Math.round(cfg.chipsPerFactor * factor));
else amount = 1 + (cfg.boostMultiplier - 1) * Math.min(2, template.rewardFactor); // boost: a multiplier
return {
template: template.id,
seconds: template.seconds,
target: Math.ceil(rate * template.seconds * template.targetFactor),
reward: { type, amount },
};
}
function grantReward(state, ctx, offer) {
const { type, amount } = offer.reward;
if (type === 'funds') state.funds += amount;
else if (type === 'research') state.rp += amount;
else if (type === 'chips') state.chips = Math.min(state.chips + amount, ctx.rules.gamble.maxChips);
else state.events.buffs.push({ type: 'global_mult', value: amount, remaining: offer.seconds });
}
/** Take offer number `index` from the board. Only one contract can be active. */
export function acceptContract(state, ctx, index) {
const c = state.contracts;
if (c.active || !c.offers[index]) return false;
const [offer] = c.offers.splice(index, 1);
c.active = { offer, progress: 0, remaining: offer.seconds };
return true;
}
export function abandonContract(state) {
if (!state.contracts.active) return false;
state.contracts.active = null;
return true;
}
export function stepContracts(state, ctx, dt, sold, d, mods, rng, offline, totals) {
if (offline) return;
const c = state.contracts;
const cfg = ctx.rules.contracts;
if (c.active) {
c.active.progress += sold;
c.active.remaining -= dt;
if (c.active.progress >= c.active.offer.target) {
grantReward(state, ctx, c.active.offer);
totals.notices.push({ kind: 'contract_done', reward: c.active.offer.reward, seconds: c.active.offer.seconds });
state.stats.contractsDone += 1;
c.active = null;
} else if (c.active.remaining <= 0) {
totals.notices.push({ kind: 'contract_failed' });
c.active = null;
}
}
state.contractTimer += dt;
if (state.contractTimer >= cfg.offerEverySeconds) {
state.contractTimer = 0;
c.offers.push(makeOffer(state, ctx, d, mods, rng));
if (c.offers.length > cfg.maxOffers) c.offers.shift(); // the oldest offer expires
}
}
// --------------------------------------------------------------------- events
function pickEventType(cfg, rng) {
const total = cfg.types.reduce((sum, t) => sum + t.weight, 0);
let roll = rng() * total;
for (const type of cfg.types) {
roll -= type.weight;
if (roll < 0) return type;
}
return cfg.types[0];
}
export function stepEvents(state, ctx, dt, d, rng, offline, totals) {
const ev = state.events;
const cfg = ctx.rules.events;
for (const buff of ev.buffs) buff.remaining -= dt;
ev.buffs = ev.buffs.filter((buff) => buff.remaining > 0);
if (offline) return;
if (ev.current) {
ev.current.remaining -= dt;
if (ev.current.remaining <= 0) ev.current = null;
}
state.eventTimer -= dt;
if (state.eventTimer > 0 || ev.current) return;
state.eventTimer = cfg.minSeconds + rng() * (cfg.maxSeconds - cfg.minSeconds);
const type = pickEventType(cfg, rng);
if (type.auto) {
ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds });
totals.notices.push({ kind: 'event_incident', type: type.id });
} else {
ev.current = { type: type.id, remaining: cfg.claimSeconds };
totals.notices.push({ kind: 'event_opportunity', type: type.id });
}
}
/** Claim the opportunity currently on offer. */
export function claimEvent(state, ctx, d) {
const ev = state.events;
if (!ev.current) return false;
const cfg = ctx.rules.events;
const type = cfg.types.find((t) => t.id === ev.current.type);
if (type.buff) ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds });
else state.funds += salesEstimate(state, d).income * cfg.windfallIncomeSeconds;
state.stats.eventsClaimed += 1;
ev.current = null;
return true;
}
+58 -16
View File
@@ -1,5 +1,7 @@
// 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.
// Effects: the single vocabulary used by upgrades, research, legacy perks,
// achievements, chips and temporary buffs. To add a new effect type: register
// it here, add its `effect.<type>` text to the core locale files, then use it
// in any data file.
export function newModifiers() {
return {
@@ -12,29 +14,69 @@ export function newModifiers() {
batchDiscount: 1,
batchSize: 1,
autoBuyMaterial: false,
dividend: 1,
reliability: 0,
offline: 0,
contract: 1,
rp: 1,
generatorCost: 1,
startFunds: 0,
priceAdvisor: false,
materialPrice: 1,
legacyGain: 1,
};
}
// `times` lets a repeatable effect (a perk level, N achievements) stack.
function mult(key) {
return (m, e, times) => {
m[key] *= e.value ** times;
};
}
function add(key) {
return (m, e, times) => {
m[key] += e.value * times;
};
}
function flag(key) {
return (m) => {
m[key] = true;
};
}
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),
batch_size_mult: (m, e) => void (m.batchSize *= e.value),
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
click_mult: mult('click'),
generator_mult: (m, e, times) => {
m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value ** times;
},
global_mult: mult('global'),
demand_mult: mult('demand'),
value_mult: mult('value'),
material_efficiency: mult('materialEfficiency'),
batch_discount: mult('batchDiscount'),
batch_size_mult: mult('batchSize'),
auto_buy_material: flag('autoBuyMaterial'),
dividend_mult: mult('dividend'),
reliability_add: add('reliability'),
offline_add: add('offline'),
contract_mult: mult('contract'),
rp_mult: mult('rp'),
generator_cost_mult: mult('generatorCost'),
start_funds: add('startFunds'),
price_advisor: flag('priceAdvisor'),
material_price_mult: mult('materialPrice'),
legacy_gain_mult: mult('legacyGain'),
};
export const EFFECT_TYPES = Object.keys(APPLY);
/** Fold every owned upgrade into a single set of multipliers. */
export function computeModifiers(ownedIds, upgradesById) {
/** Fold a list of `{ effect, times }` entries into one set of multipliers. */
export function foldEffects(entries) {
const m = newModifiers();
for (const id of ownedIds) {
const effect = upgradesById.get(id)?.effect;
if (effect) APPLY[effect.type]?.(m, effect);
for (const { effect, times = 1 } of entries) {
if (effect && times > 0) APPLY[effect.type]?.(m, effect, times);
}
return m;
}
+59 -18
View File
@@ -1,9 +1,23 @@
// 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 { computeModifiers } from './effects.js';
import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.js';
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;
@@ -11,6 +25,7 @@ const MAX_STEP_SECONDS = 1;
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;
}
@@ -26,9 +41,8 @@ export function upgradeVisible(state, upgrade) {
// ------------------------------------------------------------------- derived
/** Everything computed from the state (rates, multipliers, current prices). */
export function derive(state, ctx) {
export function derive(state, ctx, mods = modifiersFor(state, ctx)) {
const { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
const perGenerator = {};
let rate = 0;
@@ -43,10 +57,11 @@ export function derive(state, ctx) {
state.totalProduced,
rate,
state.materialFactor,
mods.batchDiscount,
mods.batchDiscount * mods.materialPrice,
mods.batchSize,
);
return {
mods,
clickYield: rules.click.yield * mods.click,
perGenerator,
rate,
@@ -58,6 +73,15 @@ export function derive(state, ctx) {
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,
};
}
@@ -71,6 +95,7 @@ export function click(state, ctx) {
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
state.lifetimeProduced += made;
return true;
}
@@ -90,12 +115,13 @@ export function buyMarketing(state, ctx) {
return true;
}
/** How many generators the player gets for `amount` ("max" or a number). */
export function generatorQuote(state, ctx, id, amount) {
/** 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 count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
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) {
@@ -138,7 +164,7 @@ function stepMaterialMarket(state, ctx, dt, rng) {
}
}
function step(state, ctx, dt, rng, totals) {
function step(state, ctx, dt, rng, totals, offline) {
stepMaterialMarket(state, ctx, dt, rng);
const d = derive(state, ctx);
@@ -147,6 +173,7 @@ function step(state, ctx, dt, rng, totals) {
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
state.lifetimeProduced += made;
// Automatic restock: patient while the price is high, urgent when nearly out.
if (d.autoBuyMaterial && state.funds >= d.batchCost) {
@@ -165,28 +192,42 @@ function step(state, ctx, dt, rng, totals) {
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. */
export function advance(state, ctx, seconds, rng = Math.random) {
const totals = { produced: 0, sold: 0, revenue: 0 };
/**
* 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);
step(state, ctx, dt, rng, totals, offline);
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.
* 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 seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
const totals = advance(state, ctx, seconds, () => 0.5);
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) };
}
+8
View File
@@ -4,6 +4,14 @@ export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/**
* Demand bonus from owning chips: diminishing returns (logarithmic), so a big
* lucky streak can never run away with the game.
*/
export function chipDemandBonus(chips, gamble) {
return 1 + gamble.chipBonusScale * Math.log2(1 + Math.max(0, chips));
}
/** Price of the next item when `owned` items are already owned. */
export function unitCost(base, growth, owned) {
return base * growth ** owned;
+77
View File
@@ -0,0 +1,77 @@
// The gamble (a "quantum computer" in the first theme): chips are a second
// currency, hard to buy (the price rises with every chip owned) and hard to
// grow. Stake chips on a cycle: win and the stake doubles, hit a jackpot and it
// is multiplied, lose it, or crash and lose more than you bet. Reliability
// upgrades (paid in funds) tilt the odds. Chips give a diminishing demand bonus.
import { clamp } from './formulas.js';
const MAX_EXPONENT = 200;
export function chipCost(state, ctx) {
const g = ctx.rules.gamble;
return Math.ceil(g.baseChipCost * g.chipCostGrowth ** Math.min(state.chips, MAX_EXPONENT));
}
export function reliabilityCost(state, ctx) {
const r = ctx.rules.gamble.reliability;
return Math.ceil(r.baseCost * r.costGrowth ** state.reliabilityLevel);
}
/** Probabilities of each outcome of one cycle. They always sum to 1. */
export function odds(state, ctx, mods) {
const g = ctx.rules.gamble;
const crash = g.crashChance;
const jackpot = g.jackpotChance;
const reliability = g.baseWin + state.reliabilityLevel * g.reliability.perLevel + mods.reliability;
const win = clamp(reliability, 0.05, g.maxWin - crash - jackpot);
return { crash, jackpot, win, lose: 1 - crash - jackpot - win };
}
export function buyChip(state, ctx) {
const cost = chipCost(state, ctx);
if (state.funds < cost || state.chips >= ctx.rules.gamble.maxChips) return false;
state.funds -= cost;
state.chips += 1;
return true;
}
export function buyReliability(state, ctx) {
if (state.reliabilityLevel >= ctx.rules.gamble.reliability.maxLevel) return false;
const cost = reliabilityCost(state, ctx);
if (state.funds < cost) return false;
state.funds -= cost;
state.reliabilityLevel += 1;
return true;
}
/**
* Stake `stake` chips on one cycle. Returns { outcome, stake, delta } or null
* when it cannot be played (no chips, nothing staked, still cooling down).
*/
export function gamble(state, ctx, stake, mods, rng = Math.random) {
const g = ctx.rules.gamble;
const bet = Math.min(Math.floor(stake), state.chips);
if (!(bet >= 1) || state.gambleCooldown > 0) return null;
const p = odds(state, ctx, mods);
const roll = rng();
let outcome = 'lose';
let delta = -bet;
if (roll < p.crash) {
outcome = 'crash';
delta = -Math.min(state.chips, Math.ceil(bet * g.crashMultiplier));
} else if (roll < p.crash + p.jackpot) {
outcome = 'jackpot';
delta = bet * g.jackpotMultiplier;
} else if (roll < p.crash + p.jackpot + p.win) {
outcome = 'win';
delta = bet;
}
state.chips = clamp(state.chips + delta, 0, g.maxChips);
state.gambleCooldown = g.cooldownSeconds;
if (delta > 0) state.stats.gambleWins += 1;
else state.stats.gambleLosses += 1;
return { outcome, stake: bet, delta };
}
+75
View File
@@ -0,0 +1,75 @@
// Investments: nine societies in three risk tiers. Every `intervalSeconds` each
// price moves by a random percentage within the tier's swing (low ±5 %, mid
// ±15 %, high ±50 %), then drifts back towards its fair value (more weakly for the wilder tiers), and
// holdings pay a small dividend. Buying and selling carries a small fee.
import { clamp } from './formulas.js';
export function holdingValue(state, id) {
return state.invest.holdings[id] * state.invest.prices[id];
}
export function portfolio(state, ctx) {
let value = 0;
let basis = 0;
for (const s of ctx.investments.societies) {
value += holdingValue(state, s.id);
basis += state.invest.basis[s.id];
}
return { value, basis, profit: value - basis };
}
function repriceAll(state, ctx, rng, dividendMultiplier) {
const cfg = ctx.investments;
const inv = state.invest;
for (const s of cfg.societies) {
const tier = cfg.tiers[s.tier];
const before = inv.prices[s.id];
let price = before * (1 + (rng() * 2 - 1) * tier.swing);
price += (s.basePrice - price) * tier.reversion;
price = clamp(price, s.basePrice * cfg.minRatio, s.basePrice * cfg.maxRatio);
inv.prices[s.id] = price;
inv.change[s.id] = price / before - 1;
state.funds += inv.holdings[s.id] * price * tier.dividend * dividendMultiplier;
}
}
export function stepInvestments(state, ctx, dt, rng, mods) {
state.investTimer += dt;
while (state.investTimer >= ctx.investments.intervalSeconds) {
state.investTimer -= ctx.investments.intervalSeconds;
repriceAll(state, ctx, rng, mods.dividend);
}
}
/** Spend `fraction` of the current funds on society `id`. */
export function buyShares(state, ctx, id, fraction) {
if (!ctx.societiesById.has(id)) return false;
const spend = state.funds * clamp(fraction, 0, 1);
if (!(spend >= 0.01)) return false;
state.funds -= spend;
state.invest.holdings[id] += (spend * (1 - ctx.investments.fee)) / state.invest.prices[id];
state.invest.basis[id] += spend;
return true;
}
/** Sell `fraction` of what is held in society `id`. */
export function sellShares(state, ctx, id, fraction) {
if (!ctx.societiesById.has(id)) return false;
const inv = state.invest;
const share = clamp(fraction, 0, 1);
if (!(inv.holdings[id] > 0) || share <= 0) return false;
const proceeds = inv.holdings[id] * share * inv.prices[id] * (1 - ctx.investments.fee);
const basisPart = inv.basis[id] * share;
state.funds += proceeds;
state.stats.investProfit += proceeds - basisPart;
if (share >= 1) {
inv.holdings[id] = 0;
inv.basis[id] = 0;
} else {
inv.holdings[id] -= inv.holdings[id] * share;
inv.basis[id] -= basisPart;
}
return true;
}
+52
View File
@@ -0,0 +1,52 @@
// Legacy (prestige): restart the run for permanent points. Points are spent on
// repeatable perks, and every point ever earned also gives a small passive bonus.
// What survives a restart: see META_KEYS in state.js.
import { modifiersFor } from './modifiers.js';
import { createState, META_KEYS } from './state.js';
/** Points a restart would grant right now (0 until the run is big enough). */
export function legacyGain(state, ctx, mods) {
const { threshold } = ctx.rules.legacy;
if (state.totalProduced < threshold) return 0;
return Math.floor(Math.sqrt(state.totalProduced / threshold) * mods.legacyGain);
}
export function perkLevel(state, perk) {
return state.legacy.perks[perk.id] ?? 0;
}
export function perkCost(perk, level) {
return Math.ceil(perk.baseCost * perk.costGrowth ** level);
}
export function buyPerk(state, ctx, id) {
const perk = ctx.perksById.get(id);
if (!perk) return false;
const level = perkLevel(state, perk);
const cost = perkCost(perk, level);
if (level >= perk.maxLevel || state.legacy.points < cost) return false;
state.legacy.points -= cost;
state.legacy.perks[id] = level + 1;
return true;
}
/** Restart the run in place. Returns the points gained, or 0 if not allowed. */
export function prestige(state, ctx, now = Date.now()) {
const gain = legacyGain(state, ctx, modifiersFor(state, ctx));
if (gain < 1) return 0;
const fresh = createState(ctx, now);
const kept = Object.fromEntries(META_KEYS.map((key) => [key, state[key]]));
kept.legacy = {
...state.legacy,
points: state.legacy.points + gain,
earned: state.legacy.earned + gain,
prestiges: state.legacy.prestiges + 1,
};
Object.assign(state, fresh, kept);
// Perks like "head start" shape the new run.
state.funds += modifiersFor(state, ctx).startFunds;
return gain;
}
+36
View File
@@ -0,0 +1,36 @@
// Gathers every source of bonuses for the current state and folds them.
import { foldEffects } from './effects.js';
import { chipDemandBonus } from './formulas.js';
/** All `{ effect, times }` entries that apply to `state`. */
export function collectEffects(state, ctx) {
const entries = [];
for (const id of state.upgrades) entries.push({ effect: ctx.upgradesById.get(id)?.effect });
for (const id of state.research) entries.push({ effect: ctx.researchById.get(id)?.effect });
for (const id of state.achievements) entries.push({ effect: ctx.achievementsById.get(id)?.effect });
for (const [id, level] of Object.entries(state.legacy.perks)) {
entries.push({ effect: ctx.perksById.get(id)?.effect, times: level });
}
// Every legacy point ever earned gives a small passive bonus.
const passive = 1 + ctx.rules.legacy.passivePerPoint;
entries.push({ effect: { type: 'global_mult', value: passive }, times: state.legacy.earned });
entries.push({ effect: { type: 'demand_mult', value: passive }, times: state.legacy.earned });
// Chips make the brand more attractive, with diminishing returns.
entries.push({
effect: { type: 'demand_mult', value: chipDemandBonus(state.chips, ctx.rules.gamble) },
});
// Temporary buffs from events and contracts.
for (const buff of state.events.buffs) entries.push({ effect: { type: buff.type, value: buff.value } });
return entries;
}
export function modifiersFor(state, ctx) {
return foldEffects(collectEffects(state, ctx));
}
+38
View File
@@ -0,0 +1,38 @@
// Research: labs (bought with funds) produce research points slowly over time;
// points buy nodes of a small tree. Research survives a legacy restart.
export function labCost(state, ctx) {
const r = ctx.rules.research;
return Math.ceil(r.labBaseCost * r.labCostGrowth ** state.labs);
}
export function rpPerSecond(state, ctx, mods) {
return state.labs * ctx.rules.research.rpPerLabPerSecond * mods.rp;
}
export function buyLab(state, ctx) {
const { labMax } = ctx.rules.research;
const cost = labCost(state, ctx);
if (state.labs >= labMax || state.funds < cost) return false;
state.funds -= cost;
state.labs += 1;
return true;
}
/** 'owned' | 'available' | 'locked' */
export function researchStatus(state, node) {
if (state.research.includes(node.id)) return 'owned';
return node.requires.every((id) => state.research.includes(id)) ? 'available' : 'locked';
}
export function buyResearch(state, ctx, id) {
const node = ctx.researchById.get(id);
if (!node || researchStatus(state, node) !== 'available' || state.rp < node.cost) return false;
state.rp -= node.cost;
state.research.push(id);
return true;
}
export function stepResearch(state, ctx, dt, mods) {
state.rp += rpPerSecond(state, ctx, mods) * dt;
}
+161 -13
View File
@@ -1,28 +1,85 @@
import { clamp } from './formulas.js';
export const STATE_VERSION = 1;
export const STATE_VERSION = 2;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300;
const MAX_LEVEL = 1000;
const STAT_KEYS = ['gambleWins', 'gambleLosses', 'contractsDone', 'eventsClaimed', 'investProfit'];
// Fields that only exist while the game runs and are never saved.
const TRANSIENT = [
'materialTimer',
'materialTrend',
'investTimer',
'contractTimer',
'eventTimer',
'gambleCooldown',
];
// What a legacy reset keeps (the "meta" progression); everything else is the run.
export const META_KEYS = [
'lifetimeProduced',
'stats',
'research',
'labs',
'rp',
'legacy',
'achievements',
];
function newInvest(ctx) {
const ids = ctx.investments.societies.map((s) => s.id);
return {
prices: Object.fromEntries(ctx.investments.societies.map((s) => [s.id, s.basePrice])),
holdings: Object.fromEntries(ids.map((id) => [id, 0])),
basis: Object.fromEntries(ids.map((id) => [id, 0])),
change: Object.fromEntries(ids.map((id) => [id, 0])),
};
}
export function createState(ctx, now = Date.now()) {
const { start, market } = ctx.rules;
const { start, market, events, contracts } = ctx.rules;
return {
v: STATE_VERSION,
// --- the run (reset by a legacy restart)
units: start.units,
funds: start.funds,
material: start.material,
totalProduced: 0,
totalSold: 0,
price: start.price ?? market.basePrice,
price: start.price ?? market.referencePrice,
marketing: 0,
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
invest: newInvest(ctx),
chips: 0,
reliabilityLevel: 0,
contracts: { offers: [], active: null },
events: { current: null, buffs: [] },
// --- meta progression (survives a legacy restart)
lifetimeProduced: 0,
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, 0])),
research: [],
labs: 0,
rp: 0,
legacy: { points: 0, earned: 0, prestiges: 0, perks: {} },
achievements: [],
savedAt: now,
materialTimer: 0, // transient, never saved
materialTrend: 0, // transient: -1 falling, 0 steady, 1 rising
// --- transient
materialTimer: 0,
materialTrend: 0, // -1 falling, 0 steady, 1 rising
investTimer: 0,
contractTimer: contracts.offerEverySeconds,
eventTimer: events.minSeconds,
gambleCooldown: 0,
};
}
@@ -34,37 +91,128 @@ function int(value, max) {
return Math.floor(num(value, 0, 0, max));
}
function knownIds(list, known) {
return [...new Set(Array.isArray(list) ? list : [])].filter((id) => known.has(id));
}
function sanitizeInvest(raw, ctx, base) {
const inv = ctx.investments;
const out = newInvest(ctx);
for (const s of inv.societies) {
out.prices[s.id] = num(raw?.prices?.[s.id], base.prices[s.id], s.basePrice * inv.minRatio, s.basePrice * inv.maxRatio);
out.holdings[s.id] = num(raw?.holdings?.[s.id], 0);
out.basis[s.id] = out.holdings[s.id] > 0 ? num(raw?.basis?.[s.id], 0) : 0;
out.change[s.id] = num(raw?.change?.[s.id], 0, -1, 100);
}
return out;
}
function sanitizeContracts(raw, ctx) {
const templates = new Set(ctx.rules.contracts.templates.map((t) => t.id));
const rewardTypes = new Set(ctx.rules.contracts.rewards);
const offer = (o) => {
if (!o || !templates.has(o.template) || !rewardTypes.has(o.reward?.type)) return null;
return {
template: o.template,
seconds: num(o.seconds, 60, 1, 3600),
target: num(o.target, 1, 1),
reward: { type: o.reward.type, amount: num(o.reward.amount, 0) },
};
};
const offers = (Array.isArray(raw?.offers) ? raw.offers : []).map(offer).filter(Boolean);
const active = offer(raw?.active?.offer);
return {
offers: offers.slice(0, ctx.rules.contracts.maxOffers),
active: active
? {
offer: active,
progress: num(raw.active.progress, 0, 0, active.target),
remaining: num(raw.active.remaining, active.seconds, 0, active.seconds),
}
: null,
};
}
function sanitizeEvents(raw, ctx) {
const types = new Map(ctx.rules.events.types.map((t) => [t.id, t]));
const current = raw?.current;
const buffTypes = new Set(['demand_mult', 'global_mult', 'material_price_mult']);
return {
current:
current && types.has(current.type) && !types.get(current.type).auto
? { type: current.type, remaining: num(current.remaining, 0, 0, ctx.rules.events.claimSeconds) }
: null,
buffs: (Array.isArray(raw?.buffs) ? raw.buffs : [])
.filter((b) => b && buffTypes.has(b.type) && Number.isFinite(b.value) && Number.isFinite(b.remaining))
.slice(0, 20)
.map((b) => ({
type: b.type,
value: clamp(b.value, 0.01, 100),
remaining: clamp(b.remaining, 0, 3600),
})),
};
}
/**
* 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.
* Saves from older versions are upgraded: missing fields take their defaults.
*/
export function sanitizeState(raw, ctx, now = Date.now()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket } = ctx.rules;
const { market, materialMarket, gamble } = ctx.rules;
const base = createState(ctx, now);
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
const generators = Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
);
const totalProduced = num(raw.totalProduced, 0);
const perks = {};
for (const perk of ctx.legacy) {
const level = int(raw.legacy?.perks?.[perk.id], perk.maxLevel);
if (level > 0) perks[perk.id] = level;
}
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),
totalProduced,
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)),
generators,
upgrades: knownIds(raw.upgrades, ctx.upgradesById),
invest: sanitizeInvest(raw.invest, ctx, base.invest),
chips: int(raw.chips, gamble.maxChips),
reliabilityLevel: int(raw.reliabilityLevel, gamble.reliability.maxLevel),
contracts: sanitizeContracts(raw.contracts, ctx),
events: sanitizeEvents(raw.events, ctx),
// Saves from before lifetime tracking: the current run is all we know.
lifetimeProduced: Math.max(num(raw.lifetimeProduced, 0), totalProduced),
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, num(raw.stats?.[k], 0)])),
research: knownIds(raw.research, ctx.researchById),
labs: int(raw.labs, ctx.rules.research.labMax),
rp: num(raw.rp, 0),
legacy: {
points: int(raw.legacy?.points, MAX_LEVEL * 100),
earned: int(raw.legacy?.earned, MAX_LEVEL * 100),
prestiges: int(raw.legacy?.prestiges, MAX_LEVEL),
perks,
},
achievements: knownIds(raw.achievements, ctx.achievementsById),
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, materialTrend, ...persisted } = state;
const persisted = { ...state };
for (const key of TRANSIENT) delete persisted[key];
return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);