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
+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) };
}