feat: New features (research, invest, gambling, new game+, trophies)
This commit is contained in:
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+58
-16
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user