feat: More bonus (exponential) and method to list icons needed

This commit is contained in:
gauvainboiche
2026-10-08 14:20:58 +02:00
parent fc0d5e3ba4
commit a88b8f5f14
25 changed files with 977 additions and 40 deletions
+8 -4
View File
@@ -118,7 +118,8 @@
'production'
'market'
'generators'
'upgrades';
'upgrades'
'endless';
}
.view {
@@ -134,6 +135,7 @@
.panel--market { grid-area: market; }
.panel--generators { grid-area: generators; }
.panel--upgrades { grid-area: upgrades; }
.panel--endless { grid-area: endless; }
/* Tablet */
@media (min-width: 760px) {
@@ -142,7 +144,8 @@
grid-template-areas:
'stats stats'
'production market'
'generators upgrades';
'generators upgrades'
'endless endless';
align-items: start;
}
@@ -159,11 +162,12 @@
@media (min-width: 1200px) {
.game {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr);
grid-template-rows: auto auto 1fr; /* spare height goes below the left column, not between its panels */
grid-template-rows: auto auto 1fr auto; /* spare height goes below the left column, not between its panels */
grid-template-areas:
'stats stats stats'
'production generators upgrades'
'market generators upgrades';
'market generators upgrades'
'endless endless endless';
}
.view {
+16
View File
@@ -0,0 +1,16 @@
[
{"id":"e_click","requires":"click_3","baseCost":6000000,"costGrowth":2.5,"maxLevel":10000,"effect":{"type":"click_mult","value":2}},
{"id":"e_gen1","requires":"g1_b","baseCost":600000,"costGrowth":2.6,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen1","value":1.5}},
{"id":"e_gen2","requires":"g2_b","baseCost":8000000,"costGrowth":2.6,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen2","value":1.5}},
{"id":"e_gen3","requires":"g3_b","baseCost":100000000,"costGrowth":2.6,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen3","value":1.5}},
{"id":"e_gen4","requires":"g4_b","baseCost":1200000000,"costGrowth":2.6,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen4","value":1.4}},
{"id":"e_gen5","requires":"g5_b","baseCost":16000000000,"costGrowth":2.7,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen5","value":1.3}},
{"id":"e_gen6","requires":"g6_b","baseCost":200000000000,"costGrowth":2.8,"maxLevel":10000,"effect":{"type":"generator_mult","target":"gen6","value":1.25}},
{"id":"e_global","requires":"global_4","baseCost":20000000000,"costGrowth":2.4,"maxLevel":10000,"effect":{"type":"global_mult","value":1.2}},
{"id":"e_demand","requires":"demand_4","baseCost":2000000000,"costGrowth":2.4,"maxLevel":10000,"effect":{"type":"demand_mult","value":1.2}},
{"id":"e_value","requires":"value_3","baseCost":1200000000,"costGrowth":2.4,"maxLevel":10000,"effect":{"type":"value_mult","value":1.15}},
{"id":"e_efficiency","requires":"mat_eff_2","baseCost":40000000,"costGrowth":2.5,"maxLevel":10000,"effect":{"type":"material_efficiency","value":1.2}},
{"id":"e_discount","requires":"mat_disc_2","baseCost":40000000,"costGrowth":2.5,"maxLevel":10000,"effect":{"type":"batch_discount","value":0.9}},
{"id":"e_stack","requires":"stack_5","baseCost":4000000000,"costGrowth":3.5,"maxLevel":10000,"effect":{"type":"batch_size_mult","value":2}},
{"id":"e_cost","requires":"g3_b","baseCost":2000000000,"costGrowth":2.4,"maxLevel":10000,"effect":{"type":"generator_cost_mult","value":0.95}}
]
+3
View File
@@ -121,6 +121,9 @@
<p class="hint" id="upgrades-empty" data-i18n="ui.noUpgrades"></p>
<ul class="cards" id="upgrades"></ul>
</section>
<!-- Endless upgrades: filled by js/ui/endlessView.js, hidden until a family of upgrades is complete -->
<section class="panel panel--endless" id="endless" hidden></section>
</div>
<!-- The other sections are built by js/ui/*View.js; they unlock with progress -->
+10
View File
@@ -29,6 +29,7 @@ export function buildContext({
achievements,
investments,
tabs,
endless,
}) {
const generatorsById = byId(generators, 'generator');
const upgradesById = byId(upgrades, 'upgrade');
@@ -36,6 +37,7 @@ export function buildContext({
const perksById = byId(legacy, 'legacy perk');
const achievementsById = byId(achievements, 'achievement');
const societiesById = byId(investments.societies, 'society');
const endlessById = byId(endless, 'endless line');
byId(tabs, 'tab');
for (const u of upgrades) {
@@ -50,6 +52,12 @@ export function buildContext({
if (!researchById.has(req)) throw new Error(`Research "${node.id}": unknown requirement "${req}"`);
}
}
for (const line of endless) {
checkEffect(`Endless "${line.id}"`, line.effect, generatorsById);
if (line.requires && !upgradesById.has(line.requires)) {
throw new Error(`Endless "${line.id}": unknown requirement "${line.requires}"`);
}
}
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) {
@@ -65,11 +73,13 @@ export function buildContext({
achievements,
investments,
tabs,
endless,
generatorsById,
upgradesById,
researchById,
perksById,
achievementsById,
societiesById,
endlessById,
};
}
+13 -1
View File
@@ -72,11 +72,23 @@ const APPLY = {
export const EFFECT_TYPES = Object.keys(APPLY);
// Endless upgrades stack without limit, so keep every result finite and sensible.
const CAP = 1e100; // low enough that products of several multipliers stay finite; real play never gets close
const FLOORS = { generatorCost: 0.05, batchDiscount: 0.001 };
function finalize(m) {
const keep = (value) => (Number.isFinite(value) ? Math.min(CAP, value) : CAP);
for (const key of Object.keys(m)) if (typeof m[key] === 'number') m[key] = keep(m[key]);
for (const id of Object.keys(m.generators)) m.generators[id] = keep(m.generators[id]);
for (const [key, floor] of Object.entries(FLOORS)) m[key] = Math.max(floor, m[key]);
return m;
}
/** Fold a list of `{ effect, times }` entries into one set of multipliers. */
export function foldEffects(entries) {
const m = newModifiers();
for (const { effect, times = 1 } of entries) {
if (effect && times > 0) APPLY[effect.type]?.(m, effect, times);
}
return m;
return finalize(m);
}
+32
View File
@@ -0,0 +1,32 @@
// Endless upgrades: once a family of upgrades is complete (its `requires`
// milestone is owned), it continues forever as a repeatable line. Every level
// multiplies the effect once more and costs exponentially more, up to
// `maxLevel` levels, which nobody will ever reach.
import { bulkCost, maxAffordable } from './formulas.js';
export const lineLevel = (state, id) => state.endless[id] ?? 0;
export function lineAvailable(state, line) {
return !line.requires || state.upgrades.includes(line.requires);
}
/** How many levels `amount` ("max" or a number) buys right now, and what they cost. */
export function endlessQuote(state, ctx, id, amount) {
const line = ctx.endlessById.get(id);
const level = lineLevel(state, id);
const wanted =
amount === 'max' ? maxAffordable(line.baseCost, line.costGrowth, level, state.funds) : amount;
const count = Math.max(0, Math.min(wanted, line.maxLevel - level));
return { count, cost: bulkCost(line.baseCost, line.costGrowth, level, count) };
}
export function buyEndless(state, ctx, id, amount = 1) {
const line = ctx.endlessById.get(id);
if (!line || !lineAvailable(state, line)) return false;
const { count, cost } = endlessQuote(state, ctx, id, amount);
if (count <= 0 || !(state.funds >= cost)) return false; // an infinite cost is never affordable
state.funds -= cost;
state.endless[id] = lineLevel(state, id) + count;
return true;
}
+21 -8
View File
@@ -99,11 +99,22 @@ export function click(state, ctx) {
return true;
}
/**
* What a material purchase gives right now: the whole lot when it is affordable,
* otherwise as much of it as the funds allow (same unit price). Partial lots
* make sure a player can never be locked out of buying material.
*/
export function materialPurchase(state, d) {
if (state.funds >= d.batchCost) return { amount: d.batchSize, cost: d.batchCost };
if (!(state.funds >= 0.01)) return { amount: 0, cost: 0 };
return { amount: (d.batchSize * state.funds) / d.batchCost, cost: state.funds };
}
export function buyMaterial(state, ctx) {
const d = derive(state, ctx);
if (state.funds < d.batchCost) return false;
state.funds -= d.batchCost;
state.material += d.batchSize;
const purchase = materialPurchase(state, derive(state, ctx));
if (purchase.amount <= 0) return false;
state.funds -= purchase.cost;
state.material += purchase.amount;
return true;
}
@@ -175,11 +186,13 @@ function step(state, ctx, dt, rng, totals, offline) {
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) {
// Automatic restock: patient (full lots, at a fair price) while stock is fine,
// urgent (whatever the funds allow) when nearly out.
if (d.autoBuyMaterial) {
const { autoBuyBelowFactor } = ctx.rules.materialMarket;
const low = state.material < d.batchSize / 2 && state.materialFactor <= autoBuyBelowFactor;
if (low || state.material < d.batchSize / 10) buyMaterial(state, ctx);
const patient =
state.material < d.batchSize / 2 && state.materialFactor <= autoBuyBelowFactor && state.funds >= d.batchCost;
if (patient || state.material < d.batchSize / 10) buyMaterial(state, ctx);
}
// Customers buy what they want, up to what is in stock.
+3
View File
@@ -8,6 +8,9 @@ export function collectEffects(state, ctx) {
const entries = [];
for (const id of state.upgrades) entries.push({ effect: ctx.upgradesById.get(id)?.effect });
for (const [id, level] of Object.entries(state.endless)) {
entries.push({ effect: ctx.endlessById.get(id)?.effect, times: level });
}
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 });
+11
View File
@@ -56,6 +56,7 @@ export function createState(ctx, now = Date.now()) {
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
endless: {},
invest: newInvest(ctx),
chips: 0,
reliabilityLevel: 0,
@@ -107,6 +108,15 @@ function sanitizeInvest(raw, ctx, base) {
return out;
}
function sanitizeEndless(raw, ctx) {
const levels = {};
for (const line of ctx.endless) {
const level = int(raw?.[line.id], line.maxLevel);
if (level > 0) levels[line.id] = level;
}
return levels;
}
function sanitizeContracts(raw, ctx) {
const templates = new Set(ctx.rules.contracts.templates.map((t) => t.id));
const rewardTypes = new Set(ctx.rules.contracts.rewards);
@@ -186,6 +196,7 @@ export function sanitizeState(raw, ctx, now = Date.now()) {
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators,
upgrades: knownIds(raw.upgrades, ctx.upgradesById),
endless: sanitizeEndless(raw.endless, ctx),
invest: sanitizeInvest(raw.invest, ctx, base.invest),
chips: int(raw.chips, gamble.maxChips),
reliabilityLevel: int(raw.reliabilityLevel, gamble.reliability.maxLevel),
+7 -1
View File
@@ -12,6 +12,7 @@ import {
setPrice,
simulateOffline,
} from './engine/engine.js';
import { buyEndless } from './engine/endless.js';
import { buyChip, buyReliability, gamble } from './engine/gamble.js';
import { buyShares, sellShares } from './engine/investments.js';
import { buyPerk, prestige } from './engine/legacy.js';
@@ -34,6 +35,7 @@ import { applyThemeStyles } from './services/themes.js';
import { createAchievementsView } from './ui/achievementsView.js';
import { createContractsView } from './ui/contractsView.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createEndlessView } from './ui/endlessView.js';
import { createGambleView } from './ui/gambleView.js';
import { createInvestView } from './ui/investView.js';
import { createLegacyView } from './ui/legacyView.js';
@@ -137,6 +139,7 @@ async function refreshTexts() {
applyTranslations();
document.title = t('theme.title');
view.rebuild(ctx, fmt);
endless.rebuild(ctx, fmt);
for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt);
tabs.rebuild();
refreshStaticIcons();
@@ -212,7 +215,8 @@ function render() {
const d = derive(state, ctx);
view.update(state, d, { income });
tabs.update(state);
if (tabs.active !== 'main') screens[tabs.active]?.update(state, d);
if (tabs.active === 'main') endless.update(state);
else screens[tabs.active]?.update(state, d);
}
// ------------------------------------------------------------------- actions
@@ -232,6 +236,8 @@ const view = createView({
nudgePrice: (delta) => act(setPrice)(state.price + delta),
});
const endless = createEndlessView($('#endless'), { buy: act(buyEndless) });
function restart() {
if (!confirm(t('legacy.confirm'))) return;
const gain = prestige(state, ctx);
+1
View File
@@ -18,6 +18,7 @@ export const DATA_FILES = {
achievements: 'achievements',
investments: 'investments',
tabs: 'tabs',
endless: 'endless',
};
export async function loadGameContext() {
+123
View File
@@ -0,0 +1,123 @@
// Endless upgrades: families of upgrades that continue forever, level after level.
import { endlessQuote, lineAvailable, lineLevel } from '../engine/endless.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { effectText, moneyFormatter, setText } from './text.js';
const AMOUNTS = [1, 10, 100, 'max'];
export function createEndlessView(root, actions) {
let ctx;
let fmt;
let money;
let amount = 1;
const rows = new Map();
const lineName = (line) =>
line.effect.target
? t('endless.name.generator', { target: t(`theme.gen.${line.effect.target}.name`) })
: t(`endless.name.${line.id}`);
function rowFor(line) {
const level = h('span', { class: 'badge' });
const total = h('p', { class: 'card__meta' });
const label = h('span', { class: 'btn__label' });
const cost = h('small');
const buy = h(
'button',
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buy(line.id, amount) },
label,
cost,
);
const li = h(
'li',
{ class: 'card', hidden: true },
icon(`endless.${line.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title' }, h('span', { text: lineName(line) }), level),
h('p', { class: 'card__desc', text: t('endless.perLevel', { effect: effectText(line.effect, fmt) }) }),
total,
),
buy,
);
return { li, level, total, label, cost, buy, line };
}
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
money = moneyFormatter(fmt);
rows.clear();
const list = h('ul', { class: 'cards cards--grid' });
for (const line of ctx.endless) {
const row = rowFor(line);
rows.set(line.id, row);
list.append(row.li);
}
const amountGroup = h(
'fieldset',
{ class: 'segmented' },
h('legend', { class: 'sr-only', text: t('endless.amount') }),
...AMOUNTS.map((a) =>
h(
'label',
{},
h('input', {
type: 'radio',
name: 'endless-amount',
value: String(a),
checked: a === amount,
onchange: () => {
amount = a;
},
}),
h('span', { text: a === 'max' ? t('ui.max') : `×${a}` }),
),
),
);
root.replaceChildren(
h(
'div',
{ class: 'panel__head' },
h('h2', { class: 'panel__title', text: t('endless.title') }),
amountGroup,
),
h('p', { class: 'hint', text: t('endless.intro') }),
list,
);
}
function update(state) {
let any = false;
for (const { li, level, total, label, cost, buy, line } of rows.values()) {
const open = lineAvailable(state, line);
li.hidden = !open;
if (!open) continue;
any = true;
const lv = lineLevel(state, line.id);
const maxed = lv >= line.maxLevel;
let quote = endlessQuote(state, ctx, line.id, amount);
if (quote.count <= 0) quote = endlessQuote(state, ctx, line.id, 1);
setText(level, t('endless.level', { level: fmt.number(lv) }));
setText(
total,
lv > 0
? t('endless.total', { effect: effectText({ ...line.effect, value: line.effect.value ** lv }, fmt) })
: t('endless.none'),
);
setText(label, maxed ? t('endless.maxed') : t('endless.buy', { count: fmt.number(quote.count) }));
setText(cost, maxed ? '' : money(quote.cost));
buy.disabled = maxed || quote.count <= 0 || !(state.funds >= quote.cost);
}
root.hidden = !any;
}
return { rebuild, update };
}
+5 -4
View File
@@ -1,7 +1,7 @@
// Main game view. Static markup lives in index.html; this module binds the
// controls once, builds the generator/upgrade lists, and refreshes the numbers.
import { generatorQuote, isUnlocked, upgradeVisible } from '../engine/engine.js';
import { generatorQuote, isUnlocked, materialPurchase, upgradeVisible } from '../engine/engine.js';
import { t } from '../services/i18n.js';
import { $, h, icon, refreshStaticIcons } from './dom.js';
@@ -232,15 +232,16 @@ export function createView(actions) {
setText(el.makeYield, t('ui.makeYield', { amount: fmt.number(d.clickYield) }));
el.make.disabled = state.material < d.materialPerUnit;
const purchase = materialPurchase(state, d); // a partial lot when funds are short
setText(
el.buyMaterialInfo,
t('ui.batchInfo', {
amount: fmt.number(d.batchSize),
cost: money(d.batchCost),
amount: fmt.number(Math.floor(purchase.amount || d.batchSize)),
cost: money(purchase.amount ? purchase.cost : d.batchCost),
trend: TREND[state.materialTrend] ?? '',
}),
);
el.buyMaterial.disabled = state.funds < d.batchCost;
el.buyMaterial.disabled = purchase.amount <= 0;
updateMarket(state, d);
updateGenerators(state, d);
+22
View File
@@ -302,5 +302,27 @@
"a_leg_2": "Eternal Return",
"a_evt_1": "Quick Hands"
}
},
"endless": {
"title": "Endless upgrades",
"intro": "Finish a family of upgrades and it carries on forever: every level multiplies its effect again and costs exponentially more. There is no real end.",
"amount": "Levels to buy",
"level": "Level {level}",
"perLevel": "Each level: {effect}",
"total": "Now: {effect}",
"none": "No level yet",
"buy": "Buy ×{count}",
"maxed": "Maxed",
"name": {
"generator": "{target} overhaul",
"e_click": "Manual mastery",
"e_global": "Total optimisation",
"e_demand": "Mass appeal",
"e_value": "Premium margin",
"e_efficiency": "{@theme.res.material} thrift",
"e_discount": "Hard bargaining",
"e_stack": "{@theme.res.material} stockpiling",
"e_cost": "Economies of scale"
}
}
}
+22
View File
@@ -302,5 +302,27 @@
"a_leg_2": "Éternel recommencement",
"a_evt_1": "Bons réflexes"
}
},
"endless": {
"title": "Améliorations sans fin",
"intro": "Terminez une famille d'améliorations et elle continue à l'infini : chaque niveau multiplie à nouveau son effet et coûte exponentiellement plus cher. Il n'y a pas vraiment de fin.",
"amount": "Niveaux à acheter",
"level": "Niveau {level}",
"perLevel": "Chaque niveau : {effect}",
"total": "Actuellement : {effect}",
"none": "Aucun niveau pour l'instant",
"buy": "Acheter ×{count}",
"maxed": "Au maximum",
"name": {
"generator": "Refonte : {target}",
"e_click": "Maîtrise manuelle",
"e_global": "Optimisation totale",
"e_demand": "Attrait de masse",
"e_value": "Marge premium",
"e_efficiency": "{@theme.res.material} : sobriété",
"e_discount": "Négociation acharnée",
"e_stack": "{@theme.res.material} : stockage massif",
"e_cost": "Économies d'échelle"
}
}
}