feat: New features (research, invest, gambling, new game+, trophies)
This commit is contained in:
+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