feat: Making the game harder to 'beat'
This commit is contained in:
@@ -35,7 +35,8 @@ public/
|
|||||||
ui/ DOM helpers · main view · one view per section · tabs/menu · settings · toasts ·
|
ui/ DOM helpers · main view · one view per section · tabs/menu · settings · toasts ·
|
||||||
fx (the quick purchase animations)
|
fx (the quick purchase animations)
|
||||||
main.js wires everything together
|
main.js wires everything together
|
||||||
tests/ node:test suites (engine, systems, save import/export, server, content completeness)
|
scripts/ tools: campaign bot (pacing), icon list generator
|
||||||
|
tests/ node:test suites (engine, systems, balance and pacing, save import/export, server, content completeness)
|
||||||
```
|
```
|
||||||
|
|
||||||
The key idea: **mechanics are shared, themes are skins.** `public/data/` defines generators and upgrades by id (`gen1`, `click_1`, …) with their numbers; a theme only gives those ids a name, a colour palette and, later, images. Rebalancing the game is a change in `public/data/`, not in each theme.
|
The key idea: **mechanics are shared, themes are skins.** `public/data/` defines generators and upgrades by id (`gen1`, `click_1`, …) with their numbers; a theme only gives those ids a name, a colour palette and, later, images. Rebalancing the game is a change in `public/data/`, not in each theme.
|
||||||
@@ -50,7 +51,7 @@ The production screen is the core loop. Further sections appear in the menu as y
|
|||||||
| Investments | 100 K | 9 societies in 3 risk tiers (low ±5 %, medium ±15 %, high ±50 % every 60 s), dividends, a 0.5 % fee. Themed names. |
|
| Investments | 100 K | 9 societies in 3 risk tiers (low ±5 %, medium ±15 %, high ±50 % every 60 s), dividends, a 0.5 % fee. Themed names. |
|
||||||
| Research | 500 K | Labs (bought with funds) produce research points slowly; a tree of 15 permanent bonuses, including a late one that reveals the balanced price. |
|
| Research | 500 K | Labs (bought with funds) produce research points slowly; a tree of 15 permanent bonuses, including a late one that reveals the balanced price. |
|
||||||
| Gamble (themed: Quantum Computer, Crystal Ball…) | 5 M | Chips are costly and each one costs more. Stake them on a cycle: win, jackpot or crash. Reliability upgrades tilt the odds; chips boost demand with diminishing returns. |
|
| Gamble (themed: Quantum Computer, Crystal Ball…) | 5 M | Chips are costly and each one costs more. Stake them on a cycle: win, jackpot or crash. Reliability upgrades tilt the odds; chips boost demand with diminishing returns. |
|
||||||
| Legacy | 500 M | Restart the run for permanent points: a passive bonus per point plus repeatable perks. Research, achievements and statistics survive. |
|
| Legacy | 500 M | Restart the run for permanent points, earned from *everything you have ever produced* with diminishing returns (the first point at 10 trillion, each further one costs more). A modest passive bonus plus repeatable perks. Research, achievements and statistics survive. |
|
||||||
| Achievements | always | 23 milestones, each with a small permanent bonus. |
|
| Achievements | always | 23 milestones, each with a small permanent bonus. |
|
||||||
|
|
||||||
Everything is data-driven: `tabs.json` (unlocks), `investments.json`, `research.json`, `legacy.json`, `achievements.json` and the `gamble`, `research`, `legacy`, `contracts` and `events` blocks of `rules.json`. All bonuses share one vocabulary of effects (`engine/effects.js`). Texts for research, perks and achievements are in the core locale files; only the theme vocabulary (the gamble's name, the chip/research/legacy currencies, the nine societies) is per theme.
|
Everything is data-driven: `tabs.json` (unlocks), `investments.json`, `research.json`, `legacy.json`, `achievements.json` and the `gamble`, `research`, `legacy`, `contracts` and `events` blocks of `rules.json`. All bonuses share one vocabulary of effects (`engine/effects.js`). Texts for research, perks and achievements are in the core locale files; only the theme vocabulary (the gamble's name, the chip/research/legacy currencies, the nine societies) is per theme.
|
||||||
@@ -91,6 +92,22 @@ Everything numeric is in `public/data/`: `rules.json` (market, raw material, mar
|
|||||||
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
### Pacing: how long the game lasts
|
||||||
|
|
||||||
|
A bot plays the whole game (automation, upgrades, endless levels, labs, research, legacy restarts) so the pacing can be measured instead of guessed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run campaign -- 120 10 # 120 simulated hours, one step = 10 s (takes a few seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints the restarts, how fast each run starts, and when each long-term goal is reached. Knobs can be tried without editing the data files (see the top of `scripts/campaign.mjs`), e.g. `THRESH=1e14 PERK_GROWTH=2.5 npm run campaign -- 200 10`. The bot is a careful player that never gambles, invests or does contracts, so a person takes at least as long.
|
||||||
|
|
||||||
|
Where the game stands (bot hours): first restart about 10 h; 5 research nodes about 7 h, all 15 about 60 h; the first restarts are 10 to 20 h apart and get longer; no legacy perk is maxed after 200 h. `tests/balance.test.js` plays 40 hours on every `npm test` and fails if the game is finished too quickly (first restart before 6 h, all research or a perk maxed within 40 h) or runs away (a restarted run producing more than a million units per second after 2 minutes).
|
||||||
|
|
||||||
|
The levers, from the biggest: `legacy.threshold`, `exponent` and the passive bonus in `rules.json` (how fast restarts pay), perk `costGrowth` in `legacy.json`, research `cost` and `rpPerLabPerSecond`, endless `baseCost` / `costGrowth`.
|
||||||
|
|
||||||
|
**A lesson kept in the code.** The first legacy system multiplied output by 1.05 *per point* and gave points from a single run's production. A few thousand points gave ×1e100 and a run took seconds (a player reached 6e136 units/s two minutes into their fourth game). Now points come from lifetime production with diminishing returns, the passive bonus grows with the square root of the points and never compounds, and saves from older versions are repaired on load: runaway legacy points and perks are capped and the run restarts (research and achievements are kept). The player is told with a message that stays until clicked.
|
||||||
|
|
||||||
## Icons to produce
|
## Icons to produce
|
||||||
|
|
||||||
Every icon the game shows is a grey square until a theme provides an image. The exhaustive work list is in **[ICONS.txt](ICONS.txt)**: a checklist per theme, with the file name, the key and what to draw for each icon. It is generated from the game data, so it is always complete. All 23 achievements share a single `trophy` icon.
|
Every icon the game shows is a grey square until a theme provides an image. The exhaustive work list is in **[ICONS.txt](ICONS.txt)**: a checklist per theme, with the file name, the key and what to draw for each icon. It is generated from the game data, so it is always complete. All 23 achievements share a single `trophy` icon.
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"icons": "node scripts/icons.mjs"
|
"icons": "node scripts/icons.mjs",
|
||||||
|
"campaign": "node scripts/campaign.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -784,3 +784,11 @@ select,
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An important message: stays until it is clicked (the container ignores the pointer otherwise). */
|
||||||
|
.toast--sticky {
|
||||||
|
max-width: 36rem;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|||||||
+14
-14
@@ -1,16 +1,16 @@
|
|||||||
[
|
[
|
||||||
{"id":"e_click","requires":"click_3","baseCost":6000000,"costGrowth":2.5,"maxLevel":10000,"effect":{"type":"click_mult","value":2}},
|
{"id":"e_click","requires":"click_3","baseCost":60000000,"costGrowth":2.65,"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_gen1","requires":"g1_b","baseCost":6000000,"costGrowth":2.75,"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_gen2","requires":"g2_b","baseCost":80000000,"costGrowth":2.75,"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_gen3","requires":"g3_b","baseCost":1000000000,"costGrowth":2.75,"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_gen4","requires":"g4_b","baseCost":12000000000,"costGrowth":2.75,"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_gen5","requires":"g5_b","baseCost":160000000000,"costGrowth":2.85,"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_gen6","requires":"g6_b","baseCost":2000000000000,"costGrowth":2.95,"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_global","requires":"global_4","baseCost":200000000000,"costGrowth":2.55,"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_demand","requires":"demand_4","baseCost":20000000000,"costGrowth":2.55,"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_value","requires":"value_3","baseCost":12000000000,"costGrowth":2.55,"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_efficiency","requires":"mat_eff_2","baseCost":400000000,"costGrowth":2.65,"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_discount","requires":"mat_disc_2","baseCost":400000000,"costGrowth":2.65,"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_stack","requires":"stack_5","baseCost":40000000000,"costGrowth":3.65,"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}}
|
{"id":"e_cost","requires":"g3_b","baseCost":20000000000,"costGrowth":2.55,"maxLevel":10000,"effect":{"type":"generator_cost_mult","value":0.95}}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[
|
[
|
||||||
{"id":"lg_prod","baseCost":1,"costGrowth":1.6,"maxLevel":15,"effect":{"type":"global_mult","value":1.3}},
|
{"id":"lg_prod","baseCost":1,"costGrowth":2,"maxLevel":15,"effect":{"type":"global_mult","value":1.15}},
|
||||||
{"id":"lg_demand","baseCost":1,"costGrowth":1.6,"maxLevel":15,"effect":{"type":"demand_mult","value":1.3}},
|
{"id":"lg_demand","baseCost":1,"costGrowth":2,"maxLevel":15,"effect":{"type":"demand_mult","value":1.15}},
|
||||||
{"id":"lg_cost","baseCost":2,"costGrowth":1.7,"maxLevel":10,"effect":{"type":"generator_cost_mult","value":0.92}},
|
{"id":"lg_cost","baseCost":2,"costGrowth":2,"maxLevel":10,"effect":{"type":"generator_cost_mult","value":0.95}},
|
||||||
{"id":"lg_start","baseCost":1,"costGrowth":1.5,"maxLevel":10,"effect":{"type":"start_funds","value":50000}},
|
{"id":"lg_start","baseCost":1,"costGrowth":2,"maxLevel":10,"effect":{"type":"start_funds","value":50000}},
|
||||||
{"id":"lg_offline","baseCost":2,"costGrowth":2,"maxLevel":5,"effect":{"type":"offline_add","value":0.1}},
|
{"id":"lg_offline","baseCost":2,"costGrowth":2,"maxLevel":5,"effect":{"type":"offline_add","value":0.1}},
|
||||||
{"id":"lg_rp","baseCost":2,"costGrowth":1.6,"maxLevel":10,"effect":{"type":"rp_mult","value":1.25}}
|
{"id":"lg_rp","baseCost":2,"costGrowth":2,"maxLevel":10,"effect":{"type":"rp_mult","value":1.15}}
|
||||||
]
|
]
|
||||||
|
|||||||
+15
-15
@@ -1,17 +1,17 @@
|
|||||||
[
|
[
|
||||||
{"id":"r_prod_1","branch":"prod","cost":20,"requires":[],"effect":{"type":"global_mult","value":1.5}},
|
{"id":"r_prod_1","branch":"prod","cost":40,"requires":[],"effect":{"type":"global_mult","value":1.5}},
|
||||||
{"id":"r_prod_2","branch":"prod","cost":120,"requires":["r_prod_1"],"effect":{"type":"global_mult","value":2}},
|
{"id":"r_prod_2","branch":"prod","cost":240,"requires":["r_prod_1"],"effect":{"type":"global_mult","value":2}},
|
||||||
{"id":"r_prod_3","branch":"prod","cost":600,"requires":["r_prod_2"],"effect":{"type":"global_mult","value":3}},
|
{"id":"r_prod_3","branch":"prod","cost":1200,"requires":["r_prod_2"],"effect":{"type":"global_mult","value":3}},
|
||||||
{"id":"r_mkt_1","branch":"mkt","cost":25,"requires":[],"effect":{"type":"demand_mult","value":2}},
|
{"id":"r_mkt_1","branch":"mkt","cost":50,"requires":[],"effect":{"type":"demand_mult","value":2}},
|
||||||
{"id":"r_mkt_2","branch":"mkt","cost":150,"requires":["r_mkt_1"],"effect":{"type":"demand_mult","value":3}},
|
{"id":"r_mkt_2","branch":"mkt","cost":300,"requires":["r_mkt_1"],"effect":{"type":"demand_mult","value":3}},
|
||||||
{"id":"r_mkt_3","branch":"mkt","cost":400,"requires":["r_mkt_2"],"effect":{"type":"value_mult","value":2}},
|
{"id":"r_mkt_3","branch":"mkt","cost":800,"requires":["r_mkt_2"],"effect":{"type":"value_mult","value":2}},
|
||||||
{"id":"r_log_1","branch":"log","cost":30,"requires":[],"effect":{"type":"material_efficiency","value":1.5}},
|
{"id":"r_log_1","branch":"log","cost":60,"requires":[],"effect":{"type":"material_efficiency","value":1.5}},
|
||||||
{"id":"r_log_2","branch":"log","cost":200,"requires":["r_log_1"],"effect":{"type":"batch_discount","value":0.7}},
|
{"id":"r_log_2","branch":"log","cost":400,"requires":["r_log_1"],"effect":{"type":"batch_discount","value":0.7}},
|
||||||
{"id":"r_log_3","branch":"log","cost":500,"requires":["r_log_2"],"effect":{"type":"generator_cost_mult","value":0.8}},
|
{"id":"r_log_3","branch":"log","cost":1000,"requires":["r_log_2"],"effect":{"type":"generator_cost_mult","value":0.8}},
|
||||||
{"id":"r_fin_1","branch":"fin","cost":40,"requires":[],"effect":{"type":"dividend_mult","value":1.5}},
|
{"id":"r_fin_1","branch":"fin","cost":80,"requires":[],"effect":{"type":"dividend_mult","value":1.5}},
|
||||||
{"id":"r_fin_2","branch":"fin","cost":160,"requires":["r_fin_1"],"effect":{"type":"reliability_add","value":0.05}},
|
{"id":"r_fin_2","branch":"fin","cost":320,"requires":["r_fin_1"],"effect":{"type":"reliability_add","value":0.05}},
|
||||||
{"id":"r_fin_3","branch":"fin","cost":350,"requires":["r_fin_2"],"effect":{"type":"contract_mult","value":2}},
|
{"id":"r_fin_3","branch":"fin","cost":700,"requires":["r_fin_2"],"effect":{"type":"contract_mult","value":2}},
|
||||||
{"id":"r_ins_1","branch":"ins","cost":50,"requires":[],"effect":{"type":"rp_mult","value":1.5}},
|
{"id":"r_ins_1","branch":"ins","cost":100,"requires":[],"effect":{"type":"rp_mult","value":1.5}},
|
||||||
{"id":"r_ins_2","branch":"ins","cost":250,"requires":["r_ins_1"],"effect":{"type":"offline_add","value":0.25}},
|
{"id":"r_ins_2","branch":"ins","cost":500,"requires":["r_ins_1"],"effect":{"type":"offline_add","value":0.25}},
|
||||||
{"id":"r_ins_3","branch":"ins","cost":800,"requires":["r_ins_2","r_mkt_2"],"effect":{"type":"price_advisor"}}
|
{"id":"r_ins_3","branch":"ins","cost":1600,"requires":["r_ins_2","r_mkt_2"],"effect":{"type":"price_advisor"}}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,11 +68,13 @@
|
|||||||
"labBaseCost": 2000000,
|
"labBaseCost": 2000000,
|
||||||
"labCostGrowth": 3,
|
"labCostGrowth": 3,
|
||||||
"labMax": 30,
|
"labMax": 30,
|
||||||
"rpPerLabPerSecond": 0.01
|
"rpPerLabPerSecond": 0.002
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"threshold": 1000000000,
|
"threshold": 10000000000000,
|
||||||
"passivePerPoint": 0.05
|
"exponent": 0.4,
|
||||||
|
"passiveScale": 0.1,
|
||||||
|
"passivePower": 0.5
|
||||||
},
|
},
|
||||||
"contracts": {
|
"contracts": {
|
||||||
"offerEverySeconds": 60,
|
"offerEverySeconds": 60,
|
||||||
|
|||||||
@@ -5,11 +5,27 @@
|
|||||||
import { modifiersFor } from './modifiers.js';
|
import { modifiersFor } from './modifiers.js';
|
||||||
import { createState, META_KEYS } from './state.js';
|
import { createState, META_KEYS } from './state.js';
|
||||||
|
|
||||||
/** Points a restart would grant right now (0 until the run is big enough). */
|
/**
|
||||||
|
* How many points a lifetime production deserves in total. Diminishing returns
|
||||||
|
* (a power below 1) on EVERYTHING ever produced, not on a single run: each
|
||||||
|
* further point needs far more production than the last, so restarting more and
|
||||||
|
* more often cannot snowball.
|
||||||
|
*/
|
||||||
|
export function pointsFor(lifetimeProduced, ctx, mods) {
|
||||||
|
const { threshold, exponent } = ctx.rules.legacy;
|
||||||
|
if (!(lifetimeProduced >= threshold)) return 0;
|
||||||
|
return Math.floor((lifetimeProduced / threshold) ** exponent * mods.legacyGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifetime production at which the next point is earned (ignoring bonuses to point gains). */
|
||||||
|
export function nextPointAt(state, ctx) {
|
||||||
|
const { threshold, exponent } = ctx.rules.legacy;
|
||||||
|
return threshold * (state.legacy.earned + 1) ** (1 / exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Points a restart would grant right now (0 until the next point is within reach). */
|
||||||
export function legacyGain(state, ctx, mods) {
|
export function legacyGain(state, ctx, mods) {
|
||||||
const { threshold } = ctx.rules.legacy;
|
return Math.max(0, pointsFor(state.lifetimeProduced, ctx, mods) - state.legacy.earned);
|
||||||
if (state.totalProduced < threshold) return 0;
|
|
||||||
return Math.floor(Math.sqrt(state.totalProduced / threshold) * mods.legacyGain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function perkLevel(state, perk) {
|
export function perkLevel(state, perk) {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
import { foldEffects } from './effects.js';
|
import { foldEffects } from './effects.js';
|
||||||
import { chipDemandBonus } from './formulas.js';
|
import { chipDemandBonus } from './formulas.js';
|
||||||
|
|
||||||
|
/** The multiplier given by all legacy points earned so far: diminishing returns (a power below 1). */
|
||||||
|
export function passiveBonus(state, ctx) {
|
||||||
|
const { passiveScale, passivePower } = ctx.rules.legacy;
|
||||||
|
return 1 + passiveScale * state.legacy.earned ** passivePower;
|
||||||
|
}
|
||||||
|
|
||||||
/** All `{ effect, times }` entries that apply to `state`. */
|
/** All `{ effect, times }` entries that apply to `state`. */
|
||||||
export function collectEffects(state, ctx) {
|
export function collectEffects(state, ctx) {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
@@ -18,10 +24,10 @@ export function collectEffects(state, ctx) {
|
|||||||
entries.push({ effect: ctx.perksById.get(id)?.effect, times: level });
|
entries.push({ effect: ctx.perksById.get(id)?.effect, times: level });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every legacy point ever earned gives a small passive bonus.
|
// Every legacy point ever earned adds a flat bonus. It has diminishing returns and does
|
||||||
const passive = 1 + ctx.rules.legacy.passivePerPoint;
|
// not compound: stacking x1.05 per point once reached 1e100 within a few runs.
|
||||||
entries.push({ effect: { type: 'global_mult', value: passive }, times: state.legacy.earned });
|
entries.push({ effect: { type: 'global_mult', value: passiveBonus(state, ctx) } });
|
||||||
entries.push({ effect: { type: 'demand_mult', value: passive }, times: state.legacy.earned });
|
entries.push({ effect: { type: 'demand_mult', value: passiveBonus(state, ctx) } });
|
||||||
|
|
||||||
// Chips make the brand more attractive, with diminishing returns.
|
// Chips make the brand more attractive, with diminishing returns.
|
||||||
entries.push({
|
entries.push({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { clamp } from './formulas.js';
|
import { clamp } from './formulas.js';
|
||||||
|
|
||||||
export const STATE_VERSION = 2;
|
// 3: legacy points come from lifetime production and the passive bonus no longer compounds.
|
||||||
|
export const STATE_VERSION = 3;
|
||||||
|
|
||||||
const MAX_NUMBER = 1e300;
|
const MAX_NUMBER = 1e300;
|
||||||
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
|
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
|
||||||
@@ -17,6 +18,7 @@ const TRANSIENT = [
|
|||||||
'contractTimer',
|
'contractTimer',
|
||||||
'eventTimer',
|
'eventTimer',
|
||||||
'gambleCooldown',
|
'gambleCooldown',
|
||||||
|
'repaired',
|
||||||
];
|
];
|
||||||
|
|
||||||
// What a legacy reset keeps (the "meta" progression); everything else is the run.
|
// What a legacy reset keeps (the "meta" progression); everything else is the run.
|
||||||
@@ -81,6 +83,7 @@ export function createState(ctx, now = Date.now()) {
|
|||||||
contractTimer: contracts.offerEverySeconds,
|
contractTimer: contracts.offerEverySeconds,
|
||||||
eventTimer: events.minSeconds,
|
eventTimer: events.minSeconds,
|
||||||
gambleCooldown: 0,
|
gambleCooldown: 0,
|
||||||
|
repaired: false, // set when an old save had to be rebalanced, so the game can say so
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ function sanitizeEvents(raw, ctx) {
|
|||||||
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
|
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
|
||||||
* Saves from older versions are upgraded: missing fields take their defaults.
|
* Saves from older versions are upgraded: missing fields take their defaults.
|
||||||
*/
|
*/
|
||||||
export function sanitizeState(raw, ctx, now = Date.now()) {
|
function parseState(raw, ctx, now) {
|
||||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
const { market, materialMarket, gamble } = ctx.rules;
|
const { market, materialMarket, gamble } = ctx.rules;
|
||||||
const base = createState(ctx, now);
|
const base = createState(ctx, now);
|
||||||
@@ -228,3 +231,46 @@ export function serializeState(state) {
|
|||||||
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
|
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- repairing old saves
|
||||||
|
|
||||||
|
const MAX_LEGIT_POINTS = 25; // more than this in a save older than v3 can only come from the old runaway bonus
|
||||||
|
const RUNAWAY_PRODUCTION = 1e40;
|
||||||
|
const REPAIRED_POINTS = 10; // what a runaway save is brought back to
|
||||||
|
const REPAIRED_PERK_LEVEL = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before version 3 every legacy point multiplied output by 1.05 on top of the
|
||||||
|
* others, so a few thousand points gave 1e100 and runs were over in seconds.
|
||||||
|
* Such a save keeps its research and achievements but gets its legacy points
|
||||||
|
* and perks capped and its (meaningless) run restarted. Any older save also has
|
||||||
|
* its lifetime production capped, so an inflated total cannot hand out a pile
|
||||||
|
* of points at the next restart.
|
||||||
|
*/
|
||||||
|
function repairOlderSave(state, ctx, now) {
|
||||||
|
const runaway = state.legacy.earned > MAX_LEGIT_POINTS || state.lifetimeProduced > RUNAWAY_PRODUCTION;
|
||||||
|
|
||||||
|
if (runaway) {
|
||||||
|
state.legacy.earned = Math.min(state.legacy.earned, REPAIRED_POINTS);
|
||||||
|
state.legacy.points = 0;
|
||||||
|
for (const id of Object.keys(state.legacy.perks)) {
|
||||||
|
state.legacy.perks[id] = Math.min(state.legacy.perks[id], REPAIRED_PERK_LEVEL);
|
||||||
|
}
|
||||||
|
const fresh = createState(ctx, now);
|
||||||
|
for (const key of Object.keys(fresh)) {
|
||||||
|
if (!META_KEYS.includes(key) && key !== 'v' && key !== 'savedAt') state[key] = fresh[key];
|
||||||
|
}
|
||||||
|
state.repaired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { threshold, exponent } = ctx.rules.legacy;
|
||||||
|
state.lifetimeProduced = Math.min(state.lifetimeProduced, threshold * (state.legacy.earned + 3) ** (1 / exponent));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Untrusted data (cookie, imported file) in, valid and up-to-date state out; null if unusable. */
|
||||||
|
export function sanitizeState(raw, ctx, now = Date.now()) {
|
||||||
|
const state = parseState(raw, ctx, now);
|
||||||
|
if (!state) return null;
|
||||||
|
return (Number(raw.v) || 1) < STATE_VERSION ? repairOlderSave(state, ctx, now) : state;
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ function catchUp(elapsedSeconds) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tell the player when an older save had to be rebalanced (see repairOlderSave in state.js). */
|
||||||
|
function reportRepair() {
|
||||||
|
if (!state.repaired) return;
|
||||||
|
state.repaired = false;
|
||||||
|
showToast(t('toast.repaired'), { sticky: true });
|
||||||
|
}
|
||||||
|
|
||||||
let lastFrame = Date.now();
|
let lastFrame = Date.now();
|
||||||
|
|
||||||
function frame() {
|
function frame() {
|
||||||
@@ -279,6 +286,7 @@ createSettings({
|
|||||||
}),
|
}),
|
||||||
importText(text) {
|
importText(text) {
|
||||||
state = parseImport(text, ctx); // throws before anything changes
|
state = parseImport(text, ctx); // throws before anything changes
|
||||||
|
reportRepair();
|
||||||
income = 0;
|
income = 0;
|
||||||
persist();
|
persist();
|
||||||
render();
|
render();
|
||||||
@@ -322,6 +330,7 @@ async function boot() {
|
|||||||
await setTheme(initialTheme.id);
|
await setTheme(initialTheme.id);
|
||||||
|
|
||||||
state = loadSavedState() ?? createState(ctx);
|
state = loadSavedState() ?? createState(ctx);
|
||||||
|
reportRepair();
|
||||||
catchUp((Date.now() - state.savedAt) / 1000);
|
catchUp((Date.now() - state.savedAt) / 1000);
|
||||||
persist();
|
persist();
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Legacy tab: restart the run for permanent points, spend them on perks.
|
// Legacy tab: restart the run for permanent points, spend them on perks.
|
||||||
|
|
||||||
import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js';
|
import { legacyGain, nextPointAt, perkCost, perkLevel } from '../engine/legacy.js';
|
||||||
|
import { passiveBonus } from '../engine/modifiers.js';
|
||||||
import { t } from '../services/i18n.js';
|
import { t } from '../services/i18n.js';
|
||||||
import { h, icon, makeBuyable, setBuyable } from './dom.js';
|
import { h, icon, makeBuyable, setBuyable } from './dom.js';
|
||||||
import { bump, purchased } from './fx.js';
|
import { bump, purchased } from './fx.js';
|
||||||
@@ -58,7 +59,7 @@ export function createLegacyView(root, actions) {
|
|||||||
'section',
|
'section',
|
||||||
{ class: 'panel' },
|
{ class: 'panel' },
|
||||||
h('h2', { class: 'panel__title', text: t('tab.legacy') }),
|
h('h2', { class: 'panel__title', text: t('tab.legacy') }),
|
||||||
h('p', { class: 'hint', text: t('legacy.intro', { passive: fmt.number(ctx.rules.legacy.passivePerPoint * 100) }) }),
|
h('p', { class: 'hint', text: t('legacy.intro') }),
|
||||||
h('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)),
|
h('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)),
|
||||||
h(
|
h(
|
||||||
'dl',
|
'dl',
|
||||||
@@ -84,21 +85,22 @@ export function createLegacyView(root, actions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update(state, d) {
|
function update(state, d) {
|
||||||
const { threshold, passivePerPoint } = ctx.rules.legacy;
|
|
||||||
setText(els.points, fmt.number(state.legacy.points));
|
setText(els.points, fmt.number(state.legacy.points));
|
||||||
setText(els.earned, fmt.number(state.legacy.earned));
|
setText(els.earned, fmt.number(state.legacy.earned));
|
||||||
setText(els.prestiges, fmt.number(state.legacy.prestiges));
|
setText(els.prestiges, fmt.number(state.legacy.prestiges));
|
||||||
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number((1 + passivePerPoint) ** state.legacy.earned) }));
|
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number(passiveBonus(state, ctx)) }));
|
||||||
|
|
||||||
const gain = legacyGain(state, ctx, d.mods);
|
const gain = legacyGain(state, ctx, d.mods);
|
||||||
setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) }));
|
setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) }));
|
||||||
els.progress.max = threshold;
|
// Points are earned from everything ever produced, so the bar shows the lifetime total.
|
||||||
els.progress.value = Math.min(state.totalProduced, threshold);
|
const target = nextPointAt(state, ctx);
|
||||||
|
els.progress.max = target;
|
||||||
|
els.progress.value = Math.min(state.lifetimeProduced, target);
|
||||||
setText(
|
setText(
|
||||||
els.need,
|
els.need,
|
||||||
gain >= 1
|
gain >= 1
|
||||||
? t('legacy.ready')
|
? t('legacy.ready')
|
||||||
: t('legacy.need', { have: fmt.number(Math.floor(state.totalProduced)), need: fmt.number(threshold) }),
|
: t('legacy.need', { have: fmt.number(Math.floor(state.lifetimeProduced)), need: fmt.number(target) }),
|
||||||
);
|
);
|
||||||
setText(els.restart, t('legacy.restart'));
|
setText(els.restart, t('legacy.restart'));
|
||||||
els.restart.disabled = gain < 1;
|
els.restart.disabled = gain < 1;
|
||||||
|
|||||||
+17
-4
@@ -3,10 +3,23 @@ import { $, h } from './dom.js';
|
|||||||
const DURATION_MS = 6000;
|
const DURATION_MS = 6000;
|
||||||
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
|
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
|
||||||
|
|
||||||
export function showToast(message, { error = false } = {}) {
|
/**
|
||||||
|
* Show a message for a few seconds.
|
||||||
|
* `sticky` messages are important: they stay until clicked, and routine messages never push them away.
|
||||||
|
*/
|
||||||
|
export function showToast(message, { error = false, sticky = false } = {}) {
|
||||||
const container = $('#toasts');
|
const container = $('#toasts');
|
||||||
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
|
const classes = ['toast', error && 'toast--error', sticky && 'toast--sticky'].filter(Boolean);
|
||||||
|
const toast = h('p', { class: classes.join(' '), text: message });
|
||||||
|
|
||||||
|
if (sticky) {
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.addEventListener('click', () => toast.remove());
|
||||||
|
} else {
|
||||||
|
setTimeout(() => toast.remove(), DURATION_MS);
|
||||||
|
}
|
||||||
container.append(toast);
|
container.append(toast);
|
||||||
while (container.children.length > MAX_VISIBLE) container.firstElementChild.remove();
|
|
||||||
setTimeout(() => toast.remove(), DURATION_MS);
|
const routine = [...container.children].filter((el) => !el.classList.contains('toast--sticky'));
|
||||||
|
for (const old of routine.slice(0, Math.max(0, routine.length - MAX_VISIBLE))) old.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@
|
|||||||
"contractFailed": "Contract failed: time ran out.",
|
"contractFailed": "Contract failed: time ran out.",
|
||||||
"eventIncident": "Incident: {title}",
|
"eventIncident": "Incident: {title}",
|
||||||
"achievement": "Achievement: {title}",
|
"achievement": "Achievement: {title}",
|
||||||
"restarted": "A new run begins! +{gain} {@theme.res.legacy}"
|
"restarted": "A new run begins! +{gain} {@theme.res.legacy}",
|
||||||
|
"repaired": "Your save came from an older version whose legacy bonus could grow out of control. It was rebalanced: legacy points and perks were capped and the run restarted. Research and achievements were kept."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -237,15 +238,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"intro": "Start over for permanent {@theme.res.legacy}. Every point also gives +{passive}% production and demand, forever. You keep research, perks, achievements and statistics.",
|
"intro": "Start over for permanent {@theme.res.legacy}. Each point adds a lasting bonus to production and demand, but with diminishing returns: the more points you have, the less each new one adds. Points come from everything you have ever produced, so each further point takes much more than the last. You keep research, perks, achievements and statistics.",
|
||||||
"earned": "Earned in total",
|
"earned": "Earned in total",
|
||||||
"restarts": "Restarts",
|
"restarts": "Restarts",
|
||||||
"passive": "Passive bonus",
|
"passive": "Passive bonus",
|
||||||
"passiveValue": "×{bonus}",
|
"passiveValue": "×{bonus}",
|
||||||
"restartTitle": "Start a new run",
|
"restartTitle": "Start a new run",
|
||||||
"gain": "Restarting now earns {gain} {@theme.res.legacy}.",
|
"gain": "Restarting now earns {gain} {@theme.res.legacy}.",
|
||||||
"need": "Reach {need} {@theme.res.units} in this run ({have} so far).",
|
"need": "The next point comes at {need} {@theme.res.units} produced in total ({have} so far).",
|
||||||
"ready": "You can restart now. The bigger the run, the bigger the reward.",
|
"ready": "You can restart now. Waiting for a much bigger total before restarting earns more points at once.",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"confirm": "Start a new run? You keep research, legacy, achievements and statistics. You lose this run's production, funds, upgrades, investments and chips.",
|
"confirm": "Start a new run? You keep research, legacy, achievements and statistics. You lose this run's production, funds, upgrades, investments and chips.",
|
||||||
"perks": "Perks",
|
"perks": "Perks",
|
||||||
|
|||||||
@@ -93,7 +93,8 @@
|
|||||||
"contractFailed": "Contrat échoué : le temps est écoulé.",
|
"contractFailed": "Contrat échoué : le temps est écoulé.",
|
||||||
"eventIncident": "Incident : {title}",
|
"eventIncident": "Incident : {title}",
|
||||||
"achievement": "Succès : {title}",
|
"achievement": "Succès : {title}",
|
||||||
"restarted": "Une nouvelle partie commence ! +{gain} ({@theme.res.legacy})"
|
"restarted": "Une nouvelle partie commence ! +{gain} ({@theme.res.legacy})",
|
||||||
|
"repaired": "Votre sauvegarde venait d'une ancienne version dont le bonus d'héritage pouvait s'emballer. Elle a été rééquilibrée : points et avantages d'héritage plafonnés, partie redémarrée. La recherche et les succès sont conservés."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -237,15 +238,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"intro": "Recommencez de zéro pour obtenir des {@theme.res.legacy} permanents. Chaque point donne aussi +{passive} % de production et de demande, pour toujours. Vous gardez la recherche, les avantages, les succès et les statistiques.",
|
"intro": "Recommencez de zéro pour obtenir des {@theme.res.legacy} permanents. Chaque point ajoute un bonus durable à la production et à la demande, mais avec des rendements décroissants : plus vous en avez, moins chaque nouveau point rapporte. Les points viennent de tout ce que vous avez déjà produit, donc chaque point suivant demande bien plus que le précédent. Vous gardez la recherche, les avantages, les succès et les statistiques.",
|
||||||
"earned": "Gagnés au total",
|
"earned": "Gagnés au total",
|
||||||
"restarts": "Redémarrages",
|
"restarts": "Redémarrages",
|
||||||
"passive": "Bonus passif",
|
"passive": "Bonus passif",
|
||||||
"passiveValue": "×{bonus}",
|
"passiveValue": "×{bonus}",
|
||||||
"restartTitle": "Lancer une nouvelle partie",
|
"restartTitle": "Lancer une nouvelle partie",
|
||||||
"gain": "Redémarrer maintenant rapporte {gain} ({@theme.res.legacy}).",
|
"gain": "Redémarrer maintenant rapporte {gain} ({@theme.res.legacy}).",
|
||||||
"need": "Atteignez {need} ({@theme.res.units}) dans cette partie ({have} pour l'instant).",
|
"need": "Le prochain point arrive à {need} ({@theme.res.units}) produits au total ({have} pour l'instant).",
|
||||||
"ready": "Vous pouvez redémarrer. Plus la partie est grosse, plus la récompense l'est.",
|
"ready": "Vous pouvez redémarrer. Attendre un total bien plus grand avant de redémarrer rapporte plus de points d'un coup.",
|
||||||
"restart": "Redémarrer",
|
"restart": "Redémarrer",
|
||||||
"confirm": "Lancer une nouvelle partie ? Vous gardez la recherche, l'héritage, les succès et les statistiques. Vous perdez la production, les fonds, les améliorations, les investissements et les jetons de cette partie.",
|
"confirm": "Lancer une nouvelle partie ? Vous gardez la recherche, l'héritage, les succès et les statistiques. Vous perdez la production, les fonds, les améliorations, les investissements et les jetons de cette partie.",
|
||||||
"perks": "Avantages",
|
"perks": "Avantages",
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// A whole-game bot, used to tune the pacing and to guard it in the tests.
|
||||||
|
//
|
||||||
|
// It plays like a careful, efficient player: sets the best price, restocks,
|
||||||
|
// buys everything worth buying (automation, upgrades, endless levels, labs,
|
||||||
|
// research) and restarts for legacy points when that is worth it. It never
|
||||||
|
// gambles, invests or does contracts, so it is a slightly conservative player.
|
||||||
|
// `npm run campaign -- <hours>` prints a timeline; see campaign.mjs.
|
||||||
|
|
||||||
|
import { buyEndless, endlessQuote, lineAvailable } from '../public/js/engine/endless.js';
|
||||||
|
import * as E from '../public/js/engine/engine.js';
|
||||||
|
import { balancedPrice } from '../public/js/engine/formulas.js';
|
||||||
|
import { buyPerk, legacyGain, perkCost, perkLevel, prestige } from '../public/js/engine/legacy.js';
|
||||||
|
import { modifiersFor } from '../public/js/engine/modifiers.js';
|
||||||
|
import { buyLab, buyResearch, labCost, researchStatus } from '../public/js/engine/research.js';
|
||||||
|
import { createState } from '../public/js/engine/state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ctx game context (scripts/context.js)
|
||||||
|
* @param options.hours simulated hours to play
|
||||||
|
* @param options.dt seconds per simulation step (bigger = faster, coarser)
|
||||||
|
* @param options.restartRatio restart when the points on offer reach this share of those already earned
|
||||||
|
*/
|
||||||
|
export function runCampaign(ctx, { hours, dt = 5, restartRatio = 0.5, seed = 3 } = {}) {
|
||||||
|
const state = createState(ctx);
|
||||||
|
let rngSeed = seed;
|
||||||
|
const rng = () => {
|
||||||
|
rngSeed = (rngSeed * 16807) % 2147483647;
|
||||||
|
return rngSeed / 2147483647;
|
||||||
|
};
|
||||||
|
const market = ctx.rules.market;
|
||||||
|
|
||||||
|
let t = 0;
|
||||||
|
let runStart = 0;
|
||||||
|
const restarts = []; // { hour, minutes, points, earned, units, rate }
|
||||||
|
const goals = new Map(); // name -> hour
|
||||||
|
const probes = []; // { run, minutes, rate } production some minutes into every run
|
||||||
|
const probed = new Set();
|
||||||
|
|
||||||
|
const mark = (name, done) => {
|
||||||
|
if (done && !goals.has(name)) goals.set(name, t / 3600);
|
||||||
|
};
|
||||||
|
|
||||||
|
function track() {
|
||||||
|
mark('first restart', state.legacy.prestiges >= 1);
|
||||||
|
mark('5 restarts', state.legacy.prestiges >= 5);
|
||||||
|
mark('5 research nodes', state.research.length >= 5);
|
||||||
|
mark('10 research nodes', state.research.length >= 10);
|
||||||
|
mark('ALL research nodes', state.research.length >= ctx.research.length);
|
||||||
|
mark('20 labs', state.labs >= 20);
|
||||||
|
mark('every perk at level 3+', ctx.legacy.every((p) => perkLevel(state, p) >= 3));
|
||||||
|
mark('ONE perk maxed', ctx.legacy.some((p) => perkLevel(state, p) >= p.maxLevel));
|
||||||
|
mark('ALL perks maxed', ctx.legacy.every((p) => perkLevel(state, p) >= p.maxLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function produce(mods) {
|
||||||
|
const d = E.derive(state, ctx, mods);
|
||||||
|
E.setPrice(state, ctx, balancedPrice(market, Math.max(d.rate, 1), d.demandMultiplier));
|
||||||
|
if (!d.autoBuyMaterial && state.material < 1 + d.rate * 8) E.buyMaterial(state, ctx);
|
||||||
|
if (t - runStart < 300) for (let i = 0; i < 5 * dt; i++) E.click(state, ctx);
|
||||||
|
|
||||||
|
const upgrades = ctx.upgrades.filter((u) => E.upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
|
||||||
|
for (const u of upgrades) E.buyUpgrade(state, ctx, u.id);
|
||||||
|
|
||||||
|
if (d.rate < market.baseDemand * d.demandMultiplier * 2) {
|
||||||
|
const best = ctx.generators
|
||||||
|
.filter((g) => E.isUnlocked(state, g.unlock))
|
||||||
|
.map((g) => ({ g, q: E.generatorQuote(state, ctx, g.id, 1, mods), r: d.perGenerator[g.id] }))
|
||||||
|
.filter((c) => c.q.cost <= state.funds)
|
||||||
|
.sort((a, b) => a.q.cost / a.r - b.q.cost / b.r)[0];
|
||||||
|
if (best) E.buyGenerator(state, ctx, best.g.id, 1);
|
||||||
|
} else {
|
||||||
|
E.buyMarketing(state, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buyEndlessLevels() {
|
||||||
|
for (let k = 0; k < 40; k++) {
|
||||||
|
let best = null;
|
||||||
|
for (const line of ctx.endless) {
|
||||||
|
if (!lineAvailable(state, line)) continue;
|
||||||
|
const { cost } = endlessQuote(state, ctx, line.id, 1);
|
||||||
|
if (cost > state.funds * 0.5) continue; // keep a reserve
|
||||||
|
const score = Math.abs(Math.log(line.effect.value)) / cost;
|
||||||
|
if (!best || score > best.score) best = { line, score };
|
||||||
|
}
|
||||||
|
if (!best) return;
|
||||||
|
buyEndless(state, ctx, best.line.id, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function research() {
|
||||||
|
if (state.funds > labCost(state, ctx) * 4) buyLab(state, ctx);
|
||||||
|
const next = ctx.research
|
||||||
|
.filter((n) => researchStatus(state, n) === 'available')
|
||||||
|
.sort((a, b) => a.cost - b.cost)[0];
|
||||||
|
if (next && state.rp >= next.cost) buyResearch(state, ctx, next.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRestart() {
|
||||||
|
const gain = legacyGain(state, ctx, modifiersFor(state, ctx));
|
||||||
|
if (gain < 1 || gain < Math.max(1, state.legacy.earned * restartRatio)) return;
|
||||||
|
|
||||||
|
const rate = E.derive(state, ctx).rate;
|
||||||
|
const units = state.totalProduced;
|
||||||
|
const points = prestige(state, ctx);
|
||||||
|
restarts.push({
|
||||||
|
hour: t / 3600,
|
||||||
|
minutes: (t - runStart) / 60,
|
||||||
|
points,
|
||||||
|
earned: state.legacy.earned,
|
||||||
|
units,
|
||||||
|
rate,
|
||||||
|
});
|
||||||
|
runStart = t;
|
||||||
|
|
||||||
|
// spend the points on the cheapest perks
|
||||||
|
for (;;) {
|
||||||
|
const affordable = ctx.legacy
|
||||||
|
.map((perk) => ({ perk, cost: perkCost(perk, perkLevel(state, perk)) }))
|
||||||
|
.filter(({ perk, cost }) => perkLevel(state, perk) < perk.maxLevel && cost <= state.legacy.points)
|
||||||
|
.sort((a, b) => a.cost - b.cost);
|
||||||
|
if (!affordable.length) break;
|
||||||
|
buyPerk(state, ctx, affordable[0].perk.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (t < hours * 3600) {
|
||||||
|
const mods = modifiersFor(state, ctx);
|
||||||
|
produce(mods);
|
||||||
|
buyEndlessLevels();
|
||||||
|
research();
|
||||||
|
E.advance(state, ctx, dt, rng);
|
||||||
|
t += dt;
|
||||||
|
maybeRestart();
|
||||||
|
track();
|
||||||
|
|
||||||
|
const run = restarts.length + 1;
|
||||||
|
const minutes = (t - runStart) / 60;
|
||||||
|
for (const mark of [2, 10, 60]) {
|
||||||
|
const key = `${run}:${mark}`;
|
||||||
|
if (minutes >= mark && !probed.has(key)) {
|
||||||
|
probed.add(key);
|
||||||
|
probes.push({ run, minutes: mark, rate: E.derive(state, ctx).rate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, restarts, goals, probes, hours };
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Plays the whole game with a bot and prints a timeline: use it to tune the pacing.
|
||||||
|
//
|
||||||
|
// npm run campaign -- <hours> [seconds-per-step] e.g. npm run campaign -- 120 10
|
||||||
|
//
|
||||||
|
// Tuning knobs can be tried without editing the data files, from the environment:
|
||||||
|
// THRESH LEGACY_EXP PASSIVE_SCALE PASSIVE_POW legacy points and passive bonus
|
||||||
|
// PERK_GROWTH legacy perk cost growth
|
||||||
|
// RP_RATE LAB_GROWTH RESEARCH_COST research
|
||||||
|
// ENDLESS_COST ENDLESS_GROWTH_ADD endless upgrades
|
||||||
|
// SHOW=<n> how many restarts to list (default 8)
|
||||||
|
|
||||||
|
import { loadContext } from './context.js';
|
||||||
|
import { runCampaign } from './campaign.js';
|
||||||
|
|
||||||
|
const ctx = loadContext();
|
||||||
|
const env = (name, fallback) => (process.env[name] === undefined ? fallback : Number(process.env[name]));
|
||||||
|
|
||||||
|
ctx.rules.legacy.threshold = env('THRESH', ctx.rules.legacy.threshold);
|
||||||
|
ctx.rules.legacy.exponent = env('LEGACY_EXP', ctx.rules.legacy.exponent);
|
||||||
|
ctx.rules.legacy.passiveScale = env('PASSIVE_SCALE', ctx.rules.legacy.passiveScale);
|
||||||
|
ctx.rules.legacy.passivePower = env('PASSIVE_POW', ctx.rules.legacy.passivePower);
|
||||||
|
ctx.rules.research.rpPerLabPerSecond = env('RP_RATE', ctx.rules.research.rpPerLabPerSecond);
|
||||||
|
ctx.rules.research.labCostGrowth = env('LAB_GROWTH', ctx.rules.research.labCostGrowth);
|
||||||
|
for (const perk of ctx.legacy) perk.costGrowth = env('PERK_GROWTH', perk.costGrowth);
|
||||||
|
for (const node of ctx.research) node.cost *= env('RESEARCH_COST', 1);
|
||||||
|
for (const line of ctx.endless) {
|
||||||
|
line.baseCost *= env('ENDLESS_COST', 1);
|
||||||
|
line.costGrowth += env('ENDLESS_GROWTH_ADD', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Number(process.argv[2] ?? 24);
|
||||||
|
const dt = Number(process.argv[3] ?? 5);
|
||||||
|
const show = Number(process.env.SHOW ?? 8);
|
||||||
|
const sci = (n) => (n >= 1e6 ? n.toExponential(2) : n.toFixed(1));
|
||||||
|
|
||||||
|
const { state, restarts, goals, probes } = runCampaign(ctx, { hours, dt });
|
||||||
|
|
||||||
|
console.log(`Simulated ${hours} h of play (one step = ${dt} s)\n`);
|
||||||
|
for (const r of restarts.slice(0, show)) {
|
||||||
|
console.log(
|
||||||
|
` hour ${r.hour.toFixed(1).padStart(6)} restart #${String(restarts.indexOf(r) + 1).padEnd(3)} after ${String(Math.round(r.minutes)).padStart(5)} min: ${sci(r.units)} units (${sci(r.rate)}/s), +${r.points} points (total ${r.earned})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (restarts.length > show) console.log(` ... ${restarts.length - show} more`);
|
||||||
|
|
||||||
|
console.log('\nHow a run starts (production per second, X minutes after a restart):');
|
||||||
|
for (const run of [...new Set(probes.map((p) => p.run))].slice(0, 6)) {
|
||||||
|
const row = probes.filter((p) => p.run === run).map((p) => `${String(p.minutes).padStart(2)} min: ${sci(p.rate)}/s`);
|
||||||
|
console.log(` run ${String(run).padEnd(3)} ${row.join(' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nGoals (hour of play when reached):');
|
||||||
|
for (const [name, hour] of goals) console.log(` ${name.padEnd(26)} ${hour.toFixed(1)} h`);
|
||||||
|
const missing = ['ALL research nodes', 'ONE perk maxed', 'ALL perks maxed'].filter((n) => !goals.has(n));
|
||||||
|
if (missing.length) console.log(` not reached in ${hours} h: ${missing.join(', ')}`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nAt the end: ${restarts.length} restarts, ${state.legacy.earned} points, ${state.research.length}/${ctx.research.length} research, ${state.labs} labs, ${state.achievements.length} achievements`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Guards against the game being finished too quickly, or running away.
|
||||||
|
// Background: the first legacy system multiplied output by 1.05 per point, so a
|
||||||
|
// few thousand points gave 1e100 and a run took seconds (a player reported
|
||||||
|
// 6e136 units/s two minutes into their fourth game).
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { runCampaign } from '../scripts/campaign.js';
|
||||||
|
import { derive } from '../public/js/engine/engine.js';
|
||||||
|
import { legacyGain, pointsFor } from '../public/js/engine/legacy.js';
|
||||||
|
import { modifiersFor, passiveBonus } from '../public/js/engine/modifiers.js';
|
||||||
|
import { createState, sanitizeState, serializeState, STATE_VERSION } from '../public/js/engine/state.js';
|
||||||
|
import { loadContext } from './helpers.js';
|
||||||
|
|
||||||
|
const ctx = loadContext();
|
||||||
|
const NOW = 1_800_000_000_000;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- the reported save
|
||||||
|
|
||||||
|
/** What the broken version could leave behind after a few restarts. */
|
||||||
|
function runawaySave(version) {
|
||||||
|
return {
|
||||||
|
v: version,
|
||||||
|
units: 1e130,
|
||||||
|
funds: 1e135,
|
||||||
|
material: 1e140,
|
||||||
|
totalProduced: 1e137,
|
||||||
|
totalSold: 1e136,
|
||||||
|
price: 0.25,
|
||||||
|
marketing: 120,
|
||||||
|
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 40])),
|
||||||
|
upgrades: ctx.upgrades.map((u) => u.id),
|
||||||
|
endless: { e_global: 400 },
|
||||||
|
lifetimeProduced: 1e139,
|
||||||
|
research: ['r_prod_1', 'r_mkt_1'],
|
||||||
|
labs: 7,
|
||||||
|
rp: 123,
|
||||||
|
achievements: ['a_prod_1', 'a_gen_1'],
|
||||||
|
stats: { gambleWins: 3 },
|
||||||
|
legacy: {
|
||||||
|
points: 4000,
|
||||||
|
earned: 5000,
|
||||||
|
prestiges: 3,
|
||||||
|
perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])),
|
||||||
|
},
|
||||||
|
savedAt: NOW - 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a save corrupted by the old runaway bonus is repaired on load', () => {
|
||||||
|
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||||
|
assert.equal(s.repaired, true);
|
||||||
|
assert.equal(s.v, STATE_VERSION);
|
||||||
|
|
||||||
|
// legacy is capped, research and achievements are kept
|
||||||
|
assert.ok(s.legacy.earned <= 10);
|
||||||
|
assert.equal(s.legacy.points, 0);
|
||||||
|
assert.equal(s.legacy.prestiges, 3);
|
||||||
|
assert.ok(Object.values(s.legacy.perks).every((level) => level <= 3));
|
||||||
|
assert.deepEqual(s.research, ['r_prod_1', 'r_mkt_1']);
|
||||||
|
assert.deepEqual(s.achievements, ['a_prod_1', 'a_gen_1']);
|
||||||
|
assert.equal(s.labs, 7);
|
||||||
|
assert.equal(s.rp, 123);
|
||||||
|
assert.equal(s.stats.gambleWins, 3);
|
||||||
|
|
||||||
|
// the meaningless run starts over
|
||||||
|
assert.equal(s.funds, 0);
|
||||||
|
assert.equal(s.totalProduced, 0);
|
||||||
|
assert.deepEqual(s.upgrades, []);
|
||||||
|
assert.deepEqual(s.endless, {});
|
||||||
|
assert.ok(Object.values(s.generators).every((n) => n === 0));
|
||||||
|
|
||||||
|
// the huge lifetime total cannot hand out a pile of points at the next restart
|
||||||
|
assert.ok(legacyGain(s, ctx, modifiersFor(s, ctx)) <= 3);
|
||||||
|
assert.ok(pointsFor(s.lifetimeProduced, ctx, modifiersFor(s, ctx)) <= s.legacy.earned + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a repaired save plays at a normal speed', () => {
|
||||||
|
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||||
|
s.generators.gen1 = 10;
|
||||||
|
const rate = derive(s, ctx).rate;
|
||||||
|
assert.ok(Number.isFinite(rate) && rate < 1000, `production is ${rate}/s`);
|
||||||
|
assert.ok(modifiersFor(s, ctx).global < 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the repair happens once: a repaired save loads unchanged', () => {
|
||||||
|
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||||
|
const again = sanitizeState(JSON.parse(serializeState(s)), ctx, NOW);
|
||||||
|
assert.equal(again.repaired, false);
|
||||||
|
assert.deepEqual(again.legacy, s.legacy);
|
||||||
|
// saves keep 10 significant digits
|
||||||
|
assert.ok(Math.abs(again.lifetimeProduced / s.lifetimeProduced - 1) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old but honest save keeps its legacy progress (only the lifetime total is capped)', () => {
|
||||||
|
const save = { ...createState(ctx, NOW), v: 2 };
|
||||||
|
save.legacy = { points: 2, earned: 3, prestiges: 3, perks: { lg_prod: 2 } };
|
||||||
|
save.generators.gen1 = 5;
|
||||||
|
save.totalProduced = 5e9;
|
||||||
|
save.lifetimeProduced = 2e10;
|
||||||
|
const s = sanitizeState(JSON.parse(serializeState(save)), ctx, NOW);
|
||||||
|
assert.equal(s.repaired, false);
|
||||||
|
assert.deepEqual(s.legacy, save.legacy);
|
||||||
|
assert.equal(s.generators.gen1, 5, 'the run is untouched');
|
||||||
|
assert.equal(s.lifetimeProduced, 2e10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a current save is never touched, however big', () => {
|
||||||
|
const save = sanitizeState(runawaySave(STATE_VERSION), ctx, NOW);
|
||||||
|
assert.equal(save.repaired, false);
|
||||||
|
assert.equal(save.legacy.earned, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an export from an older version is repaired too', () => {
|
||||||
|
const s = sanitizeState(runawaySave(1), ctx, NOW);
|
||||||
|
assert.equal(s.repaired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- hard bounds
|
||||||
|
|
||||||
|
test('everything permanent, at its maximum, is a bounded boost', () => {
|
||||||
|
const s = createState(ctx, NOW);
|
||||||
|
s.research = ctx.research.map((r) => r.id);
|
||||||
|
s.achievements = ctx.achievements.map((a) => a.id);
|
||||||
|
s.legacy = {
|
||||||
|
points: 0,
|
||||||
|
earned: 1e6, // a million points: the number that used to give 1e100 at 5000
|
||||||
|
prestiges: 50,
|
||||||
|
perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])),
|
||||||
|
};
|
||||||
|
const m = modifiersFor(s, ctx);
|
||||||
|
assert.ok(m.global < 1e5, `global x${m.global}`);
|
||||||
|
assert.ok(m.demand < 1e5, `demand x${m.demand}`);
|
||||||
|
assert.ok(passiveBonus(s, ctx) < 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('points need ever more production: a trillion times more gives far fewer than a trillion times the points', () => {
|
||||||
|
const m = modifiersFor(createState(ctx), ctx);
|
||||||
|
const base = pointsFor(ctx.rules.legacy.threshold, ctx, m);
|
||||||
|
const huge = pointsFor(ctx.rules.legacy.threshold * 1e12, ctx, m);
|
||||||
|
assert.equal(base, 1);
|
||||||
|
assert.ok(huge < 1e6, `${huge} points`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- the pacing
|
||||||
|
|
||||||
|
test('the game is not finished quickly: 40 hours of efficient play', () => {
|
||||||
|
const { state, restarts, goals, probes } = runCampaign(ctx, { hours: 40, dt: 10 });
|
||||||
|
|
||||||
|
assert.ok(goals.get('first restart') >= 6, `first restart after ${goals.get('first restart')?.toFixed(1)} h`);
|
||||||
|
assert.ok(restarts.length < 5, `${restarts.length} restarts in 40 h`);
|
||||||
|
assert.ok(!goals.has('ALL research nodes'), 'all the research is not done in 40 h');
|
||||||
|
assert.ok(!goals.has('ONE perk maxed'), 'no perk is maxed in 40 h');
|
||||||
|
assert.ok(Math.max(...Object.values(state.legacy.perks), 0) <= 4, 'perks are still low');
|
||||||
|
|
||||||
|
// but it does move: this is not a wall either
|
||||||
|
assert.ok(state.research.length >= 5, 'research progresses');
|
||||||
|
assert.ok(restarts.length >= 1, 'a restart is reachable within 40 h');
|
||||||
|
assert.ok(state.lifetimeProduced > 1e13);
|
||||||
|
|
||||||
|
// a new run after a restart is quicker, but never absurd (it once reached 6e136/s in 2 minutes)
|
||||||
|
for (const probe of probes) {
|
||||||
|
assert.ok(Number.isFinite(probe.rate), 'finite production');
|
||||||
|
if (probe.minutes === 2) assert.ok(probe.rate < 1e6, `run ${probe.run}: ${probe.rate}/s after 2 min`);
|
||||||
|
if (probe.minutes === 60) assert.ok(probe.rate < 1e12, `run ${probe.run}: ${probe.rate}/s after 1 h`);
|
||||||
|
}
|
||||||
|
for (const value of [state.funds, state.units, state.material, state.totalProduced]) {
|
||||||
|
assert.ok(Number.isFinite(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -51,7 +51,7 @@ test('costs grow exponentially, level after level', () => {
|
|||||||
|
|
||||||
test('buying several levels charges the exact sum', () => {
|
test('buying several levels charges the exact sum', () => {
|
||||||
const s = finished();
|
const s = finished();
|
||||||
s.funds = 1e13;
|
s.funds = 1e15;
|
||||||
const l = line('e_demand');
|
const l = line('e_demand');
|
||||||
const before = s.funds;
|
const before = s.funds;
|
||||||
assert.ok(buyEndless(s, ctx, 'e_demand', 10));
|
assert.ok(buyEndless(s, ctx, 'e_demand', 10));
|
||||||
@@ -128,7 +128,7 @@ test('even absurd levels never produce NaN or Infinity', () => {
|
|||||||
test('endless levels belong to the run: a legacy restart resets them', () => {
|
test('endless levels belong to the run: a legacy restart resets them', () => {
|
||||||
const s = finished();
|
const s = finished();
|
||||||
s.endless.e_global = 12;
|
s.endless.e_global = 12;
|
||||||
s.totalProduced = 1e10;
|
s.totalProduced = s.lifetimeProduced = 1e14;
|
||||||
prestige(s, ctx);
|
prestige(s, ctx);
|
||||||
assert.deepEqual(s.endless, {});
|
assert.deepEqual(s.endless, {});
|
||||||
});
|
});
|
||||||
|
|||||||
+66
-20
@@ -13,8 +13,8 @@ import {
|
|||||||
reliabilityCost,
|
reliabilityCost,
|
||||||
} from '../public/js/engine/gamble.js';
|
} from '../public/js/engine/gamble.js';
|
||||||
import { buyShares, holdingValue, portfolio, sellShares } from '../public/js/engine/investments.js';
|
import { buyShares, holdingValue, portfolio, sellShares } from '../public/js/engine/investments.js';
|
||||||
import { buyPerk, legacyGain, perkCost, prestige } from '../public/js/engine/legacy.js';
|
import { buyPerk, legacyGain, nextPointAt, perkCost, pointsFor, prestige } from '../public/js/engine/legacy.js';
|
||||||
import { modifiersFor } from '../public/js/engine/modifiers.js';
|
import { modifiersFor, passiveBonus } from '../public/js/engine/modifiers.js';
|
||||||
import { buyLab, buyResearch, labCost, researchStatus } from '../public/js/engine/research.js';
|
import { buyLab, buyResearch, labCost, researchStatus } from '../public/js/engine/research.js';
|
||||||
import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
|
import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
|
||||||
import { loadContext } from './helpers.js';
|
import { loadContext } from './helpers.js';
|
||||||
@@ -241,22 +241,45 @@ test('the price advisor research reveals the balanced price', () => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------- legacy
|
// ---------------------------------------------------------------------- legacy
|
||||||
|
|
||||||
test('a legacy restart needs a big enough run, and rewards the square root of it', () => {
|
test('legacy points come from LIFETIME production, with diminishing returns', () => {
|
||||||
|
const { threshold, exponent } = ctx.rules.legacy;
|
||||||
|
const points = (lifetime) => pointsFor(lifetime, ctx, mods(fresh()));
|
||||||
|
assert.equal(points(threshold - 1), 0, 'nothing below the threshold');
|
||||||
|
assert.equal(points(threshold), 1);
|
||||||
|
|
||||||
|
// To double the points, production must be multiplied by 2 ** (1 / exponent): far more than 2.
|
||||||
|
const factor = 2 ** (1 / exponent);
|
||||||
|
assert.ok(factor > 4);
|
||||||
|
assert.equal(points(threshold * 4 ** (1 / exponent) * 1.001), 4);
|
||||||
|
assert.ok(points(threshold * 100) < 100, 'a 100x bigger lifetime does not give 100x the points');
|
||||||
|
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.totalProduced = ctx.rules.legacy.threshold - 1;
|
s.legacy.earned = 3;
|
||||||
|
assert.ok(Math.abs(nextPointAt(s, ctx) - threshold * 4 ** (1 / exponent)) < 1, 'the next point is at the 4th step');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a restart gives only what was NOT already rewarded: restarting often cannot snowball', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.lifetimeProduced = s.totalProduced = 6e13;
|
||||||
|
assert.equal(legacyGain(s, ctx, mods(s)), 2);
|
||||||
|
assert.equal(prestige(s, ctx), 2);
|
||||||
|
|
||||||
|
// Straight away, with the same lifetime, there is nothing left to claim.
|
||||||
|
assert.equal(legacyGain(s, ctx, mods(s)), 0);
|
||||||
|
assert.equal(prestige(s, ctx), 0);
|
||||||
|
|
||||||
|
// A short new run changes little: points are about the whole lifetime, not about each run.
|
||||||
|
s.lifetimeProduced += 1e13;
|
||||||
assert.equal(legacyGain(s, ctx, mods(s)), 0);
|
assert.equal(legacyGain(s, ctx, mods(s)), 0);
|
||||||
s.totalProduced = ctx.rules.legacy.threshold;
|
|
||||||
assert.equal(legacyGain(s, ctx, mods(s)), 1);
|
|
||||||
s.totalProduced = ctx.rules.legacy.threshold * 100;
|
|
||||||
assert.equal(legacyGain(s, ctx, mods(s)), 10);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restarting resets the run but keeps the meta progression', () => {
|
test('restarting resets the run but keeps the meta progression', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.totalProduced = s.lifetimeProduced = 4e9;
|
s.totalProduced = s.lifetimeProduced = 6e13;
|
||||||
s.funds = 1e9;
|
s.funds = 1e9;
|
||||||
s.generators.gen3 = 40;
|
s.generators.gen3 = 40;
|
||||||
s.upgrades = ['click_1'];
|
s.upgrades = ['click_1'];
|
||||||
|
s.endless = { e_global: 4 };
|
||||||
s.chips = 12;
|
s.chips = 12;
|
||||||
s.invest.holdings.inv_low_1 = 5;
|
s.invest.holdings.inv_low_1 = 5;
|
||||||
s.research = ['r_prod_1'];
|
s.research = ['r_prod_1'];
|
||||||
@@ -270,9 +293,10 @@ test('restarting resets the run but keeps the meta progression', () => {
|
|||||||
assert.equal(s.totalProduced, 0);
|
assert.equal(s.totalProduced, 0);
|
||||||
assert.equal(s.generators.gen3, 0);
|
assert.equal(s.generators.gen3, 0);
|
||||||
assert.deepEqual(s.upgrades, []);
|
assert.deepEqual(s.upgrades, []);
|
||||||
|
assert.deepEqual(s.endless, {});
|
||||||
assert.equal(s.chips, 0);
|
assert.equal(s.chips, 0);
|
||||||
assert.equal(s.invest.holdings.inv_low_1, 0);
|
assert.equal(s.invest.holdings.inv_low_1, 0);
|
||||||
assert.equal(s.lifetimeProduced, 4e9);
|
assert.equal(s.lifetimeProduced, 6e13);
|
||||||
assert.deepEqual(s.research, ['r_prod_1']);
|
assert.deepEqual(s.research, ['r_prod_1']);
|
||||||
assert.equal(s.labs, 2);
|
assert.equal(s.labs, 2);
|
||||||
assert.equal(s.rp, 33);
|
assert.equal(s.rp, 33);
|
||||||
@@ -283,41 +307,63 @@ test('restarting resets the run but keeps the meta progression', () => {
|
|||||||
|
|
||||||
test('restarting is refused below the threshold', () => {
|
test('restarting is refused below the threshold', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.totalProduced = 1e6;
|
s.totalProduced = s.lifetimeProduced = 1e6;
|
||||||
assert.equal(prestige(s, ctx), 0);
|
assert.equal(prestige(s, ctx), 0);
|
||||||
assert.equal(s.totalProduced, 1e6);
|
assert.equal(s.totalProduced, 1e6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the passive bonus grows slowly and can never run away (it once reached 1e100)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const at = (earned) => passiveBonus({ ...s, legacy: { ...s.legacy, earned } }, ctx);
|
||||||
|
assert.equal(at(0), 1);
|
||||||
|
assert.ok(at(1) > 1 && at(1) < 1.5);
|
||||||
|
assert.ok(at(100) < 3);
|
||||||
|
assert.ok(at(400) < 2 * at(100), 'four times the points, less than twice the bonus');
|
||||||
|
assert.ok(at(1e6) < 1e3, 'a million points is nowhere near astronomical');
|
||||||
|
assert.ok(at(5000) < 100, 'the number of points that used to give 1e100');
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy points give a passive bonus and buy perks that stay after a restart', () => {
|
test('legacy points give a passive bonus and buy perks that stay after a restart', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.legacy = { points: 10, earned: 10, prestiges: 1, perks: {} };
|
s.legacy = { points: 10, earned: 10, prestiges: 1, perks: {} };
|
||||||
s.generators.gen1 = 10;
|
s.generators.gen1 = 10;
|
||||||
const base = derive({ ...s, legacy: { ...s.legacy, earned: 0 } }, ctx).rate;
|
const base = derive({ ...s, legacy: { ...s.legacy, earned: 0 } }, ctx).rate;
|
||||||
assert.ok(Math.abs(derive(s, ctx).rate / base - 1.05 ** 10) < 1e-9, 'passive +5 % per point earned');
|
const bonus = passiveBonus(s, ctx);
|
||||||
|
assert.ok(Math.abs(derive(s, ctx).rate / base - bonus) < 1e-9);
|
||||||
|
assert.ok(bonus > 1 && bonus < 2, 'a modest bonus');
|
||||||
|
|
||||||
const perk = ctx.perksById.get('lg_prod');
|
const perk = ctx.perksById.get('lg_prod');
|
||||||
const cost = perkCost(perk, 0);
|
const cost = perkCost(perk, 0);
|
||||||
assert.ok(buyPerk(s, ctx, 'lg_prod'));
|
assert.ok(buyPerk(s, ctx, 'lg_prod'));
|
||||||
assert.equal(s.legacy.points, 10 - cost);
|
assert.equal(s.legacy.points, 10 - cost);
|
||||||
assert.ok(Math.abs(derive(s, ctx).rate / base - 1.05 ** 10 * 1.3) < 1e-9);
|
assert.ok(Math.abs(derive(s, ctx).rate / base - bonus * perk.effect.value) < 1e-9);
|
||||||
|
|
||||||
s.totalProduced = 1e9;
|
s.lifetimeProduced = s.totalProduced = 1e16;
|
||||||
prestige(s, ctx);
|
assert.ok(prestige(s, ctx) > 0);
|
||||||
assert.equal(s.legacy.perks.lg_prod, 1, 'perk survives the restart');
|
assert.equal(s.legacy.perks.lg_prod, 1, 'perk survives the restart');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('perks have levels, growing costs and a cap; head start grants funds', () => {
|
test('perks have levels, steeply growing costs and a cap; head start grants funds', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.legacy.points = 1e6;
|
s.legacy.points = 1e9;
|
||||||
const perk = ctx.perksById.get('lg_start');
|
const perk = ctx.perksById.get('lg_start');
|
||||||
assert.ok(perkCost(perk, 3) > perkCost(perk, 0));
|
assert.ok(perkCost(perk, 3) >= perkCost(perk, 0) * 6, 'each level costs about twice the last');
|
||||||
for (let i = 0; i < 20; i++) buyPerk(s, ctx, 'lg_start');
|
for (let i = 0; i < 20; i++) buyPerk(s, ctx, 'lg_start');
|
||||||
assert.equal(s.legacy.perks.lg_start, perk.maxLevel);
|
assert.equal(s.legacy.perks.lg_start, perk.maxLevel);
|
||||||
s.totalProduced = 1e9;
|
s.lifetimeProduced = s.totalProduced = 1e13;
|
||||||
prestige(s, ctx);
|
assert.equal(prestige(s, ctx), 1);
|
||||||
assert.equal(s.funds, perk.effect.value * perk.maxLevel);
|
assert.equal(s.funds, perk.effect.value * perk.maxLevel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the legacy perks together are a bounded boost, not a runaway', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.generators.gen1 = 10;
|
||||||
|
const base = derive(s, ctx).rate;
|
||||||
|
s.legacy.perks = Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel]));
|
||||||
|
const boosted = derive(s, ctx).rate / base;
|
||||||
|
assert.ok(boosted > 3 && boosted < 20, `all perks maxed multiply production by ${boosted.toFixed(1)}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('generator cost perks make generators cheaper', () => {
|
test('generator cost perks make generators cheaper', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.funds = 1e6;
|
s.funds = 1e6;
|
||||||
|
|||||||
Reference in New Issue
Block a user