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
+1
View File
@@ -9,3 +9,4 @@ Dockerfile
docker-compose.yml
README.md
LICENSE
scripts
+194 -8
View File
@@ -71,13 +71,7 @@ Missing keys fall back to English, then to the raw key. Strings accept `{param}`
## Images
Every icon is currently a grey square. To use real images, put files in the theme folder and list them in its `theme.json`:
```json
{ "order": 1, "assets": { "gen.gen1": "img/autoclipper.svg", "res.units": "img/clip.svg", "upg.click_1": "img/bender.svg" } }
```
Keys: `res.units|funds|material`, `gen.<id>`, `upg.<id>`. Anything not listed keeps the placeholder.
Every icon is currently a grey square. See [Icons to produce](#icons-to-produce) at the end of this file for the exhaustive list, the format and how to plug images in.
## Saves
@@ -88,7 +82,199 @@ Keys: `res.units|funds|material`, `gen.<id>`, `upg.<id>`. Anything not listed ke
## Tuning the game
Everything numeric is in `public/data/`: `rules.json` (market, raw material, marketing, offline, autosave), `generators.json`, `upgrades.json`.
Everything numeric is in `public/data/`: `rules.json` (market, raw material, marketing, offline, autosave), `generators.json`, `upgrades.json`, `endless.json`.
- **Endless upgrades:** the 36 one-time upgrades are milestones. When a family is complete (manual work, each automation tier, global output, demand, sale value, material efficiency, discounts, stack size), it continues as a repeatable line in `endless.json`: each level multiplies the effect once more (`effect.value`) and costs `baseCost × costGrowth ^ level`, up to `maxLevel` (10 000, which cannot be reached: costs overflow long before). Costs must climb much faster than effects. Income is the *product* of several lines, so if the ratios `ln(effect) / ln(costGrowth)` of the income-driving lines add up to more than 1, the economy explodes (an early tuning reached 1e207 units/s in two hours). A test guards this sum. Endless levels belong to the run: a legacy restart resets them.
- **Material lots** can be bought in part: if the whole lot costs more than your funds, you get as much as the funds buy, at the same unit price, so a player can never be locked out of material.
- **Selling:** demand is a smooth curve, `baseDemand × demandMultiplier × (referencePrice / price) ^ elasticity` units/s: lower the price and more people buy, with no ceiling. The best price is where demand just matches your output (selling for less gains no volume, selling for more leaves stock unsold). That point moves with every generator, upgrade and marketing level, and the game does not display it: the market hint only says whether you are selling too cheap, too dear, or about right.
- **Raw material** (wire, herbs…): the price per unit starts high (`startUnitPrice`, about a third of the sale price) and falls towards `floorUnitPrice` as total production grows (economies of scale). A lot covers about 10 s of current output (in doublings), multiplied by the "stack" upgrades (`batch_size_mult`, a chain of five, ×180 in total). A lot costs in proportion to its size, so bigger stacks cost more per purchase while the price per unit still falls with scale. The market is erratic: the price is re-drawn every 10 s (`updateEverySeconds`) with wide random swings plus frequent shortages and gluts (`spikeChance`, `spikeSize`), and is shown to the cent, with ▲/▼ for its last move. The auto-buy upgrade is patient: it waits for a fair price unless stock is nearly gone.
## Icons to produce
Every icon the game shows is a grey square until a theme provides an image. Each theme needs the **same set of icons, drawn in its own style**: the list below is generated from the game data, so it is always complete.
**Format.** One square image per icon: SVG preferred (or PNG at 128×128 px or more), transparent background, readable at 28 px (top bar) and 48 px (lists). Put the files in `public/themes/<theme>/img/`.
**Plugging them in.** List each file in the theme's `theme.json`; anything not listed keeps the grey square, so images can be added a few at a time:
```json
{ "order": 1, "assets": { "res.units": "img/res-units.svg", "gen.gen1": "img/gen-gen1.svg" } }
```
`npm run icons -- --manifest` prints a complete `assets` skeleton (every key mapped to `img/<key>.svg`) to start from. `npm run icons` prints the list below, and `npm run icons -- --write` refreshes it in this file; `npm test` fails if it is out of date or if the interface uses an icon key that is not listed.
The tables give what each icon represents in each theme (the name used in the game, in English).
<!-- icons:start (generated by `npm run icons -- --write`, do not edit by hand) -->
**115 icons per theme, 575 in total** (5 themes: `paperclip`, `witch`, `alien`, `insects`, `computers`).
| Group | Icons per theme | Shown in |
|---|---:|---|
| Resources | 6 | top bar (units, funds, material), Gamble, Research and Legacy screens |
| Automation tiers | 6 | Production screen, Automation list |
| Upgrades | 36 | Production screen, Upgrades list |
| Investment societies | 9 | Investments screen |
| Endless upgrades | 14 | Production screen, Endless upgrades panel |
| Research | 15 | Research screen |
| Legacy perks | 6 | Legacy screen |
| Achievements | 23 | Achievements screen |
| **Total** | **115** | |
#### Resources (6)
| Key | `paperclip` | `witch` | `alien` | `insects` | `computers` |
|---|---|---|---|---|---|
| `res.units` | Paperclips | Potions | Xenocrystals | Leaves | CPU Power |
| `res.funds` | Funds | Gold | Credits | Honeydew | GalliumCoin |
| `res.material` | Wire | Herbs | Raw ore | Silk | Energy (W) |
| `res.chips` | Quantum Chips | Fate Tokens | Chance Crystals | Pollen Grains | Qubits |
| `res.research` | R&D Points | Arcane Insight | Xeno-Data | Instinct | Algorithms |
| `res.legacy` | Patents | Grimoire Pages | Blueprints | Colony Memories | Legacy Code |
#### Automation tiers (6)
| Key | `paperclip` | `witch` | `alien` | `insects` | `computers` |
|---|---|---|---|---|---|
| `gen.gen1` | Autoclipper | Apprentice | Mining Drone | Worker Ant | Old Laptop |
| `gen.gen2` | MegaClipper | Bubbling Cauldron | Plasma Drill | Leafcutter Squad | Gaming PC |
| `gen.gen3` | Clip Factory | Alchemy Lab | Orbital Extractor | Beetle Hauler | GPU Mining Rig |
| `gen.gen4` | Hypno Drones | Broomstick Fleet | Asteroid Harvester | Termite Mound | Server Rack |
| `gen.gen5` | Probe Swarm | Moonlit Coven | Hive Swarm | Locust Swarm | Data Center |
| `gen.gen6` | Matter Converter | Philosopher's Engine | Planet Cracker | Hive Queen | Quantum Mainframe |
#### Upgrades (36)
| Key | `paperclip` | `witch` | `alien` | `insects` | `computers` |
|---|---|---|---|---|---|
| `upg.click_1` | Ergonomic Bender | Steady Stirring | Gravity Gloves | Sharper Mandibles | Mechanical Keyboard |
| `upg.click_2` | Pneumatic Grip | Enchanted Ladle | Exo-Suit | Pheromone Trail | Keyboard Macros |
| `upg.click_3` | Robotic Arm | Familiar's Help | Neural Link | Double-Grip Legs | Neural Interface |
| `upg.g1_a` | Sharper Autoclippers | Apprentice Lessons | Drone Firmware | Ant Drill Sergeant | Thermal Paste |
| `upg.g1_b` | Overclocked Autoclippers | Apprentice Mastery | Drone Swarm Logic | Marching Songs | Fresh OS Install |
| `upg.g2_a` | MegaClipper Servos | Copper Cauldrons | Diamond Bits | Wider Leaf Slices | Liquid Cooling |
| `upg.g2_b` | MegaClipper Turbines | Eternal Flame | Plasma Focusing | Leafcutter Guild | Overclocking |
| `upg.g3_a` | Assembly Line | Crystal Alembics | Tether Cables | Reinforced Shells | Custom BIOS |
| `upg.g3_b` | Just-in-Time Logistics | Rune-Etched Tables | Zero-G Smelting | Dung Beetle Logistics | Memory Timing Tuning |
| `upg.g4_a` | Hypno Harmonics | Waxed Brooms | Mass Drivers | Air-Conditioned Chambers | Hot-Swap Drives |
| `upg.g4_b` | Persuasion Algorithms | Hex-Powered Flight | Autonomous Prospecting | Deep Tunnel Network | Load Balancer |
| `upg.g5_a` | Probe Self-Replication | Full Moon Rites | Hive Mind Sync | Riding the Wind | Redundant Power Lines |
| `upg.g5_b` | Von Neumann Protocols | Blood Moon Pact | Queen Directive | Migration Instinct | Free Cooling (Arctic Site) |
| `upg.g6_a` | Atomic Reassembly | Transmutation Circles | Tectonic Resonance | Royal Jelly | Cryogenic Coolant |
| `upg.g6_b` | Universal Reformatting | The Fifth Element | Core Shattering | Queen's Decree | Entangled Qubits |
| `upg.global_1` | Quality Control | Spellbook Index | Quantum Diagnostics | Dewdrop Hydration | Firmware Update |
| `upg.global_2` | Lean Manufacturing | Grandmother's Grimoire | Alien Alloys | Seasonal Rhythm | Parallel Computing |
| `upg.global_3` | Industrial AI | Ley Line Tap | Dyson Tap | Hive Mind | Learning Scheduler |
| `upg.global_4` | Quantum Scheduling | Time Dilation Charm | Spacetime Folding | Perfect Spring | Distributed Grid |
| `upg.demand_1` | Newspaper Ad | Village Notice Board | Trade Beacon | Scent Markers | Forum Hype |
| `upg.demand_2` | Radio Campaign | Market Stall | Interstellar Broker | Cricket Courier | Tech Influencers |
| `upg.demand_3` | Viral Video | Royal Commission | Galactic Exchange | Forest Market | Exchange Listing |
| `upg.demand_4` | Global Brand | Legend of the Witch | Cosmic Monopoly | Famous Pile | Global Hype Cycle |
| `upg.value_1` | Gold-Plated Paperclips | Glittering Labels | Purity Certificate | Dewy Gloss | Shiny Whitepaper |
| `upg.value_2` | Premium Packaging | Apothecary Seal | Rare Spectrum Cut | Autumn Colours | Celebrity Endorsement |
| `upg.value_3` | Luxury Branding | Royal Warrant | Imperial Grade | Golden Leaf Grade | To The Moon |
| `upg.mat_eff_1` | Thinner Wire | Careful Pruning | Ore Analyzer | Tighter Weaving | Efficient PSU |
| `upg.mat_eff_2` | Wire Recycling | Distilled Essence | Molecular Sieve | Silk Recycling | Low-Power Chips |
| `upg.mat_disc_1` | Bulk Wire Contract | Forager's Pact | Asteroid Contract | Spider Contract | Off-Peak Contract |
| `upg.mat_disc_2` | Direct From the Mill | Enchanted Garden | Own the Mine | Silkworm Farm | Own Solar Farm |
| `upg.stack_1` | Bigger Spools | Larger Baskets | Bigger Cargo Pods | Bigger Silk Bundles | Bigger Batteries |
| `upg.stack_2` | Pallet Orders | Herb Sacks | Container Drones | Cocoon Sacks | Capacitor Banks |
| `upg.stack_3` | Container Loads | Cartloads | Freighter Holds | Web Warehouses | Grid Substation |
| `upg.stack_4` | Freight Trains | Greenhouse Harvest | Convoy Fleets | Spider Caravans | Dedicated Power Line |
| `upg.stack_5` | Wire Mills | Whole-Forest Rights | Orbital Stockpile | Great Silk Hoard | Private Power Plant |
| `upg.auto_buy` | WireBuyer | Self-Gathering Basket | Autonomous Freighter | Spider Delivery Service | Smart Power Meter |
#### Investment societies (9)
| Key | `paperclip` | `witch` | `alien` | `insects` | `computers` |
|---|---|---|---|---|---|
| `inv.inv_low_1` | Steady Staples Inc. | Old Hollow Apothecary | Zeta Mining Cooperative | Anthill Savings Bank | Stable Silicon Bank |
| `inv.inv_low_2` | Office Supply Trust | Greywood Herb Co-op | Orion Ore Trust | Honeycomb Trust | ByteSafe Trust |
| `inv.inv_low_3` | Copper Wire Bond | Village Cauldron Guild | Lunar Refinery Bond | Leafy Greens Co-op | Mainframe Heritage Fund |
| `inv.inv_mid_1` | Binder Dynamics | Moonpetal Brewers | XenoCorp Industries | Dung Beetle Logistics Ltd | NeoChip Holdings |
| `inv.inv_mid_2` | Clip Logistics | Nightshade & Sons | Nebula Extraction Ltd | Termite Tower Builders | CloudNine Hosting |
| `inv.inv_mid_3` | Stationery Futures | Crooked Broom Trading | Void Cargo Syndicate | Cricket Courier Corp | Packet Dynamics |
| `inv.inv_high_1` | NanoWire Labs | Dragonscale Elixirs | Hyperion Rift Labs | Locust Swarm Capital | MoonShot Mining DAO |
| `inv.inv_high_2` | Paperless Dreams Ltd | Wyrmwood Potions Ltd | Singularity Salvage | Wasp Venture Fund | HypeCoin Labs |
| `inv.inv_high_3` | Hyper-Clip Ventures | Forbidden Brew Cartel | Black Hole Ventures | Mantis Ambush Futures | Quantum Leap Capital |
#### Endless upgrades (14)
| Key | `paperclip` | `witch` | `alien` | `insects` | `computers` |
|---|---|---|---|---|---|
| `endless.e_click` | Manual mastery | Manual mastery | Manual mastery | Manual mastery | Manual mastery |
| `endless.e_gen1` | Autoclipper overhaul | Apprentice overhaul | Mining Drone overhaul | Worker Ant overhaul | Old Laptop overhaul |
| `endless.e_gen2` | MegaClipper overhaul | Bubbling Cauldron overhaul | Plasma Drill overhaul | Leafcutter Squad overhaul | Gaming PC overhaul |
| `endless.e_gen3` | Clip Factory overhaul | Alchemy Lab overhaul | Orbital Extractor overhaul | Beetle Hauler overhaul | GPU Mining Rig overhaul |
| `endless.e_gen4` | Hypno Drones overhaul | Broomstick Fleet overhaul | Asteroid Harvester overhaul | Termite Mound overhaul | Server Rack overhaul |
| `endless.e_gen5` | Probe Swarm overhaul | Moonlit Coven overhaul | Hive Swarm overhaul | Locust Swarm overhaul | Data Center overhaul |
| `endless.e_gen6` | Matter Converter overhaul | Philosopher's Engine overhaul | Planet Cracker overhaul | Hive Queen overhaul | Quantum Mainframe overhaul |
| `endless.e_global` | Total optimisation | Total optimisation | Total optimisation | Total optimisation | Total optimisation |
| `endless.e_demand` | Mass appeal | Mass appeal | Mass appeal | Mass appeal | Mass appeal |
| `endless.e_value` | Premium margin | Premium margin | Premium margin | Premium margin | Premium margin |
| `endless.e_efficiency` | Wire thrift | Herbs thrift | Raw ore thrift | Silk thrift | Energy (W) thrift |
| `endless.e_discount` | Hard bargaining | Hard bargaining | Hard bargaining | Hard bargaining | Hard bargaining |
| `endless.e_stack` | Wire stockpiling | Herbs stockpiling | Raw ore stockpiling | Silk stockpiling | Energy (W) stockpiling |
| `endless.e_cost` | Economies of scale | Economies of scale | Economies of scale | Economies of scale | Economies of scale |
#### Research (15)
| Key | Subject (the same in every theme, drawn in each theme's style) |
|---|---|
| `research.r_prod_1` | Optimised Workflows |
| `research.r_prod_2` | Automation Suite |
| `research.r_prod_3` | Total Integration |
| `research.r_mkt_1` | Market Analysis |
| `research.r_mkt_2` | Brand Strategy |
| `research.r_mkt_3` | Premium Positioning |
| `research.r_log_1` | Resource Efficiency |
| `research.r_log_2` | Bulk Procurement |
| `research.r_log_3` | Lean Manufacturing |
| `research.r_fin_1` | Portfolio Management |
| `research.r_fin_2` | Risk Modelling |
| `research.r_fin_3` | Negotiation Training |
| `research.r_ins_1` | Research Methods |
| `research.r_ins_2` | Remote Operations |
| `research.r_ins_3` | Price Oracle |
#### Legacy perks (6)
| Key | Subject (the same in every theme, drawn in each theme's style) |
|---|---|
| `legacy.lg_prod` | Industrial Memory |
| `legacy.lg_demand` | Brand Heritage |
| `legacy.lg_cost` | Lean Blueprints |
| `legacy.lg_start` | Head Start |
| `legacy.lg_offline` | Night Shift |
| `legacy.lg_rp` | Research Archive |
#### Achievements (23)
| Key | Subject (the same in every theme, drawn in each theme's style) |
|---|---|
| `ach.a_prod_1` | First Steps |
| `ach.a_prod_2` | Getting Serious |
| `ach.a_prod_3` | Industrial Scale |
| `ach.a_prod_4` | Beyond Imagination |
| `ach.a_prod_5` | Cosmic Output |
| `ach.a_gen_1` | Small Team |
| `ach.a_gen_2` | Big Operation |
| `ach.a_gen_3` | Endless Workforce |
| `ach.a_upg_1` | Tinkerer |
| `ach.a_upg_2` | Perfectionist |
| `ach.a_inv_1` | Pocket Money |
| `ach.a_inv_2` | Smart Money |
| `ach.a_inv_3` | Tycoon |
| `ach.a_gam_1` | Beginner's Luck |
| `ach.a_gam_2` | High Roller |
| `ach.a_gam_3` | Chip Collector |
| `ach.a_con_1` | Deal Maker |
| `ach.a_con_2` | Reliable Partner |
| `ach.a_res_1` | Curious Mind |
| `ach.a_res_2` | Master Scientist |
| `ach.a_leg_1` | Fresh Start |
| `ach.a_leg_2` | Eternal Return |
| `ach.a_evt_1` | Quick Hands |
<!-- icons:end -->
+2 -1
View File
@@ -10,6 +10,7 @@
},
"scripts": {
"start": "node server/index.js",
"test": "node --test"
"test": "node --test",
"icons": "node scripts/icons.mjs"
}
}
+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"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
// Loads the game data from disk (the browser loads the same files over HTTP).
// Shared by the tests and the scripts in this folder.
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));
}
+159
View File
@@ -0,0 +1,159 @@
// The complete list of icons a theme must provide, derived from the game data
// (so it can never drift from the game). Every key is what the UI passes to
// icon(key) in public/js/ui/dom.js; a theme maps it to a file in theme.json.
import { readdirSync } from 'node:fs';
import { PUBLIC_DIR, readJson } from './context.js';
export const THEME_IDS = readdirSync(`${PUBLIC_DIR}themes`, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => readJson(`themes/${a}/theme.json`).order - readJson(`themes/${b}/theme.json`).order);
function flatten(object, prefix = '', out = {}) {
for (const [key, value] of Object.entries(object)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object') flatten(value, path, out);
else out[path] = String(value);
}
return out;
}
/** English texts of a theme: the core locale plus the theme's own, merged like the game does. */
function dictionaryFor(themeId) {
return { ...flatten(readJson('locales/en.json')), ...flatten(readJson(`themes/${themeId}/locales/en.json`)) };
}
/** Same interpolation as services/i18n.js: {param} and {@other.key}. */
function render(dict, key, params = {}, depth = 0) {
const text = dict[key];
if (text === undefined) return key;
return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => {
if (embed) return depth < 5 ? render(dict, name, params, depth + 1) : '';
return name in params ? params[name] : match;
});
}
/**
* Groups of icons. `themed` groups have a different subject in every theme
* (so the table has one column per theme); the others have the same subject
* everywhere (but still need drawing in each theme's style).
* Each icon's `label(dict)` gives what to draw, from that theme's texts.
*/
export function iconGroups(ctx) {
const text = (key) => (dict) => render(dict, key);
const resource = (name) => ({ key: `res.${name}`, label: text(`theme.res.${name}`) });
return [
{
id: 'res',
title: 'Resources',
shownIn: 'top bar (units, funds, material), Gamble, Research and Legacy screens',
themed: true,
icons: ['units', 'funds', 'material', 'chips', 'research', 'legacy'].map(resource),
},
{
id: 'gen',
title: 'Automation tiers',
shownIn: 'Production screen, Automation list',
themed: true,
icons: ctx.generators.map((g) => ({ key: `gen.${g.id}`, label: text(`theme.gen.${g.id}.name`) })),
},
{
id: 'upg',
title: 'Upgrades',
shownIn: 'Production screen, Upgrades list',
themed: true,
icons: ctx.upgrades.map((u) => ({ key: `upg.${u.id}`, label: text(`theme.upg.${u.id}`) })),
},
{
id: 'inv',
title: 'Investment societies',
shownIn: 'Investments screen',
themed: true,
icons: ctx.investments.societies.map((s) => ({ key: `inv.${s.id}`, label: text(`theme.inv.${s.id}.name`) })),
},
{
id: 'endless',
title: 'Endless upgrades',
shownIn: 'Production screen, Endless upgrades panel',
themed: true,
icons: ctx.endless.map((line) => ({
key: `endless.${line.id}`,
label: (dict) =>
line.effect.target
? render(dict, 'endless.name.generator', { target: dict[`theme.gen.${line.effect.target}.name`] })
: render(dict, `endless.name.${line.id}`),
})),
},
{
id: 'research',
title: 'Research',
shownIn: 'Research screen',
themed: false,
icons: ctx.research.map((r) => ({ key: `research.${r.id}`, label: text(`research.node.${r.id}`) })),
},
{
id: 'legacy',
title: 'Legacy perks',
shownIn: 'Legacy screen',
themed: false,
icons: ctx.legacy.map((p) => ({ key: `legacy.${p.id}`, label: text(`legacy.perk.${p.id}`) })),
},
{
id: 'ach',
title: 'Achievements',
shownIn: 'Achievements screen',
themed: false,
icons: ctx.achievements.map((a) => ({ key: `ach.${a.id}`, label: text(`ach.title.${a.id}`) })),
},
];
}
export function allIconKeys(ctx) {
return iconGroups(ctx).flatMap((group) => group.icons.map((icon) => icon.key));
}
/** Markdown for the README: totals, then one table per group. */
export function renderMarkdown(ctx) {
const groups = iconGroups(ctx);
const dicts = Object.fromEntries(THEME_IDS.map((id) => [id, dictionaryFor(id)]));
const perTheme = groups.reduce((sum, g) => sum + g.icons.length, 0);
const themeColumns = THEME_IDS.map((id) => `\`${id}\``);
const lines = [
`**${perTheme} icons per theme, ${perTheme * THEME_IDS.length} in total** (${THEME_IDS.length} themes: ${themeColumns.join(', ')}).`,
'',
'| Group | Icons per theme | Shown in |',
'|---|---:|---|',
...groups.map((g) => `| ${g.title} | ${g.icons.length} | ${g.shownIn} |`),
`| **Total** | **${perTheme}** | |`,
'',
];
for (const group of groups) {
lines.push(`#### ${group.title} (${group.icons.length})`, '');
if (group.themed) {
lines.push(
`| Key | ${themeColumns.join(' | ')} |`,
`|---|${THEME_IDS.map(() => '---').join('|')}|`,
...group.icons.map(
(icon) => `| \`${icon.key}\` | ${THEME_IDS.map((id) => icon.label(dicts[id])).join(' | ')} |`,
),
);
} else {
lines.push(
"| Key | Subject (the same in every theme, drawn in each theme's style) |",
'|---|---|',
...group.icons.map((icon) => `| \`${icon.key}\` | ${icon.label(dicts[THEME_IDS[0]])} |`),
);
}
lines.push('');
}
return lines.join('\n').trimEnd();
}
/** theme.json "assets" skeleton: every key mapped to its default file name. */
export function assetsSkeleton(ctx, extension = 'svg') {
return Object.fromEntries(allIconKeys(ctx).map((key) => [key, `img/${key.replaceAll('.', '-')}.${extension}`]));
}
+35
View File
@@ -0,0 +1,35 @@
// Icon list tooling.
//
// npm run icons print the exhaustive list as Markdown
// npm run icons -- --write refresh the list inside README.md
// npm run icons -- --manifest print a theme.json "assets" skeleton (every key -> img/<key>.svg)
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { assetsSkeleton, renderMarkdown } from './icon-keys.js';
import { loadContext } from './context.js';
export const README = fileURLToPath(new URL('../README.md', import.meta.url));
export const START = '<!-- icons:start (generated by `npm run icons -- --write`, do not edit by hand) -->';
export const END = '<!-- icons:end -->';
/** README text with the generated list refreshed between the markers. */
export function withIconList(readme, ctx) {
const start = readme.indexOf(START);
const end = readme.indexOf(END);
if (start < 0 || end < start) throw new Error('README.md has no icon list markers');
return `${readme.slice(0, start + START.length)}\n\n${renderMarkdown(ctx)}\n\n${readme.slice(end)}`;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const ctx = loadContext();
const args = process.argv.slice(2);
if (args.includes('--write')) {
writeFileSync(README, withIconList(readFileSync(README, 'utf8'), ctx));
console.log('README.md updated');
} else if (args.includes('--manifest')) {
console.log(JSON.stringify({ assets: assetsSkeleton(ctx) }, null, 2));
} else {
console.log(renderMarkdown(ctx));
}
}
+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]'));
});