feat: More bonus (exponential) and method to list icons needed
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 = '') {
|
||||
|
||||
@@ -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]'));
|
||||
});
|
||||
Reference in New Issue
Block a user