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
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buyEndless, endlessQuote, lineAvailable } from '../public/js/engine/endless.js';
import { advance, derive, generatorQuote } from '../public/js/engine/engine.js';
import { bulkCost } from '../public/js/engine/formulas.js';
import { prestige } from '../public/js/engine/legacy.js';
import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
import { loadContext } from './helpers.js';
const ctx = loadContext();
const fresh = () => createState(ctx, 1_000_000);
const line = (id) => ctx.endlessById.get(id);
/** A state that owns every milestone upgrade, so every endless line is open. */
function finished() {
const s = fresh();
s.upgrades = ctx.upgrades.map((u) => u.id);
return s;
}
test('a line opens only once its milestone upgrade is owned', () => {
const s = fresh();
assert.equal(lineAvailable(s, line('e_global')), false);
assert.equal(buyEndless(s, ctx, 'e_global'), false);
s.upgrades = ['global_4'];
s.funds = 1e12;
assert.equal(lineAvailable(s, line('e_global')), true);
assert.ok(buyEndless(s, ctx, 'e_global'));
assert.equal(s.endless.e_global, 1);
});
test('every milestone family continues in an endless line', () => {
const fed = new Set(ctx.endless.map((l) => l.requires));
for (const id of ['click_3', 'g1_b', 'g2_b', 'g3_b', 'g4_b', 'g5_b', 'g6_b', 'global_4', 'demand_4', 'value_3', 'mat_eff_2', 'mat_disc_2', 'stack_5']) {
assert.ok(fed.has(id), `${id} leads to an endless line`);
}
});
test('costs grow exponentially, level after level', () => {
const s = finished();
const l = line('e_global');
const costs = [0, 1, 2, 10, 50].map((level) => {
s.endless.e_global = level;
return endlessQuote(s, ctx, 'e_global', 1).cost;
});
assert.equal(costs[0], Math.ceil(l.baseCost));
for (let i = 1; i < costs.length; i++) assert.ok(costs[i] > costs[i - 1]);
assert.ok(Math.abs(costs[2] / costs[1] - l.costGrowth) < 0.001);
assert.ok(costs[4] / costs[0] > l.costGrowth ** 49);
});
test('buying several levels charges the exact sum', () => {
const s = finished();
s.funds = 1e13;
const l = line('e_demand');
const before = s.funds;
assert.ok(buyEndless(s, ctx, 'e_demand', 10));
assert.equal(s.endless.e_demand, 10);
assert.equal(before - s.funds, bulkCost(l.baseCost, l.costGrowth, 0, 10));
});
test('"max" buys every affordable level and no more', () => {
const s = finished();
s.funds = 1e15;
assert.ok(buyEndless(s, ctx, 'e_value', 'max'));
const level = s.endless.e_value;
assert.ok(level > 5);
assert.ok(s.funds >= 0);
assert.ok(endlessQuote(s, ctx, 'e_value', 1).cost > s.funds, 'the next level is out of reach');
});
test('each level multiplies the effect again', () => {
const s = finished();
s.generators.gen3 = 10;
const base = derive(s, ctx).rate;
s.endless.e_gen3 = 4;
assert.ok(Math.abs(derive(s, ctx).rate / base - 1.5 ** 4) < 1e-9);
s.endless.e_global = 3;
const global = line('e_global').effect.value;
assert.ok(Math.abs(derive(s, ctx).rate / base - 1.5 ** 4 * global ** 3) < 1e-9);
s.endless.e_demand = 5;
const demand = line('e_demand').effect.value;
assert.ok(Math.abs(derive(s, ctx).demandMultiplier / derive(finished(), ctx).demandMultiplier - demand ** 5) < 1e-9);
});
test('discount lines make material cheaper, cost lines make automation cheaper, with floors', () => {
const s = finished();
const batch = derive(s, ctx).batchCost;
const quote = generatorQuote(s, ctx, 'gen2', 1).cost;
s.endless.e_discount = 10;
s.endless.e_cost = 10;
assert.ok(derive(s, ctx).batchCost < batch);
assert.ok(generatorQuote(s, ctx, 'gen2', 1).cost < quote);
s.endless.e_cost = 10000;
s.endless.e_discount = 10000;
assert.ok(generatorQuote(s, ctx, 'gen2', 1).cost >= quote * 0.04, 'never free');
assert.ok(derive(s, ctx).batchCost >= 0.01);
});
test('the level cap is respected and an infinite price is never payable', () => {
const s = finished();
s.funds = 1e300;
s.endless.e_click = 9999;
assert.equal(endlessQuote(s, ctx, 'e_click', 50).count, 1, 'capped at the maximum level');
s.endless.e_click = 5000;
assert.equal(endlessQuote(s, ctx, 'e_click', 1).cost, Infinity);
assert.equal(buyEndless(s, ctx, 'e_click', 1), false);
s.endless.e_click = line('e_click').maxLevel;
assert.equal(buyEndless(s, ctx, 'e_click', 'max'), false);
});
test('even absurd levels never produce NaN or Infinity', () => {
const s = finished();
for (const l of ctx.endless) s.endless[l.id] = l.maxLevel;
for (const g of ctx.generators) s.generators[g.id] = 100;
s.marketing = 300;
const d = derive(s, ctx);
for (const key of ['rate', 'demand', 'demandMultiplier', 'valueMultiplier', 'batchSize', 'batchCost', 'materialPerUnit', 'clickYield']) {
assert.ok(Number.isFinite(d[key]), `${key} = ${d[key]}`);
}
for (const v of Object.values(d.perGenerator)) assert.ok(Number.isFinite(v));
advance(s, ctx, 5);
for (const key of ['units', 'funds', 'material', 'totalProduced']) {
assert.ok(!Number.isNaN(s[key]), `${key} is NaN`);
}
});
test('endless levels belong to the run: a legacy restart resets them', () => {
const s = finished();
s.endless.e_global = 12;
s.totalProduced = 1e10;
prestige(s, ctx);
assert.deepEqual(s.endless, {});
});
test('saved levels are cleaned up and survive a round trip', () => {
const s = finished();
s.endless = { e_global: 25, e_gen4: 7 };
const back = sanitizeState(JSON.parse(serializeState(s)), ctx);
assert.deepEqual(back.endless, { e_global: 25, e_gen4: 7 });
const dirty = sanitizeState({ endless: { e_global: 1e9, e_click: -3, nope: 5, e_gen1: 'x', e_value: 2.9 } }, ctx);
assert.deepEqual(dirty.endless, { e_global: line('e_global').maxLevel, e_value: 2 });
assert.deepEqual(sanitizeState({}, ctx).endless, {}, 'older saves have none');
});
test('growth stays open-ended without ever running away', () => {
// Income is the product of several lines, so what matters is the sum, over the
// lines that raise income, of (log of the effect) / (log of the cost growth),
// weighted by how much of income each one drives. Above 1 the economy blows up
// (it did, in an earlier tuning: 1e207/s after two hours); keep a margin below it.
const ratio = (l) => Math.log(l.effect.value) / Math.log(l.costGrowth);
const weights = { e_global: 2 / 3, e_demand: 1 / 3, e_value: 1 };
let sum = Object.entries(weights).reduce((acc, [id, w]) => acc + w * ratio(line(id)), 0);
// A generator line only drives its tier's share of production (equal counts per tier).
const totalRate = ctx.generators.reduce((acc, g) => acc + g.baseRate, 0);
for (const l of ctx.endless.filter((x) => x.effect.target)) {
sum += (ctx.generatorsById.get(l.effect.target).baseRate / totalRate) * ratio(l);
}
// The existing marketing levels also feed demand.
const marketing = Math.log(ctx.rules.marketing.demandPerLevel) / Math.log(ctx.rules.marketing.costGrowth);
sum += marketing / 3;
assert.ok(sum < 0.95, 'income-driving ratios add up to ' + sum.toFixed(2));
for (const l of ctx.endless) {
assert.ok(l.costGrowth > 1.5, l.id + ' must get costly steeply');
assert.equal(l.maxLevel, 10000);
}
});
+36
View File
@@ -9,6 +9,7 @@ import {
click,
derive,
generatorQuote,
materialPurchase,
setPrice,
simulateOffline,
upgradeVisible,
@@ -311,3 +312,38 @@ test('state survives a serialize / sanitize round trip', () => {
assert.equal(back.marketing, 3);
});
test('a partial lot can always be bought: material is never out of reach', () => {
const s = fresh();
s.upgrades = ['stack_1', 'stack_2', 'stack_3', 'stack_4', 'stack_5'];
s.endless = { e_stack: 30 }; // a lot far larger than any funds
s.generators.gen1 = 5;
s.material = 0;
s.funds = 50;
const d = derive(s, ctx);
assert.ok(d.batchCost > 1e6, 'the whole lot is out of reach');
const purchase = materialPurchase(s, d);
assert.equal(purchase.cost, 50, 'spends what it can');
assert.ok(Math.abs(purchase.amount / d.batchSize - 50 / d.batchCost) < 1e-12, 'proportional to the lot price');
assert.ok(buyMaterial(s, ctx));
assert.equal(s.funds, 0);
assert.ok(s.material > 0);
assert.equal(buyMaterial(s, ctx), false, 'nothing left to spend');
});
test('a whole lot is bought when affordable', () => {
const s = fresh();
s.funds = 1e6;
const d = derive(s, ctx);
assert.deepEqual(materialPurchase(s, d), { amount: d.batchSize, cost: d.batchCost });
});
test('auto-buy still restocks when the lot is out of reach (urgent partial buy)', () => {
const s = fresh();
s.upgrades = ['auto_buy'];
s.endless = { e_stack: 30 };
s.funds = 100;
s.material = 0;
advance(s, ctx, 0.1, () => 0.5);
assert.ok(s.material > 0);
});
+1 -13
View File
@@ -1,16 +1,4 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { buildContext } from '../public/js/engine/context.js';
import { DATA_FILES } from '../public/js/services/data.js';
export const PUBLIC_DIR = fileURLToPath(new URL('../public/', import.meta.url));
export const readJson = (relativePath) => JSON.parse(readFileSync(PUBLIC_DIR + relativePath, 'utf8'));
export function loadContext() {
const entries = Object.entries(DATA_FILES).map(([key, file]) => [key, readJson(`data/${file}.json`)]);
return buildContext(Object.fromEntries(entries));
}
export { loadContext, PUBLIC_DIR, readJson } from '../scripts/context.js';
/** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */
export function flatKeys(object, prefix = '') {
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { loadContext, PUBLIC_DIR } from './helpers.js';
import { README, withIconList } from '../scripts/icons.mjs';
import { allIconKeys, assetsSkeleton, iconGroups, THEME_IDS } from '../scripts/icon-keys.js';
const ctx = loadContext();
test('the icon list in the README is up to date (run: npm run icons -- --write)', () => {
const readme = readFileSync(README, 'utf8').replaceAll('\r\n', '\n');
assert.equal(readme, withIconList(readme, ctx));
});
test('the README lists every single icon key, once per theme row', () => {
const readme = readFileSync(README, 'utf8');
const keys = allIconKeys(ctx);
assert.equal(new Set(keys).size, keys.length, 'icon keys are unique');
for (const key of keys) assert.ok(readme.includes(`| \`${key}\` |`), key);
});
test('every theme names every themed icon (nothing falls back to a raw key)', () => {
const full = readFileSync(README, 'utf8');
const generated = full.slice(full.indexOf('<!-- icons:start'), full.indexOf('<!-- icons:end'));
assert.ok(generated.length > 5000, 'the generated list is there');
assert.ok(!/\| [a-z]+(\.[a-z_0-9]+)+ \|/.test(generated.replace(/`[^`]*`/g, '')), 'no unresolved text key in a table');
assert.ok(!generated.includes('{@'), 'embedded theme words are resolved');
assert.ok(!/\{target\}|\{value\}/.test(generated), 'placeholders are resolved');
assert.ok(THEME_IDS.length >= 5);
});
test('the interface only uses icon keys that are documented', () => {
const documented = new Set(iconGroups(ctx).map((group) => group.id));
const used = new Set();
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith('.js')) {
// icon('gen.x') and icon(`inv.${id}`) calls, outside the helper's own definition
if (path.endsWith('ui/dom.js')) continue;
for (const m of readFileSync(path, 'utf8').matchAll(/\bicon\(\s*[`'"]([a-z]+)\./g)) used.add(m[1]);
}
}
};
walk(`${PUBLIC_DIR}js`);
for (const m of readFileSync(`${PUBLIC_DIR}index.html`, 'utf8').matchAll(/data-icon="([a-z]+)\./g)) used.add(m[1]);
assert.ok(used.size >= 7, 'the scan found the icons');
const undocumented = [...used].filter((prefix) => !documented.has(prefix));
assert.deepEqual(undocumented, [], `icons used but not listed in the README: ${undocumented.join(', ')}`);
});
test('the generated manifest covers every key with a real file name', () => {
const assets = assetsSkeleton(ctx);
assert.deepEqual(Object.keys(assets), allIconKeys(ctx));
assert.equal(assets['gen.gen1'], 'img/gen-gen1.svg');
assert.ok(Object.values(assets).every((file) => /^img\/[a-z0-9_-]+\.svg$/.test(file)));
});
test('the paths the game reads from theme.json are the same keys', () => {
// dom.js looks assets up by the exact key, so a skeleton entry must resolve as-is.
const domSource = readFileSync(fileURLToPath(new URL('../public/js/ui/dom.js', import.meta.url)), 'utf8');
assert.ok(domSource.includes('assets[key]'));
});