From fc0d5e3ba42dd31a4c0cae4fd2d07aa843243a4d Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Thu, 8 Oct 2026 14:03:00 +0200 Subject: [PATCH] feat: New features (research, invest, gambling, new game+, trophies) --- README.md | 29 +- public/css/base.css | 2 + public/css/components.css | 219 +++++++++ public/css/layout.css | 118 ++++- public/data/achievements.json | 25 + public/data/investments.json | 70 +++ public/data/legacy.json | 8 + public/data/research.json | 17 + public/data/rules.json | 131 +++++- public/data/tabs.json | 9 + public/index.html | 80 ++-- public/js/engine/achievements.js | 35 ++ public/js/engine/context.js | 80 +++- public/js/engine/contracts.js | 144 ++++++ public/js/engine/effects.js | 74 ++- public/js/engine/engine.js | 77 +++- public/js/engine/formulas.js | 8 + public/js/engine/gamble.js | 77 ++++ public/js/engine/investments.js | 75 +++ public/js/engine/legacy.js | 52 +++ public/js/engine/modifiers.js | 36 ++ public/js/engine/research.js | 38 ++ public/js/engine/state.js | 174 ++++++- public/js/main.js | 96 +++- public/js/services/data.js | 22 +- public/js/ui/achievementsView.js | 57 +++ public/js/ui/contractsView.js | 164 +++++++ public/js/ui/dom.js | 7 +- public/js/ui/gambleView.js | 167 +++++++ public/js/ui/investView.js | 181 ++++++++ public/js/ui/legacyView.js | 106 +++++ public/js/ui/researchView.js | 114 +++++ public/js/ui/tabs.js | 89 ++++ public/js/ui/text.js | 51 +++ public/js/ui/toast.js | 5 +- public/locales/en.json | 224 ++++++++- public/locales/fr.json | 224 ++++++++- public/themes/alien/locales/en.json | 82 +++- public/themes/alien/locales/fr.json | 82 +++- public/themes/alien/theme.css | 2 + public/themes/computers/locales/en.json | 82 +++- public/themes/computers/locales/fr.json | 82 +++- public/themes/computers/theme.css | 2 + public/themes/insects/locales/en.json | 82 +++- public/themes/insects/locales/fr.json | 82 +++- public/themes/insects/theme.css | 2 + public/themes/paperclip/locales/en.json | 82 +++- public/themes/paperclip/locales/fr.json | 82 +++- public/themes/paperclip/theme.css | 2 + public/themes/witch/locales/en.json | 82 +++- public/themes/witch/locales/fr.json | 82 +++- public/themes/witch/theme.css | 2 + tests/content.test.js | 72 ++- tests/engine.test.js | 7 - tests/helpers.js | 8 +- tests/systems.test.js | 579 ++++++++++++++++++++++++ 56 files changed, 4332 insertions(+), 249 deletions(-) create mode 100644 public/data/achievements.json create mode 100644 public/data/investments.json create mode 100644 public/data/legacy.json create mode 100644 public/data/research.json create mode 100644 public/data/tabs.json create mode 100644 public/js/engine/achievements.js create mode 100644 public/js/engine/contracts.js create mode 100644 public/js/engine/gamble.js create mode 100644 public/js/engine/investments.js create mode 100644 public/js/engine/legacy.js create mode 100644 public/js/engine/modifiers.js create mode 100644 public/js/engine/research.js create mode 100644 public/js/ui/achievementsView.js create mode 100644 public/js/ui/contractsView.js create mode 100644 public/js/ui/gambleView.js create mode 100644 public/js/ui/investView.js create mode 100644 public/js/ui/legacyView.js create mode 100644 public/js/ui/researchView.js create mode 100644 public/js/ui/tabs.js create mode 100644 public/js/ui/text.js create mode 100644 tests/systems.test.js diff --git a/README.md b/README.md index e4063fa..5262892 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Idle Clicker -A themeable idle game inspired by *Universal Paperclips*. Make units by hand, sell them, automate production, buy upgrades. Runs in any browser (phone, tablet, desktop); progress is saved in cookies and can be exported/imported as a JSON or TXT file. There is **one game**: themes (paperclips, witch potions, alien minerals, leaf-piling insects) are only skins, so switching theme keeps every amount. +A themeable idle game inspired by *Universal Paperclips*. Make units by hand, sell them, automate production, buy upgrades. Runs in any browser (phone, tablet, desktop); progress is saved in cookies and can be exported/imported as a JSON or TXT file. There is **one game**: themes (paperclips, witch potions, alien minerals, leaf-piling insects, computers) are only skins, so switching theme keeps every amount. Zero runtime dependencies: a small Node server (`http` module only) plus a vanilla JS frontend (ES modules). @@ -21,22 +21,39 @@ server/ static file server + /api/themes and /api/locales (auto public/ index.html page skeleton (texts come from i18n via data-i18n attributes) css/ base (tokens) · layout (responsive grid) · components - data/ GAME CONTENT, theme-independent: rules, generators, upgrades (ids + numbers) + data/ GAME CONTENT, theme-independent (ids + numbers): rules, generators, upgrades, + investments, research, legacy perks, achievements, tabs locales/ core UI texts, one file per language (en, fr) themes// ONE FOLDER PER THEME theme.json order + (later) image paths theme.css colours/fonts, as CSS variables locales/.json the theme's vocabulary: names of resources, generators, upgrades js/ - engine/ game rules, pure JS, no DOM (also used by the tests) + engine/ game rules, pure JS, no DOM (also used by the tests): engine (production, market, + time) + one module per system (investments, gamble, research, legacy, contracts, achievements) services/ cookies · storage · save import/export · i18n · formatting · data loading - ui/ DOM helpers · main view · settings dialog · toasts + ui/ DOM helpers · main view · one view per section · tabs/menu · settings · toasts main.js wires everything together -tests/ node:test suites (engine, save import/export, server, content completeness) +tests/ node:test suites (engine, systems, 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 game beyond the factory + +The production screen is the core loop. Further sections appear in the menu as you progress (a tab bar on tablets and desktops, a burger menu on phones; the three resources stay visible in a bar at the top): + +| Section | Unlocks at (lifetime production) | What it is | +|---|---|---| +| Contracts | 20 K | Sell a target amount in time for funds, research, chips or a production boost; opportunities (surge, rush, windfall) to seize within seconds; rare incidents. | +| 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. | +| 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. | +| 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. + ## Add a theme 1. Copy `public/themes/paperclip/` to `public/themes//` (lowercase letters, digits, dashes). @@ -67,7 +84,7 @@ Keys: `res.units|funds|material`, `gen.`, `upg.`. Anything not listed ke - One shared game for all themes, stored in cookies (`ic_game_*`, chunked and base64-encoded because a cookie holds ~4 KB), plus one preferences cookie (theme + language). Saves from the first version (one per theme) are merged automatically: the most advanced one is kept. - **Save & settings** exports the save as `.json` or `.txt` (download or copy/paste) and imports either (file picker or pasted text). The file does not depend on the theme. Imports are validated and sanitised: unknown ids are dropped, numbers clamped. - Progress continues while the game is closed: up to 8 h, at 50 % efficiency (`public/data/rules.json`). -- Cookies are sent with every request to the server. A save is under 2 KB, far below header limits, but it is the reason to keep saves compact. +- Cookies are sent with every request to the server. A late-game save is about 4 KB (base64), far below header limits, but it is the reason to keep saves compact. ## Tuning the game diff --git a/public/css/base.css b/public/css/base.css index d444c37..4e424ed 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -15,6 +15,8 @@ --accent: #2563eb; --accent-contrast: #ffffff; --danger: #c0392b; + --good: #1a7f4b; /* gains */ + --bad: #c0392b; /* losses */ --radius: 10px; --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; diff --git a/public/css/components.css b/public/css/components.css index d6ffd18..2e3049c 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -423,3 +423,222 @@ select, background: var(--danger); color: #fff; } + +/* ------------------------------------------------------- resource bar */ + +.res { + display: flex; + flex: 1 1 0; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.res .icon { + --icon-size: 1.75rem; +} + +.res > div { + min-width: 0; +} + +.res__label { + display: block; + overflow: hidden; + color: var(--text-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.res strong { + display: block; + overflow: hidden; + font-size: 1.05rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.res--primary strong { + color: var(--accent); +} + +@media (max-width: 519px) { + .resourcebar { + gap: 0.5rem; + padding: 0.35rem 0.75rem; + } + + .res .icon { + display: none; + } + + .res strong { + font-size: 0.95rem; + } +} + +/* ----------------------------------------------------------------- tabs */ + +.tab { + position: relative; + width: 100%; + min-height: 44px; + padding: 0.4rem 1rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + text-align: left; + touch-action: manipulation; +} + +.tab[aria-current='page'] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); + font-weight: 600; +} + +@media (min-width: 760px) { + .tab { + width: auto; + text-align: center; + } +} + +/* A dot: something new (newly unlocked) or waiting (an opportunity to seize). */ +.tab--new .tab::after, +.tab--alert .tab::after { + position: absolute; + top: 6px; + right: 6px; + width: 10px; + height: 10px; + background: var(--danger); + border: 2px solid var(--surface); + border-radius: 50%; + content: ''; +} + +.tab--alert .tab::after { + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + 50% { + transform: scale(1.5); + } +} + +@media (prefers-reduced-motion: reduce) { + .tab--alert .tab::after { + animation: none; + } +} + +/* ------------------------------------------------------------ gains/losses */ + +.pos { + color: var(--good); +} + +.neg { + color: var(--bad); +} + +.badge.pos, +.badge.neg { + background: transparent; + border: 1px solid currentColor; +} + +/* --------------------------------------------------- cards with actions */ + +.card--stack { + flex-direction: column; +} + +.card__row { + display: flex; + align-items: flex-start; + gap: var(--gap); +} + +.card__actions { + display: flex; + gap: var(--gap); +} + +.card__actions .btn { + flex: 1; +} + +.card--done { + border-left: 4px solid var(--good); +} + +.card--locked { + opacity: 0.6; +} + +.cards--grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); +} + +.panel--low .panel__title { color: var(--good); } +.panel--high .panel__title { color: var(--bad); } + +/* ---------------------------------------------- contracts, gamble, legacy */ + +.callout { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.75rem; + background: var(--surface-2); + border: 2px solid var(--accent); + border-radius: var(--radius); +} + +.progress { + width: 100%; + height: 14px; + accent-color: var(--accent); +} + +.log { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.log__entry { + padding: 0.3rem 0.6rem; + background: var(--surface-2); + border-left: 4px solid var(--border); + border-radius: 4px; + font-size: 0.9rem; +} + +.log__entry--win, +.log__entry--jackpot { + border-left-color: var(--good); +} + +.log__entry--lose, +.log__entry--crash { + border-left-color: var(--bad); +} + +.log__entry--jackpot, +.log__entry--crash { + font-weight: 700; +} + +/* A hint under a big figure goes on its own line (rate under research points, bonus under chips). */ +.tile .hint { + display: block; +} diff --git a/public/css/layout.css b/public/css/layout.css index 5398433..15a1878 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -1,10 +1,14 @@ -/* Page layout. Mobile first: one column, then 2 and 3 columns as space allows. */ +/* Page layout. Mobile first: one column, a burger menu; then tabs and more columns as space allows. */ + +/* --------------------------------------------------------------- top bar */ .topbar { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'title toggle' + 'menu menu'; align-items: center; - justify-content: space-between; gap: var(--gap); padding: var(--gap) 1rem; background: var(--surface); @@ -12,22 +16,102 @@ } .topbar__title { + grid-area: title; font-size: 1.25rem; + overflow-wrap: anywhere; } -.topbar__controls { +.menu-toggle { + grid-area: toggle; +} + +/* Phones: the menu is a drop-down opened by the burger button. */ +.menu { + grid-area: menu; + display: none; + flex-direction: column; + gap: var(--gap); +} + +.menu[data-open] { + display: flex; +} + +.tabs { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.menu__controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--gap); + padding-top: var(--gap); + border-top: 1px solid var(--border); } +/* Tablet and desktop: the tabs form a bar under the title, the controls sit on the right. */ +@media (min-width: 760px) { + .topbar { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'title controls' + 'tabs tabs'; + row-gap: 0.5rem; + } + + /* More specific than .btn (components.css loads later) so the burger really disappears. */ + .topbar .menu-toggle { + display: none; + } + + /* The nav's children join the top bar grid directly. */ + .menu, + .menu[data-open] { + display: contents; + } + + .tabs { + grid-area: tabs; + flex-direction: row; + flex-wrap: wrap; + } + + .menu__controls { + grid-area: controls; + padding-top: 0; + border-top: 0; + } +} + +/* -------------------------------------------------------- resource bar */ + +.resourcebar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + gap: var(--gap); + padding: 0.4rem 1rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: 0 2px 6px rgb(0 0 0 / 0.12); +} + +/* ----------------------------------------------------------------- views */ + +.views { + max-width: 1400px; + margin: 0 auto; +} + +/* The production section is a named grid, the others a simple responsive one. */ .game { display: grid; gap: 1rem; padding: 1rem; - max-width: 1400px; - margin: 0 auto; grid-template-columns: minmax(0, 1fr); grid-template-areas: 'stats' @@ -37,6 +121,14 @@ 'upgrades'; } +.view { + display: grid; + gap: 1rem; + padding: 1rem; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + .panel--stats { grid-area: stats; } .panel--production { grid-area: production; } .panel--market { grid-area: market; } @@ -53,6 +145,14 @@ 'generators upgrades'; align-items: start; } + + .view { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .panel--wide { + grid-column: 1 / -1; + } } /* Desktop */ @@ -65,4 +165,8 @@ 'production generators upgrades' 'market generators upgrades'; } + + .view { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } diff --git a/public/data/achievements.json b/public/data/achievements.json new file mode 100644 index 0000000..0b42bb7 --- /dev/null +++ b/public/data/achievements.json @@ -0,0 +1,25 @@ +[ + {"id":"a_prod_1","condition":{"stat":"lifetimeProduced","min":1000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_prod_2","condition":{"stat":"lifetimeProduced","min":1000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_prod_3","condition":{"stat":"lifetimeProduced","min":1000000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_prod_4","condition":{"stat":"lifetimeProduced","min":1000000000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_prod_5","condition":{"stat":"lifetimeProduced","min":1000000000000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gen_1","condition":{"stat":"generatorsOwned","min":10},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gen_2","condition":{"stat":"generatorsOwned","min":100},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gen_3","condition":{"stat":"generatorsOwned","min":500},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_upg_1","condition":{"stat":"upgradesOwned","min":10},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_upg_2","condition":{"stat":"upgradesOwned","min":30},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_inv_1","condition":{"stat":"investProfit","min":1000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_inv_2","condition":{"stat":"investProfit","min":1000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_inv_3","condition":{"stat":"investProfit","min":1000000000},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gam_1","condition":{"stat":"gambleWins","min":5},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gam_2","condition":{"stat":"gambleWins","min":50},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_gam_3","condition":{"stat":"chips","min":25},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_con_1","condition":{"stat":"contractsDone","min":1},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_con_2","condition":{"stat":"contractsDone","min":10},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_res_1","condition":{"stat":"researchOwned","min":1},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_res_2","condition":{"stat":"researchOwned","min":15},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_leg_1","condition":{"stat":"prestiges","min":1},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_leg_2","condition":{"stat":"prestiges","min":5},"effect":{"type":"global_mult","value":1.02}}, + {"id":"a_evt_1","condition":{"stat":"eventsClaimed","min":5},"effect":{"type":"global_mult","value":1.02}} +] diff --git a/public/data/investments.json b/public/data/investments.json new file mode 100644 index 0000000..e0035ff --- /dev/null +++ b/public/data/investments.json @@ -0,0 +1,70 @@ +{ + "intervalSeconds": 60, + "fee": 0.005, + "minRatio": 0.05, + "maxRatio": 20, + "tiers": { + "low": { + "swing": 0.05, + "dividend": 0.0015, + "reversion": 0.04 + }, + "mid": { + "swing": 0.15, + "dividend": 0.004, + "reversion": 0.025 + }, + "high": { + "swing": 0.5, + "dividend": 0.009, + "reversion": 0.012 + } + }, + "societies": [ + { + "id": "inv_low_1", + "tier": "low", + "basePrice": 100 + }, + { + "id": "inv_low_2", + "tier": "low", + "basePrice": 45 + }, + { + "id": "inv_low_3", + "tier": "low", + "basePrice": 230 + }, + { + "id": "inv_mid_1", + "tier": "mid", + "basePrice": 80 + }, + { + "id": "inv_mid_2", + "tier": "mid", + "basePrice": 150 + }, + { + "id": "inv_mid_3", + "tier": "mid", + "basePrice": 30 + }, + { + "id": "inv_high_1", + "tier": "high", + "basePrice": 60 + }, + { + "id": "inv_high_2", + "tier": "high", + "basePrice": 12 + }, + { + "id": "inv_high_3", + "tier": "high", + "basePrice": 400 + } + ] +} diff --git a/public/data/legacy.json b/public/data/legacy.json new file mode 100644 index 0000000..2167380 --- /dev/null +++ b/public/data/legacy.json @@ -0,0 +1,8 @@ +[ + {"id":"lg_prod","baseCost":1,"costGrowth":1.6,"maxLevel":15,"effect":{"type":"global_mult","value":1.3}}, + {"id":"lg_demand","baseCost":1,"costGrowth":1.6,"maxLevel":15,"effect":{"type":"demand_mult","value":1.3}}, + {"id":"lg_cost","baseCost":2,"costGrowth":1.7,"maxLevel":10,"effect":{"type":"generator_cost_mult","value":0.92}}, + {"id":"lg_start","baseCost":1,"costGrowth":1.5,"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_rp","baseCost":2,"costGrowth":1.6,"maxLevel":10,"effect":{"type":"rp_mult","value":1.25}} +] diff --git a/public/data/research.json b/public/data/research.json new file mode 100644 index 0000000..7322238 --- /dev/null +++ b/public/data/research.json @@ -0,0 +1,17 @@ +[ + {"id":"r_prod_1","branch":"prod","cost":20,"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_3","branch":"prod","cost":600,"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_2","branch":"mkt","cost":150,"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_log_1","branch":"log","cost":30,"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_3","branch":"log","cost":500,"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_2","branch":"fin","cost":160,"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_ins_1","branch":"ins","cost":50,"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_3","branch":"ins","cost":800,"requires":["r_ins_2","r_mkt_2"],"effect":{"type":"price_advisor"}} +] diff --git a/public/data/rules.json b/public/data/rules.json index 4ed49c0..fcc0a80 100644 --- a/public/data/rules.json +++ b/public/data/rules.json @@ -1,7 +1,16 @@ { - "start": { "units": 0, "funds": 0, "material": 100, "price": 0.25 }, - "click": { "yield": 1 }, - "unit": { "materialPerUnit": 1 }, + "start": { + "units": 0, + "funds": 0, + "material": 100, + "price": 0.25 + }, + "click": { + "yield": 1 + }, + "unit": { + "materialPerUnit": 1 + }, "market": { "referencePrice": 0.25, "baseDemand": 2.5, @@ -25,7 +34,117 @@ "updateEverySeconds": 10, "autoBuyBelowFactor": 1.2 }, - "marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 }, - "offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 }, - "autosaveSeconds": 10 + "marketing": { + "baseCost": 40, + "costGrowth": 1.45, + "demandPerLevel": 1.4 + }, + "offline": { + "maxSeconds": 28800, + "efficiency": 0.5, + "minSeconds": 10 + }, + "autosaveSeconds": 10, + "gamble": { + "baseChipCost": 200000, + "chipCostGrowth": 1.3, + "maxChips": 1000000, + "chipBonusScale": 0.15, + "baseWin": 0.4, + "jackpotChance": 0.02, + "jackpotMultiplier": 4, + "crashChance": 0.03, + "crashMultiplier": 2, + "maxWin": 0.9, + "cooldownSeconds": 1.5, + "reliability": { + "baseCost": 500000, + "costGrowth": 8, + "perLevel": 0.04, + "maxLevel": 6 + } + }, + "research": { + "labBaseCost": 2000000, + "labCostGrowth": 3, + "labMax": 30, + "rpPerLabPerSecond": 0.01 + }, + "legacy": { + "threshold": 1000000000, + "passivePerPoint": 0.05 + }, + "contracts": { + "offerEverySeconds": 60, + "maxOffers": 3, + "templates": [ + { + "id": "sprint", + "seconds": 60, + "targetFactor": 1.1, + "rewardFactor": 1 + }, + { + "id": "steady", + "seconds": 180, + "targetFactor": 1.3, + "rewardFactor": 2 + }, + { + "id": "marathon", + "seconds": 420, + "targetFactor": 1.6, + "rewardFactor": 4.5 + } + ], + "rewards": [ + "funds", + "research", + "chips", + "boost" + ], + "boostMultiplier": 1.5, + "researchPerFactor": 3, + "chipsPerFactor": 1 + }, + "events": { + "minSeconds": 240, + "maxSeconds": 480, + "claimSeconds": 25, + "windfallIncomeSeconds": 120, + "types": [ + { + "id": "surge", + "weight": 3, + "buff": { + "type": "demand_mult", + "value": 2, + "seconds": 60 + } + }, + { + "id": "rush", + "weight": 3, + "buff": { + "type": "global_mult", + "value": 2, + "seconds": 45 + } + }, + { + "id": "windfall", + "weight": 2 + }, + { + "id": "crash", + "weight": 1, + "auto": true, + "buff": { + "type": "material_price_mult", + "value": 3, + "seconds": 60 + } + } + ] + } } diff --git a/public/data/tabs.json b/public/data/tabs.json new file mode 100644 index 0000000..12cb61d --- /dev/null +++ b/public/data/tabs.json @@ -0,0 +1,9 @@ +[ + {"id":"main"}, + {"id":"contracts","unlock":{"lifetimeProduced":20000}}, + {"id":"invest","unlock":{"lifetimeProduced":100000}}, + {"id":"research","unlock":{"lifetimeProduced":500000}}, + {"id":"gamble","unlock":{"lifetimeProduced":5000000}}, + {"id":"legacy","unlock":{"lifetimeProduced":500000000}}, + {"id":"achievements"} +] diff --git a/public/index.html b/public/index.html index 37acb5f..f98b835 100644 --- a/public/index.html +++ b/public/index.html @@ -14,48 +14,49 @@

Idle Clicker

-
- - - -
+ + +
+ +
+
+ +
0
+
+
+ +
0
+
+
+ +
0
+
+
+ -
+
+

Overview

-
-
- -
- - 0 -
-
-
- -
- - 0 -
-
-
- -
- - 0 -
-
-
0
0
@@ -120,6 +121,15 @@

    +
    + + + + + + + +
    diff --git a/public/js/engine/achievements.js b/public/js/engine/achievements.js new file mode 100644 index 0000000..094cf67 --- /dev/null +++ b/public/js/engine/achievements.js @@ -0,0 +1,35 @@ +// Achievements: permanent small bonuses for milestones. Each one reads a +// single statistic, so adding one is a line in data/achievements.json (plus +// its title in the core locale files). + +export function statValue(state, stat) { + switch (stat) { + case 'lifetimeProduced': + return state.lifetimeProduced; + case 'generatorsOwned': + return Object.values(state.generators).reduce((sum, n) => sum + n, 0); + case 'upgradesOwned': + return state.upgrades.length; + case 'researchOwned': + return state.research.length; + case 'prestiges': + return state.legacy.prestiges; + case 'chips': + return state.chips; + default: + return state.stats[stat] ?? 0; // investProfit, gambleWins, contractsDone, eventsClaimed... + } +} + +/** Grant every achievement whose condition is now met. Returns the new ids. */ +export function checkAchievements(state, ctx) { + const granted = []; + for (const a of ctx.achievements) { + if (state.achievements.includes(a.id)) continue; + if (statValue(state, a.condition.stat) >= a.condition.min) { + state.achievements.push(a.id); + granted.push(a.id); + } + } + return granted; +} diff --git a/public/js/engine/context.js b/public/js/engine/context.js index 44a6681..9e32bd3 100644 --- a/public/js/engine/context.js +++ b/public/js/engine/context.js @@ -1,27 +1,75 @@ import { EFFECT_TYPES } from './effects.js'; -/** - * Bundle the static game data (data/*.json) with lookup tables and validate it, - * so a typo in a JSON file fails loudly at startup instead of mid-game. - */ -export function buildContext({ rules, generators, upgrades }) { - const generatorsById = new Map(generators.map((g) => [g.id, g])); - const upgradesById = new Map(upgrades.map((u) => [u.id, u])); +function byId(list, what) { + const map = new Map(list.map((item) => [item.id, item])); + if (map.size !== list.length) throw new Error(`Duplicate ${what} id`); + return map; +} - if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id'); - if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id'); +function checkEffect(owner, effect, generatorsById) { + if (!EFFECT_TYPES.includes(effect?.type)) { + throw new Error(`${owner}: unknown effect type "${effect?.type}"`); + } + if (effect.target && !generatorsById.has(effect.target)) { + throw new Error(`${owner}: unknown generator "${effect.target}"`); + } +} + +/** + * Bundle the static game data (public/data/*.json) with lookup tables and + * validate it, so a typo in a JSON file fails loudly at startup (and in the + * tests) instead of mid-game. + */ +export function buildContext({ + rules, + generators, + upgrades, + research, + legacy, + achievements, + investments, + tabs, +}) { + const generatorsById = byId(generators, 'generator'); + const upgradesById = byId(upgrades, 'upgrade'); + const researchById = byId(research, 'research'); + const perksById = byId(legacy, 'legacy perk'); + const achievementsById = byId(achievements, 'achievement'); + const societiesById = byId(investments.societies, 'society'); + byId(tabs, 'tab'); for (const u of upgrades) { - if (!EFFECT_TYPES.includes(u.effect?.type)) { - throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`); - } - if (u.effect.target && !generatorsById.has(u.effect.target)) { - throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`); - } + checkEffect(`Upgrade "${u.id}"`, u.effect, generatorsById); if (u.requires && !upgradesById.has(u.requires)) { throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`); } } + for (const node of research) { + checkEffect(`Research "${node.id}"`, node.effect, generatorsById); + for (const req of node.requires) { + if (!researchById.has(req)) throw new Error(`Research "${node.id}": unknown requirement "${req}"`); + } + } + 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) { + if (!investments.tiers[s.tier]) throw new Error(`Society "${s.id}": unknown tier "${s.tier}"`); + } - return { rules, generators, upgrades, generatorsById, upgradesById }; + return { + rules, + generators, + upgrades, + research, + legacy, + achievements, + investments, + tabs, + generatorsById, + upgradesById, + researchById, + perksById, + achievementsById, + societiesById, + }; } diff --git a/public/js/engine/contracts.js b/public/js/engine/contracts.js new file mode 100644 index 0000000..13979ac --- /dev/null +++ b/public/js/engine/contracts.js @@ -0,0 +1,144 @@ +// Contracts and random events: the "active play" layer. +// +// Contracts: up to `maxOffers` offers are on the board; accept one and sell +// `target` units before the time runs out (a bit more than your current sales +// pace, so it takes some effort) for a reward. Events: now and then an +// opportunity appears for a short window and must be claimed; rarer incidents +// (like a price crash for the raw material) just happen. + +export function salesEstimate(state, d) { + const rate = Math.max(Math.min(d.rate, d.demand), 1); + return { rate, income: rate * state.price * d.valueMultiplier }; +} + +/** A random element, safe even if rng() ever returns exactly 1. */ +function pick(list, rng) { + return list[Math.min(list.length - 1, Math.floor(rng() * list.length))]; +} + +// ------------------------------------------------------------------ contracts + +function makeOffer(state, ctx, d, mods, rng) { + const cfg = ctx.rules.contracts; + const template = pick(cfg.templates, rng); + const type = pick(cfg.rewards, rng); + const { rate, income } = salesEstimate(state, d); + const factor = template.rewardFactor * mods.contract; + + let amount; + if (type === 'funds') amount = income * template.seconds * factor; + else if (type === 'research') amount = Math.max(1, Math.round(cfg.researchPerFactor * factor)); + else if (type === 'chips') amount = Math.max(1, Math.round(cfg.chipsPerFactor * factor)); + else amount = 1 + (cfg.boostMultiplier - 1) * Math.min(2, template.rewardFactor); // boost: a multiplier + + return { + template: template.id, + seconds: template.seconds, + target: Math.ceil(rate * template.seconds * template.targetFactor), + reward: { type, amount }, + }; +} + +function grantReward(state, ctx, offer) { + const { type, amount } = offer.reward; + if (type === 'funds') state.funds += amount; + else if (type === 'research') state.rp += amount; + else if (type === 'chips') state.chips = Math.min(state.chips + amount, ctx.rules.gamble.maxChips); + else state.events.buffs.push({ type: 'global_mult', value: amount, remaining: offer.seconds }); +} + +/** Take offer number `index` from the board. Only one contract can be active. */ +export function acceptContract(state, ctx, index) { + const c = state.contracts; + if (c.active || !c.offers[index]) return false; + const [offer] = c.offers.splice(index, 1); + c.active = { offer, progress: 0, remaining: offer.seconds }; + return true; +} + +export function abandonContract(state) { + if (!state.contracts.active) return false; + state.contracts.active = null; + return true; +} + +export function stepContracts(state, ctx, dt, sold, d, mods, rng, offline, totals) { + if (offline) return; + const c = state.contracts; + const cfg = ctx.rules.contracts; + + if (c.active) { + c.active.progress += sold; + c.active.remaining -= dt; + if (c.active.progress >= c.active.offer.target) { + grantReward(state, ctx, c.active.offer); + totals.notices.push({ kind: 'contract_done', reward: c.active.offer.reward, seconds: c.active.offer.seconds }); + state.stats.contractsDone += 1; + c.active = null; + } else if (c.active.remaining <= 0) { + totals.notices.push({ kind: 'contract_failed' }); + c.active = null; + } + } + + state.contractTimer += dt; + if (state.contractTimer >= cfg.offerEverySeconds) { + state.contractTimer = 0; + c.offers.push(makeOffer(state, ctx, d, mods, rng)); + if (c.offers.length > cfg.maxOffers) c.offers.shift(); // the oldest offer expires + } +} + +// --------------------------------------------------------------------- events + +function pickEventType(cfg, rng) { + const total = cfg.types.reduce((sum, t) => sum + t.weight, 0); + let roll = rng() * total; + for (const type of cfg.types) { + roll -= type.weight; + if (roll < 0) return type; + } + return cfg.types[0]; +} + +export function stepEvents(state, ctx, dt, d, rng, offline, totals) { + const ev = state.events; + const cfg = ctx.rules.events; + + for (const buff of ev.buffs) buff.remaining -= dt; + ev.buffs = ev.buffs.filter((buff) => buff.remaining > 0); + if (offline) return; + + if (ev.current) { + ev.current.remaining -= dt; + if (ev.current.remaining <= 0) ev.current = null; + } + + state.eventTimer -= dt; + if (state.eventTimer > 0 || ev.current) return; + state.eventTimer = cfg.minSeconds + rng() * (cfg.maxSeconds - cfg.minSeconds); + + const type = pickEventType(cfg, rng); + if (type.auto) { + ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds }); + totals.notices.push({ kind: 'event_incident', type: type.id }); + } else { + ev.current = { type: type.id, remaining: cfg.claimSeconds }; + totals.notices.push({ kind: 'event_opportunity', type: type.id }); + } +} + +/** Claim the opportunity currently on offer. */ +export function claimEvent(state, ctx, d) { + const ev = state.events; + if (!ev.current) return false; + const cfg = ctx.rules.events; + const type = cfg.types.find((t) => t.id === ev.current.type); + + if (type.buff) ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds }); + else state.funds += salesEstimate(state, d).income * cfg.windfallIncomeSeconds; + + state.stats.eventsClaimed += 1; + ev.current = null; + return true; +} diff --git a/public/js/engine/effects.js b/public/js/engine/effects.js index 065ba18..f0561b7 100644 --- a/public/js/engine/effects.js +++ b/public/js/engine/effects.js @@ -1,5 +1,7 @@ -// Upgrade effects. To add a new effect type: register it here, add its -// `effect.` string to the core locale files, and use it in upgrades.json. +// Effects: the single vocabulary used by upgrades, research, legacy perks, +// achievements, chips and temporary buffs. To add a new effect type: register +// it here, add its `effect.` text to the core locale files, then use it +// in any data file. export function newModifiers() { return { @@ -12,29 +14,69 @@ export function newModifiers() { batchDiscount: 1, batchSize: 1, autoBuyMaterial: false, + dividend: 1, + reliability: 0, + offline: 0, + contract: 1, + rp: 1, + generatorCost: 1, + startFunds: 0, + priceAdvisor: false, + materialPrice: 1, + legacyGain: 1, + }; +} + +// `times` lets a repeatable effect (a perk level, N achievements) stack. +function mult(key) { + return (m, e, times) => { + m[key] *= e.value ** times; + }; +} + +function add(key) { + return (m, e, times) => { + m[key] += e.value * times; + }; +} + +function flag(key) { + return (m) => { + m[key] = true; }; } const APPLY = { - click_mult: (m, e) => void (m.click *= e.value), - generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value), - global_mult: (m, e) => void (m.global *= e.value), - demand_mult: (m, e) => void (m.demand *= e.value), - value_mult: (m, e) => void (m.value *= e.value), - material_efficiency: (m, e) => void (m.materialEfficiency *= e.value), - batch_discount: (m, e) => void (m.batchDiscount *= e.value), - batch_size_mult: (m, e) => void (m.batchSize *= e.value), - auto_buy_material: (m) => void (m.autoBuyMaterial = true), + click_mult: mult('click'), + generator_mult: (m, e, times) => { + m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value ** times; + }, + global_mult: mult('global'), + demand_mult: mult('demand'), + value_mult: mult('value'), + material_efficiency: mult('materialEfficiency'), + batch_discount: mult('batchDiscount'), + batch_size_mult: mult('batchSize'), + auto_buy_material: flag('autoBuyMaterial'), + dividend_mult: mult('dividend'), + reliability_add: add('reliability'), + offline_add: add('offline'), + contract_mult: mult('contract'), + rp_mult: mult('rp'), + generator_cost_mult: mult('generatorCost'), + start_funds: add('startFunds'), + price_advisor: flag('priceAdvisor'), + material_price_mult: mult('materialPrice'), + legacy_gain_mult: mult('legacyGain'), }; export const EFFECT_TYPES = Object.keys(APPLY); -/** Fold every owned upgrade into a single set of multipliers. */ -export function computeModifiers(ownedIds, upgradesById) { +/** Fold a list of `{ effect, times }` entries into one set of multipliers. */ +export function foldEffects(entries) { const m = newModifiers(); - for (const id of ownedIds) { - const effect = upgradesById.get(id)?.effect; - if (effect) APPLY[effect.type]?.(m, effect); + for (const { effect, times = 1 } of entries) { + if (effect && times > 0) APPLY[effect.type]?.(m, effect, times); } return m; } diff --git a/public/js/engine/engine.js b/public/js/engine/engine.js index f129d7e..dc0983f 100644 --- a/public/js/engine/engine.js +++ b/public/js/engine/engine.js @@ -1,9 +1,23 @@ // Game rules. Every function takes the mutable `state` and the static `ctx` // (see context.js) and never touches the DOM, so it runs the same in the // browser and in Node (tests, balance simulations). +// +// This file holds the production/market loop and ties the other systems +// (investments, research, contracts, events, achievements...) into time. -import { computeModifiers } from './effects.js'; -import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.js'; +import { checkAchievements } from './achievements.js'; +import { stepContracts, stepEvents } from './contracts.js'; +import { + balancedPrice, + bulkCost, + clamp, + demandPerSecond, + materialBatch, + maxAffordable, +} from './formulas.js'; +import { stepInvestments } from './investments.js'; +import { modifiersFor } from './modifiers.js'; +import { rpPerSecond, stepResearch } from './research.js'; const MAX_STEP_SECONDS = 1; @@ -11,6 +25,7 @@ const MAX_STEP_SECONDS = 1; export function isUnlocked(state, condition = {}) { if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false; + if (condition.lifetimeProduced != null && state.lifetimeProduced < condition.lifetimeProduced) return false; if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) { return false; } @@ -26,9 +41,8 @@ export function upgradeVisible(state, upgrade) { // ------------------------------------------------------------------- derived /** Everything computed from the state (rates, multipliers, current prices). */ -export function derive(state, ctx) { +export function derive(state, ctx, mods = modifiersFor(state, ctx)) { const { rules } = ctx; - const mods = computeModifiers(state.upgrades, ctx.upgradesById); const perGenerator = {}; let rate = 0; @@ -43,10 +57,11 @@ export function derive(state, ctx) { state.totalProduced, rate, state.materialFactor, - mods.batchDiscount, + mods.batchDiscount * mods.materialPrice, mods.batchSize, ); return { + mods, clickYield: rules.click.yield * mods.click, perGenerator, rate, @@ -58,6 +73,15 @@ export function derive(state, ctx) { batchCost: batch.cost, autoBuyMaterial: mods.autoBuyMaterial, marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing), + rpRate: rpPerSecond(state, ctx, mods), + // Only researched: the price where demand matches output (see formulas.balancedPrice). + advisedPrice: mods.priceAdvisor + ? clamp( + balancedPrice(rules.market, Math.max(rate, 1e-9), demandMultiplier), + rules.market.minPrice, + rules.market.maxPrice, + ) + : null, }; } @@ -71,6 +95,7 @@ export function click(state, ctx) { state.material -= made * d.materialPerUnit; state.units += made; state.totalProduced += made; + state.lifetimeProduced += made; return true; } @@ -90,12 +115,13 @@ export function buyMarketing(state, ctx) { return true; } -/** How many generators the player gets for `amount` ("max" or a number). */ -export function generatorQuote(state, ctx, id, amount) { +/** How many generators the player gets for `amount` ("max" or a number), and what it costs. */ +export function generatorQuote(state, ctx, id, amount, mods = modifiersFor(state, ctx)) { const g = ctx.generatorsById.get(id); const owned = state.generators[id] ?? 0; - const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount; - return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) }; + const base = g.baseCost * mods.generatorCost; + const count = amount === 'max' ? maxAffordable(base, g.costGrowth, owned, state.funds) : amount; + return { count, cost: bulkCost(base, g.costGrowth, owned, count) }; } export function buyGenerator(state, ctx, id, amount = 1) { @@ -138,7 +164,7 @@ function stepMaterialMarket(state, ctx, dt, rng) { } } -function step(state, ctx, dt, rng, totals) { +function step(state, ctx, dt, rng, totals, offline) { stepMaterialMarket(state, ctx, dt, rng); const d = derive(state, ctx); @@ -147,6 +173,7 @@ function step(state, ctx, dt, rng, totals) { state.material -= made * d.materialPerUnit; state.units += made; 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) { @@ -165,28 +192,42 @@ function step(state, ctx, dt, rng, totals) { totals.produced += made; totals.sold += sold; totals.revenue += revenue; + + // The other systems. + stepInvestments(state, ctx, dt, rng, d.mods); + stepResearch(state, ctx, dt, d.mods); + stepContracts(state, ctx, dt, sold, d, d.mods, rng, offline, totals); + stepEvents(state, ctx, dt, d, rng, offline, totals); + state.gambleCooldown = Math.max(0, state.gambleCooldown - dt); + for (const id of checkAchievements(state, ctx)) totals.notices.push({ kind: 'achievement', id }); } -/** Advance the simulation by `seconds`. Returns what happened during that time. */ -export function advance(state, ctx, seconds, rng = Math.random) { - const totals = { produced: 0, sold: 0, revenue: 0 }; +/** + * Advance the simulation by `seconds`. Returns what happened during that time, + * including `notices` (things worth telling the player). `offline` skips the + * parts that need the player present (contracts, events). + */ +export function advance(state, ctx, seconds, rng = Math.random, { offline = false } = {}) { + const totals = { produced: 0, sold: 0, revenue: 0, notices: [] }; let left = Math.max(0, seconds); while (left > 1e-9) { const dt = Math.min(left, MAX_STEP_SECONDS); - step(state, ctx, dt, rng, totals); + step(state, ctx, dt, rng, totals, offline); left -= dt; } return totals; } /** - * Catch up on the time the game was closed. Deterministic (no price noise) - * and discounted by `offline.efficiency`. Returns null if too short to matter. + * Catch up on the time the game was closed. Deterministic (no price noise), + * discounted by the offline efficiency (research and legacy raise it). + * Returns null if too short to matter. */ export function simulateOffline(state, ctx, elapsedSeconds) { const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline; if (elapsedSeconds < minSeconds) return null; - const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency; - const totals = advance(state, ctx, seconds, () => 0.5); + const effective = Math.min(1, efficiency + modifiersFor(state, ctx).offline); + const seconds = Math.min(elapsedSeconds, maxSeconds) * effective; + const totals = advance(state, ctx, seconds, () => 0.5, { offline: true }); return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) }; } diff --git a/public/js/engine/formulas.js b/public/js/engine/formulas.js index 4fe513b..2971e7e 100644 --- a/public/js/engine/formulas.js +++ b/public/js/engine/formulas.js @@ -4,6 +4,14 @@ export function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } +/** + * Demand bonus from owning chips: diminishing returns (logarithmic), so a big + * lucky streak can never run away with the game. + */ +export function chipDemandBonus(chips, gamble) { + return 1 + gamble.chipBonusScale * Math.log2(1 + Math.max(0, chips)); +} + /** Price of the next item when `owned` items are already owned. */ export function unitCost(base, growth, owned) { return base * growth ** owned; diff --git a/public/js/engine/gamble.js b/public/js/engine/gamble.js new file mode 100644 index 0000000..dd7deae --- /dev/null +++ b/public/js/engine/gamble.js @@ -0,0 +1,77 @@ +// The gamble (a "quantum computer" in the first theme): chips are a second +// currency, hard to buy (the price rises with every chip owned) and hard to +// grow. Stake chips on a cycle: win and the stake doubles, hit a jackpot and it +// is multiplied, lose it, or crash and lose more than you bet. Reliability +// upgrades (paid in funds) tilt the odds. Chips give a diminishing demand bonus. + +import { clamp } from './formulas.js'; + +const MAX_EXPONENT = 200; + +export function chipCost(state, ctx) { + const g = ctx.rules.gamble; + return Math.ceil(g.baseChipCost * g.chipCostGrowth ** Math.min(state.chips, MAX_EXPONENT)); +} + +export function reliabilityCost(state, ctx) { + const r = ctx.rules.gamble.reliability; + return Math.ceil(r.baseCost * r.costGrowth ** state.reliabilityLevel); +} + +/** Probabilities of each outcome of one cycle. They always sum to 1. */ +export function odds(state, ctx, mods) { + const g = ctx.rules.gamble; + const crash = g.crashChance; + const jackpot = g.jackpotChance; + const reliability = g.baseWin + state.reliabilityLevel * g.reliability.perLevel + mods.reliability; + const win = clamp(reliability, 0.05, g.maxWin - crash - jackpot); + return { crash, jackpot, win, lose: 1 - crash - jackpot - win }; +} + +export function buyChip(state, ctx) { + const cost = chipCost(state, ctx); + if (state.funds < cost || state.chips >= ctx.rules.gamble.maxChips) return false; + state.funds -= cost; + state.chips += 1; + return true; +} + +export function buyReliability(state, ctx) { + if (state.reliabilityLevel >= ctx.rules.gamble.reliability.maxLevel) return false; + const cost = reliabilityCost(state, ctx); + if (state.funds < cost) return false; + state.funds -= cost; + state.reliabilityLevel += 1; + return true; +} + +/** + * Stake `stake` chips on one cycle. Returns { outcome, stake, delta } or null + * when it cannot be played (no chips, nothing staked, still cooling down). + */ +export function gamble(state, ctx, stake, mods, rng = Math.random) { + const g = ctx.rules.gamble; + const bet = Math.min(Math.floor(stake), state.chips); + if (!(bet >= 1) || state.gambleCooldown > 0) return null; + + const p = odds(state, ctx, mods); + const roll = rng(); + let outcome = 'lose'; + let delta = -bet; + if (roll < p.crash) { + outcome = 'crash'; + delta = -Math.min(state.chips, Math.ceil(bet * g.crashMultiplier)); + } else if (roll < p.crash + p.jackpot) { + outcome = 'jackpot'; + delta = bet * g.jackpotMultiplier; + } else if (roll < p.crash + p.jackpot + p.win) { + outcome = 'win'; + delta = bet; + } + + state.chips = clamp(state.chips + delta, 0, g.maxChips); + state.gambleCooldown = g.cooldownSeconds; + if (delta > 0) state.stats.gambleWins += 1; + else state.stats.gambleLosses += 1; + return { outcome, stake: bet, delta }; +} diff --git a/public/js/engine/investments.js b/public/js/engine/investments.js new file mode 100644 index 0000000..2030ca9 --- /dev/null +++ b/public/js/engine/investments.js @@ -0,0 +1,75 @@ +// Investments: nine societies in three risk tiers. Every `intervalSeconds` each +// price moves by a random percentage within the tier's swing (low ±5 %, mid +// ±15 %, high ±50 %), then drifts back towards its fair value (more weakly for the wilder tiers), and +// holdings pay a small dividend. Buying and selling carries a small fee. + +import { clamp } from './formulas.js'; + +export function holdingValue(state, id) { + return state.invest.holdings[id] * state.invest.prices[id]; +} + +export function portfolio(state, ctx) { + let value = 0; + let basis = 0; + for (const s of ctx.investments.societies) { + value += holdingValue(state, s.id); + basis += state.invest.basis[s.id]; + } + return { value, basis, profit: value - basis }; +} + +function repriceAll(state, ctx, rng, dividendMultiplier) { + const cfg = ctx.investments; + const inv = state.invest; + for (const s of cfg.societies) { + const tier = cfg.tiers[s.tier]; + const before = inv.prices[s.id]; + let price = before * (1 + (rng() * 2 - 1) * tier.swing); + price += (s.basePrice - price) * tier.reversion; + price = clamp(price, s.basePrice * cfg.minRatio, s.basePrice * cfg.maxRatio); + inv.prices[s.id] = price; + inv.change[s.id] = price / before - 1; + state.funds += inv.holdings[s.id] * price * tier.dividend * dividendMultiplier; + } +} + +export function stepInvestments(state, ctx, dt, rng, mods) { + state.investTimer += dt; + while (state.investTimer >= ctx.investments.intervalSeconds) { + state.investTimer -= ctx.investments.intervalSeconds; + repriceAll(state, ctx, rng, mods.dividend); + } +} + +/** Spend `fraction` of the current funds on society `id`. */ +export function buyShares(state, ctx, id, fraction) { + if (!ctx.societiesById.has(id)) return false; + const spend = state.funds * clamp(fraction, 0, 1); + if (!(spend >= 0.01)) return false; + state.funds -= spend; + state.invest.holdings[id] += (spend * (1 - ctx.investments.fee)) / state.invest.prices[id]; + state.invest.basis[id] += spend; + return true; +} + +/** Sell `fraction` of what is held in society `id`. */ +export function sellShares(state, ctx, id, fraction) { + if (!ctx.societiesById.has(id)) return false; + const inv = state.invest; + const share = clamp(fraction, 0, 1); + if (!(inv.holdings[id] > 0) || share <= 0) return false; + + const proceeds = inv.holdings[id] * share * inv.prices[id] * (1 - ctx.investments.fee); + const basisPart = inv.basis[id] * share; + state.funds += proceeds; + state.stats.investProfit += proceeds - basisPart; + if (share >= 1) { + inv.holdings[id] = 0; + inv.basis[id] = 0; + } else { + inv.holdings[id] -= inv.holdings[id] * share; + inv.basis[id] -= basisPart; + } + return true; +} diff --git a/public/js/engine/legacy.js b/public/js/engine/legacy.js new file mode 100644 index 0000000..63ab078 --- /dev/null +++ b/public/js/engine/legacy.js @@ -0,0 +1,52 @@ +// Legacy (prestige): restart the run for permanent points. Points are spent on +// repeatable perks, and every point ever earned also gives a small passive bonus. +// What survives a restart: see META_KEYS in state.js. + +import { modifiersFor } from './modifiers.js'; +import { createState, META_KEYS } from './state.js'; + +/** Points a restart would grant right now (0 until the run is big enough). */ +export function legacyGain(state, ctx, mods) { + const { threshold } = ctx.rules.legacy; + if (state.totalProduced < threshold) return 0; + return Math.floor(Math.sqrt(state.totalProduced / threshold) * mods.legacyGain); +} + +export function perkLevel(state, perk) { + return state.legacy.perks[perk.id] ?? 0; +} + +export function perkCost(perk, level) { + return Math.ceil(perk.baseCost * perk.costGrowth ** level); +} + +export function buyPerk(state, ctx, id) { + const perk = ctx.perksById.get(id); + if (!perk) return false; + const level = perkLevel(state, perk); + const cost = perkCost(perk, level); + if (level >= perk.maxLevel || state.legacy.points < cost) return false; + state.legacy.points -= cost; + state.legacy.perks[id] = level + 1; + return true; +} + +/** Restart the run in place. Returns the points gained, or 0 if not allowed. */ +export function prestige(state, ctx, now = Date.now()) { + const gain = legacyGain(state, ctx, modifiersFor(state, ctx)); + if (gain < 1) return 0; + + const fresh = createState(ctx, now); + const kept = Object.fromEntries(META_KEYS.map((key) => [key, state[key]])); + kept.legacy = { + ...state.legacy, + points: state.legacy.points + gain, + earned: state.legacy.earned + gain, + prestiges: state.legacy.prestiges + 1, + }; + Object.assign(state, fresh, kept); + + // Perks like "head start" shape the new run. + state.funds += modifiersFor(state, ctx).startFunds; + return gain; +} diff --git a/public/js/engine/modifiers.js b/public/js/engine/modifiers.js new file mode 100644 index 0000000..35ee9e3 --- /dev/null +++ b/public/js/engine/modifiers.js @@ -0,0 +1,36 @@ +// Gathers every source of bonuses for the current state and folds them. + +import { foldEffects } from './effects.js'; +import { chipDemandBonus } from './formulas.js'; + +/** All `{ effect, times }` entries that apply to `state`. */ +export function collectEffects(state, ctx) { + const entries = []; + + for (const id of state.upgrades) entries.push({ effect: ctx.upgradesById.get(id)?.effect }); + 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 }); + + for (const [id, level] of Object.entries(state.legacy.perks)) { + entries.push({ effect: ctx.perksById.get(id)?.effect, times: level }); + } + + // Every legacy point ever earned gives a small passive bonus. + const passive = 1 + ctx.rules.legacy.passivePerPoint; + entries.push({ effect: { type: 'global_mult', value: passive }, times: state.legacy.earned }); + entries.push({ effect: { type: 'demand_mult', value: passive }, times: state.legacy.earned }); + + // Chips make the brand more attractive, with diminishing returns. + entries.push({ + effect: { type: 'demand_mult', value: chipDemandBonus(state.chips, ctx.rules.gamble) }, + }); + + // Temporary buffs from events and contracts. + for (const buff of state.events.buffs) entries.push({ effect: { type: buff.type, value: buff.value } }); + + return entries; +} + +export function modifiersFor(state, ctx) { + return foldEffects(collectEffects(state, ctx)); +} diff --git a/public/js/engine/research.js b/public/js/engine/research.js new file mode 100644 index 0000000..1f9b2bf --- /dev/null +++ b/public/js/engine/research.js @@ -0,0 +1,38 @@ +// Research: labs (bought with funds) produce research points slowly over time; +// points buy nodes of a small tree. Research survives a legacy restart. + +export function labCost(state, ctx) { + const r = ctx.rules.research; + return Math.ceil(r.labBaseCost * r.labCostGrowth ** state.labs); +} + +export function rpPerSecond(state, ctx, mods) { + return state.labs * ctx.rules.research.rpPerLabPerSecond * mods.rp; +} + +export function buyLab(state, ctx) { + const { labMax } = ctx.rules.research; + const cost = labCost(state, ctx); + if (state.labs >= labMax || state.funds < cost) return false; + state.funds -= cost; + state.labs += 1; + return true; +} + +/** 'owned' | 'available' | 'locked' */ +export function researchStatus(state, node) { + if (state.research.includes(node.id)) return 'owned'; + return node.requires.every((id) => state.research.includes(id)) ? 'available' : 'locked'; +} + +export function buyResearch(state, ctx, id) { + const node = ctx.researchById.get(id); + if (!node || researchStatus(state, node) !== 'available' || state.rp < node.cost) return false; + state.rp -= node.cost; + state.research.push(id); + return true; +} + +export function stepResearch(state, ctx, dt, mods) { + state.rp += rpPerSecond(state, ctx, mods) * dt; +} diff --git a/public/js/engine/state.js b/public/js/engine/state.js index b885310..00197ed 100644 --- a/public/js/engine/state.js +++ b/public/js/engine/state.js @@ -1,28 +1,85 @@ import { clamp } from './formulas.js'; -export const STATE_VERSION = 1; +export const STATE_VERSION = 2; const MAX_NUMBER = 1e300; const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite const MAX_MARKETING = 300; +const MAX_LEVEL = 1000; + +const STAT_KEYS = ['gambleWins', 'gambleLosses', 'contractsDone', 'eventsClaimed', 'investProfit']; + +// Fields that only exist while the game runs and are never saved. +const TRANSIENT = [ + 'materialTimer', + 'materialTrend', + 'investTimer', + 'contractTimer', + 'eventTimer', + 'gambleCooldown', +]; + +// What a legacy reset keeps (the "meta" progression); everything else is the run. +export const META_KEYS = [ + 'lifetimeProduced', + 'stats', + 'research', + 'labs', + 'rp', + 'legacy', + 'achievements', +]; + +function newInvest(ctx) { + const ids = ctx.investments.societies.map((s) => s.id); + return { + prices: Object.fromEntries(ctx.investments.societies.map((s) => [s.id, s.basePrice])), + holdings: Object.fromEntries(ids.map((id) => [id, 0])), + basis: Object.fromEntries(ids.map((id) => [id, 0])), + change: Object.fromEntries(ids.map((id) => [id, 0])), + }; +} export function createState(ctx, now = Date.now()) { - const { start, market } = ctx.rules; + const { start, market, events, contracts } = ctx.rules; return { v: STATE_VERSION, + + // --- the run (reset by a legacy restart) units: start.units, funds: start.funds, material: start.material, totalProduced: 0, totalSold: 0, - price: start.price ?? market.basePrice, + price: start.price ?? market.referencePrice, marketing: 0, materialFactor: 1, generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])), upgrades: [], + invest: newInvest(ctx), + chips: 0, + reliabilityLevel: 0, + contracts: { offers: [], active: null }, + events: { current: null, buffs: [] }, + + // --- meta progression (survives a legacy restart) + lifetimeProduced: 0, + stats: Object.fromEntries(STAT_KEYS.map((k) => [k, 0])), + research: [], + labs: 0, + rp: 0, + legacy: { points: 0, earned: 0, prestiges: 0, perks: {} }, + achievements: [], + savedAt: now, - materialTimer: 0, // transient, never saved - materialTrend: 0, // transient: -1 falling, 0 steady, 1 rising + + // --- transient + materialTimer: 0, + materialTrend: 0, // -1 falling, 0 steady, 1 rising + investTimer: 0, + contractTimer: contracts.offerEverySeconds, + eventTimer: events.minSeconds, + gambleCooldown: 0, }; } @@ -34,37 +91,128 @@ function int(value, max) { return Math.floor(num(value, 0, 0, max)); } +function knownIds(list, known) { + return [...new Set(Array.isArray(list) ? list : [])].filter((id) => known.has(id)); +} + +function sanitizeInvest(raw, ctx, base) { + const inv = ctx.investments; + const out = newInvest(ctx); + for (const s of inv.societies) { + out.prices[s.id] = num(raw?.prices?.[s.id], base.prices[s.id], s.basePrice * inv.minRatio, s.basePrice * inv.maxRatio); + out.holdings[s.id] = num(raw?.holdings?.[s.id], 0); + out.basis[s.id] = out.holdings[s.id] > 0 ? num(raw?.basis?.[s.id], 0) : 0; + out.change[s.id] = num(raw?.change?.[s.id], 0, -1, 100); + } + return out; +} + +function sanitizeContracts(raw, ctx) { + const templates = new Set(ctx.rules.contracts.templates.map((t) => t.id)); + const rewardTypes = new Set(ctx.rules.contracts.rewards); + const offer = (o) => { + if (!o || !templates.has(o.template) || !rewardTypes.has(o.reward?.type)) return null; + return { + template: o.template, + seconds: num(o.seconds, 60, 1, 3600), + target: num(o.target, 1, 1), + reward: { type: o.reward.type, amount: num(o.reward.amount, 0) }, + }; + }; + const offers = (Array.isArray(raw?.offers) ? raw.offers : []).map(offer).filter(Boolean); + const active = offer(raw?.active?.offer); + return { + offers: offers.slice(0, ctx.rules.contracts.maxOffers), + active: active + ? { + offer: active, + progress: num(raw.active.progress, 0, 0, active.target), + remaining: num(raw.active.remaining, active.seconds, 0, active.seconds), + } + : null, + }; +} + +function sanitizeEvents(raw, ctx) { + const types = new Map(ctx.rules.events.types.map((t) => [t.id, t])); + const current = raw?.current; + const buffTypes = new Set(['demand_mult', 'global_mult', 'material_price_mult']); + return { + current: + current && types.has(current.type) && !types.get(current.type).auto + ? { type: current.type, remaining: num(current.remaining, 0, 0, ctx.rules.events.claimSeconds) } + : null, + buffs: (Array.isArray(raw?.buffs) ? raw.buffs : []) + .filter((b) => b && buffTypes.has(b.type) && Number.isFinite(b.value) && Number.isFinite(b.remaining)) + .slice(0, 20) + .map((b) => ({ + type: b.type, + value: clamp(b.value, 0.01, 100), + remaining: clamp(b.remaining, 0, 3600), + })), + }; +} + /** * Turn untrusted data (cookie, imported file) into a valid state. * 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. */ export function sanitizeState(raw, ctx, now = Date.now()) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; - const { market, materialMarket } = ctx.rules; + const { market, materialMarket, gamble } = ctx.rules; const base = createState(ctx, now); - const owned = Array.isArray(raw.upgrades) ? raw.upgrades : []; + const generators = Object.fromEntries( + ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]), + ); + const totalProduced = num(raw.totalProduced, 0); + + const perks = {}; + for (const perk of ctx.legacy) { + const level = int(raw.legacy?.perks?.[perk.id], perk.maxLevel); + if (level > 0) perks[perk.id] = level; + } + return { ...base, units: num(raw.units, base.units), funds: num(raw.funds, base.funds), material: num(raw.material, base.material), - totalProduced: num(raw.totalProduced, 0), + totalProduced, totalSold: num(raw.totalSold, 0), price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100, marketing: int(raw.marketing, MAX_MARKETING), materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor), - generators: Object.fromEntries( - ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]), - ), - upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)), + generators, + upgrades: knownIds(raw.upgrades, ctx.upgradesById), + invest: sanitizeInvest(raw.invest, ctx, base.invest), + chips: int(raw.chips, gamble.maxChips), + reliabilityLevel: int(raw.reliabilityLevel, gamble.reliability.maxLevel), + contracts: sanitizeContracts(raw.contracts, ctx), + events: sanitizeEvents(raw.events, ctx), + + // Saves from before lifetime tracking: the current run is all we know. + lifetimeProduced: Math.max(num(raw.lifetimeProduced, 0), totalProduced), + stats: Object.fromEntries(STAT_KEYS.map((k) => [k, num(raw.stats?.[k], 0)])), + research: knownIds(raw.research, ctx.researchById), + labs: int(raw.labs, ctx.rules.research.labMax), + rp: num(raw.rp, 0), + legacy: { + points: int(raw.legacy?.points, MAX_LEVEL * 100), + earned: int(raw.legacy?.earned, MAX_LEVEL * 100), + prestiges: int(raw.legacy?.prestiges, MAX_LEVEL), + perks, + }, + achievements: knownIds(raw.achievements, ctx.achievementsById), savedAt: num(raw.savedAt, now, 0, now), }; } /** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */ export function serializeState(state) { - const { materialTimer, materialTrend, ...persisted } = state; + const persisted = { ...state }; + for (const key of TRANSIENT) delete persisted[key]; return JSON.stringify(persisted, (_key, value) => typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value, ); diff --git a/public/js/main.js b/public/js/main.js index 2ea1ca7..510e5aa 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,5 +1,6 @@ // Entry point: wires the engine, the services (cookies, i18n, themes) and the UI. +import { abandonContract, acceptContract, claimEvent } from './engine/contracts.js'; import { advance, buyGenerator, @@ -11,6 +12,10 @@ import { setPrice, simulateOffline, } from './engine/engine.js'; +import { buyChip, buyReliability, gamble } from './engine/gamble.js'; +import { buyShares, sellShares } from './engine/investments.js'; +import { buyPerk, prestige } from './engine/legacy.js'; +import { buyLab, buyResearch } from './engine/research.js'; import { createState, sanitizeState, serializeState } from './engine/state.js'; import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js'; import { createFormatter } from './services/format.js'; @@ -26,8 +31,16 @@ import { takeLegacySaves, } from './services/storage.js'; 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 { createGambleView } from './ui/gambleView.js'; +import { createInvestView } from './ui/investView.js'; +import { createLegacyView } from './ui/legacyView.js'; +import { createResearchView } from './ui/researchView.js'; import { createSettings } from './ui/settings.js'; +import { createTabs } from './ui/tabs.js'; +import { formatDuration, moneyFormatter, rewardText } from './ui/text.js'; import { showToast } from './ui/toast.js'; import { createView } from './ui/view.js'; @@ -42,6 +55,7 @@ let themeId; let locale; let state; let fmt; +let tabs; let income = 0; let lastSaveError = 0; const themeNames = new Map(); @@ -115,7 +129,7 @@ async function fillSelects() { ); } -/** Reload texts, assets and the lists for the current theme + language. */ +/** Reload texts, assets and every screen for the current theme + language. */ async function refreshTexts() { await loadDictionary(locale, themeId); fmt = createFormatter(locale); @@ -123,6 +137,9 @@ async function refreshTexts() { applyTranslations(); document.title = t('theme.title'); view.rebuild(ctx, fmt); + for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt); + tabs.rebuild(); + refreshStaticIcons(); await fillSelects(); } @@ -137,11 +154,26 @@ async function setTheme(id) { // ------------------------------------------------------------------ the loop -function formatDuration(seconds) { - const minutes = Math.floor(seconds / 60); - if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) }); - if (minutes < 60) return t('time.minutes', { value: minutes }); - return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 }); +/** Tell the player about what just happened (see `notices` in engine.advance). */ +function announce(notice) { + switch (notice.kind) { + case 'contract_done': + showToast(t('toast.contractDone', { reward: rewardText(notice.reward, fmt, notice.seconds) })); + break; + case 'contract_failed': + showToast(t('toast.contractFailed'), { error: true }); + break; + case 'event_opportunity': + showToast(t('toast.eventOpportunity', { title: t(`event.${notice.type}.title`) })); + break; + case 'event_incident': + showToast(t('toast.eventIncident', { title: t(`event.${notice.type}.title`) }), { error: true }); + break; + case 'achievement': + showToast(t('toast.achievement', { title: t(`ach.title.${notice.id}`) })); + break; + default: + } } /** Apply progress made while the game was closed or the device asleep. */ @@ -152,7 +184,7 @@ function catchUp(elapsedSeconds) { t('toast.welcomeBack', { duration: formatDuration(report.seconds), produced: fmt.number(Math.floor(report.produced)), - funds: t('theme.fmt.funds', { value: fmt.money(report.revenue) }), + funds: moneyFormatter(fmt)(report.revenue), }), ); } @@ -168,14 +200,19 @@ function frame() { if (dt >= ctx.rules.offline.minSeconds) { catchUp(dt); // tab was frozen or the device slept } else { - const { revenue } = advance(state, ctx, dt); - income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS); + const totals = advance(state, ctx, dt); + income += (totals.revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS); + totals.notices.forEach(announce); } render(); } +/** The resource bar and the unlocked tabs always; the open section only. */ function render() { - view.update(state, derive(state, ctx), { income }); + const d = derive(state, ctx); + view.update(state, d, { income }); + tabs.update(state); + if (tabs.active !== 'main') screens[tabs.active]?.update(state, d); } // ------------------------------------------------------------------- actions @@ -195,6 +232,41 @@ const view = createView({ nudgePrice: (delta) => act(setPrice)(state.price + delta), }); +function restart() { + if (!confirm(t('legacy.confirm'))) return; + const gain = prestige(state, ctx); + if (!gain) return; + income = 0; + persist(); + tabs.select('main'); + showToast(t('toast.restarted', { gain: fmt.number(gain) })); +} + +/** One screen per section; each is built and refreshed by its own module. */ +const screens = { + contracts: createContractsView($('#view-contracts'), { + accept: act(acceptContract), + abandon: act(abandonContract), + claimEvent: () => { + claimEvent(state, ctx, derive(state, ctx)); + render(); + }, + }), + invest: createInvestView($('#view-invest'), { buy: act(buyShares), sell: act(sellShares) }), + research: createResearchView($('#view-research'), { buyLab: act(buyLab), buyNode: act(buyResearch) }), + gamble: createGambleView($('#view-gamble'), { + buyChip: act(buyChip), + buyReliability: act(buyReliability), + run(stake) { + const result = gamble(state, ctx, stake, derive(state, ctx).mods); + render(); + return result; + }, + }), + legacy: createLegacyView($('#view-legacy'), { buyPerk: act(buyPerk), restart }), + achievements: createAchievementsView($('#view-achievements')), +}; + createSettings({ getExport: () => ({ text: buildExport(state), @@ -211,6 +283,7 @@ createSettings({ state = createState(ctx); income = 0; persist(); + tabs.select('main'); render(); }, }); @@ -226,6 +299,8 @@ async function boot() { [ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]); if (themes.length === 0) throw new Error('No theme installed in public/themes/'); + tabs = createTabs(ctx.tabs, () => state && render()); + const prefs = loadPrefs(); locale = pickLocale(prefs.lang); const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0]; @@ -240,7 +315,6 @@ async function boot() { }); await setTheme(initialTheme.id); - refreshStaticIcons(); state = loadSavedState() ?? createState(ctx); catchUp((Date.now() - state.savedAt) / 1000); diff --git a/public/js/services/data.js b/public/js/services/data.js index 30968e6..bac5e60 100644 --- a/public/js/services/data.js +++ b/public/js/services/data.js @@ -8,13 +8,23 @@ async function getJson(url) { return response.json(); } +// Every data file the game needs: context key -> file name in public/data/. +export const DATA_FILES = { + rules: 'rules', + generators: 'generators', + upgrades: 'upgrades', + research: 'research', + legacy: 'legacy', + achievements: 'achievements', + investments: 'investments', + tabs: 'tabs', +}; + export async function loadGameContext() { - const [rules, generators, upgrades] = await Promise.all([ - getJson('data/rules.json'), - getJson('data/generators.json'), - getJson('data/upgrades.json'), - ]); - return buildContext({ rules, generators, upgrades }); + const entries = await Promise.all( + Object.entries(DATA_FILES).map(async ([key, file]) => [key, await getJson(`data/${file}.json`)]), + ); + return buildContext(Object.fromEntries(entries)); } /** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */ diff --git a/public/js/ui/achievementsView.js b/public/js/ui/achievementsView.js new file mode 100644 index 0000000..8f9e768 --- /dev/null +++ b/public/js/ui/achievementsView.js @@ -0,0 +1,57 @@ +// Achievements tab: milestones with a small permanent bonus each. + +import { statValue } from '../engine/achievements.js'; +import { t } from '../services/i18n.js'; +import { h, icon } from './dom.js'; +import { effectText, setText } from './text.js'; + +export function createAchievementsView(root) { + let ctx; + let fmt; + let summary; + const rows = new Map(); + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + rows.clear(); + summary = h('p', { class: 'hint' }); + + const list = h('ul', { class: 'cards cards--grid' }); + for (const a of ctx.achievements) { + const progress = h('p', { class: 'card__meta' }); + const li = h( + 'li', + { class: 'card' }, + icon(`ach.${a.id}`), + h( + 'div', + { class: 'card__body' }, + h('h3', { class: 'card__title', text: t(`ach.title.${a.id}`) }), + h('p', { class: 'card__desc', text: t(`ach.cond.${a.condition.stat}`, { value: fmt.number(a.condition.min) }) }), + h('p', { class: 'card__meta', text: effectText(a.effect, fmt) }), + progress, + ), + ); + list.append(li); + rows.set(a.id, { a, li, progress }); + } + root.replaceChildren( + h('section', { class: 'panel panel--wide' }, h('h2', { class: 'panel__title', text: t('tab.achievements') }), summary, list), + ); + } + + function update(state) { + const bonus = state.achievements.reduce((product, id) => product * ctx.achievementsById.get(id).effect.value, 1); + setText(summary, t('ach.summary', { done: state.achievements.length, total: ctx.achievements.length, bonus: fmt.number(bonus) })); + for (const { a, li, progress } of rows.values()) { + const done = state.achievements.includes(a.id); + li.classList.toggle('card--done', done); + li.classList.toggle('card--locked', !done); + const value = Math.min(statValue(state, a.condition.stat), a.condition.min); + setText(progress, done ? t('ach.done') : `${fmt.number(Math.floor(value))} / ${fmt.number(a.condition.min)}`); + } + } + + return { rebuild, update }; +} diff --git a/public/js/ui/contractsView.js b/public/js/ui/contractsView.js new file mode 100644 index 0000000..ac50de4 --- /dev/null +++ b/public/js/ui/contractsView.js @@ -0,0 +1,164 @@ +// Contracts tab: opportunities to seize, active effects, the active contract +// and the board of offers. + +import { t } from '../services/i18n.js'; +import { h } from './dom.js'; +import { formatDuration, moneyFormatter, rewardText, setText } from './text.js'; + +const BUFF_LABEL = { + demand_mult: 'contracts.buff.demand', + global_mult: 'contracts.buff.production', + material_price_mult: 'contracts.buff.material', +}; + +export function createContractsView(root, actions) { + let ctx; + let fmt; + let els; + let offersSignature = ''; + let buffsSignature = ''; + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + offersSignature = ''; + buffsSignature = ''; + + els = { + eventTitle: h('h3', { class: 'card__title' }), + eventText: h('p', { class: 'card__desc' }), + eventTime: h('p', { class: 'card__meta' }), + claim: h('button', { class: 'btn btn--primary', type: 'button', text: t('contracts.claim'), onclick: () => actions.claimEvent() }), + eventBox: h('div', { class: 'callout' }), + eventNone: h('p', { class: 'hint', text: t('contracts.eventNone') }), + buffs: h('ul', { class: 'cards' }), + activeBox: h('div', { class: 'callout' }), + activeTitle: h('h3', { class: 'card__title' }), + activeReward: h('p', { class: 'card__desc' }), + activeProgress: h('progress', { class: 'progress', max: 1, value: 0 }), + activeText: h('p', { class: 'card__meta' }), + abandon: h('button', { class: 'btn', type: 'button', text: t('contracts.abandon'), onclick: () => actions.abandon() }), + activeNone: h('p', { class: 'hint', text: t('contracts.activeNone') }), + offers: h('ul', { class: 'cards' }), + offersNone: h('p', { class: 'hint', text: t('contracts.offersNone') }), + }; + els.eventBox.append(els.eventTitle, els.eventText, els.eventTime, els.claim); + els.activeBox.append(els.activeTitle, els.activeReward, els.activeProgress, els.activeText, els.abandon); + + root.replaceChildren( + h( + 'section', + { class: 'panel' }, + h('h2', { class: 'panel__title', text: t('contracts.events') }), + els.eventBox, + els.eventNone, + h('h3', { class: 'dialog__subtitle', text: t('contracts.effects') }), + els.buffs, + ), + h( + 'section', + { class: 'panel' }, + h('h2', { class: 'panel__title', text: t('contracts.active') }), + els.activeBox, + els.activeNone, + h('h3', { class: 'dialog__subtitle', text: t('contracts.board') }), + h('p', { class: 'hint', text: t('contracts.boardInfo', { seconds: ctx.rules.contracts.offerEverySeconds }) }), + els.offers, + els.offersNone, + ), + ); + } + + function eventText(type) { + const cfg = ctx.rules.events.types.find((e) => e.id === type); + return t(`event.${type}.text`, { + value: cfg?.buff ? fmt.number(cfg.buff.value) : '', + seconds: cfg?.buff ? cfg.buff.seconds : '', + }); + } + + function updateEvent(state) { + const current = state.events.current; + els.eventBox.hidden = !current; + els.eventNone.hidden = Boolean(current); + if (!current) return; + setText(els.eventTitle, t(`event.${current.type}.title`)); + setText(els.eventText, eventText(current.type)); + setText(els.eventTime, t('contracts.timeLeft', { time: formatDuration(current.remaining) })); + } + + function updateBuffs(state) { + const signature = state.events.buffs.map((b) => `${b.type}:${b.value}`).join(); + if (signature !== buffsSignature) { + buffsSignature = signature; + els.buffs.replaceChildren( + ...state.events.buffs.map((b) => + h( + 'li', + { class: 'card' }, + h('div', { class: 'card__body' }, h('h3', { class: 'card__title', text: t(BUFF_LABEL[b.type], { value: fmt.number(b.value) }) }), h('p', { class: 'card__meta' })), + ), + ), + ); + } + [...els.buffs.children].forEach((li, i) => { + const buff = state.events.buffs[i]; + if (buff) setText(li.querySelector('.card__meta'), t('contracts.timeLeft', { time: formatDuration(buff.remaining) })); + }); + } + + function updateActive(state) { + const active = state.contracts.active; + els.activeBox.hidden = !active; + els.activeNone.hidden = Boolean(active); + if (!active) return; + const { offer } = active; + setText(els.activeTitle, t(`contracts.template.${offer.template}`)); + setText(els.activeReward, rewardText(offer.reward, fmt, offer.seconds)); + els.activeProgress.max = offer.target; + els.activeProgress.value = active.progress; + setText( + els.activeText, + t('contracts.progress', { + done: fmt.number(Math.floor(active.progress)), + target: fmt.number(offer.target), + time: formatDuration(active.remaining), + }), + ); + } + + function updateOffers(state) { + const offers = state.contracts.offers; + const signature = offers.map((o) => `${o.template}:${o.target}:${o.reward.type}:${o.reward.amount}`).join(); + if (signature !== offersSignature) { + offersSignature = signature; + els.offers.replaceChildren( + ...offers.map((offer, index) => + h( + 'li', + { class: 'card' }, + h( + 'div', + { class: 'card__body' }, + h('h3', { class: 'card__title', text: t(`contracts.template.${offer.template}`) }), + h('p', { class: 'card__desc', text: t('contracts.goal', { target: fmt.number(offer.target), duration: formatDuration(offer.seconds) }) }), + h('p', { class: 'card__meta', text: rewardText(offer.reward, fmt, offer.seconds) }), + ), + h('button', { class: 'btn btn--buy', type: 'button', text: t('contracts.accept'), 'data-index': index, onclick: () => actions.accept(index) }), + ), + ), + ); + } + els.offersNone.hidden = offers.length > 0; + for (const button of els.offers.querySelectorAll('button')) button.disabled = Boolean(state.contracts.active); + } + + function update(state) { + updateEvent(state); + updateBuffs(state); + updateActive(state); + updateOffers(state); + } + + return { rebuild, update }; +} diff --git a/public/js/ui/dom.js b/public/js/ui/dom.js index e197fea..793272f 100644 --- a/public/js/ui/dom.js +++ b/public/js/ui/dom.js @@ -35,10 +35,7 @@ export function icon(key) { return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } }); } -/** Re-resolve the icons that live in index.html (resource tiles). */ +/** Re-resolve the icons that live in index.html (the resource bar). */ export function refreshStaticIcons(root = document) { - for (const el of root.querySelectorAll('[data-icon]')) { - if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js - el.replaceWith(icon(el.dataset.icon)); - } + for (const el of root.querySelectorAll('.resourcebar [data-icon]')) el.replaceWith(icon(el.dataset.icon)); } diff --git a/public/js/ui/gambleView.js b/public/js/ui/gambleView.js new file mode 100644 index 0000000..a54b92c --- /dev/null +++ b/public/js/ui/gambleView.js @@ -0,0 +1,167 @@ +// Gamble tab: chips are hard to buy and hard to keep. Stake them on a cycle. + +import { chipCost, odds, reliabilityCost } from '../engine/gamble.js'; +import { chipDemandBonus } from '../engine/formulas.js'; +import { t } from '../services/i18n.js'; +import { h, icon } from './dom.js'; +import { moneyFormatter, setText } from './text.js'; + +const STAKES = [ + { id: 'one', count: () => 1 }, + { id: 'tenth', fraction: 0.1 }, + { id: 'half', fraction: 0.5 }, + { id: 'all', fraction: 1 }, +]; +const LOG_SIZE = 6; + +export function createGambleView(root, actions) { + let ctx; + let fmt; + let money; + let stake = STAKES[0]; + let els; + const log = []; + + const stakeCount = (chips) => (stake.count ? stake.count() : Math.max(1, Math.ceil(chips * stake.fraction))); + + function stakeLabel(option) { + if (option.id === 'one') return t('gamble.stakeOne'); + if (option.id === 'all') return t('gamble.stakeAll'); + return t('gamble.stakePercent', { percent: option.fraction * 100 }); + } + + function renderLog() { + els.log.replaceChildren( + ...log.map((entry) => + h('li', { class: `log__entry log__entry--${entry.outcome}`, text: t(`gamble.result.${entry.outcome}`, { amount: fmt.number(Math.abs(entry.delta)) }) }), + ), + ); + els.logEmpty.hidden = log.length > 0; + } + + function run(state) { + const result = actions.run(stakeCount(state.chips)); + if (!result) return; + log.unshift(result); + log.length = Math.min(log.length, LOG_SIZE); + renderLog(); + } + + let lastState; + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + money = moneyFormatter(fmt); + + els = { + chips: h('strong'), + bonus: h('span', { class: 'hint' }), + buyChip: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyChip() }), + reliability: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyReliability() }), + odds: h('p', { class: 'hint' }), + run: h('button', { class: 'btn btn--primary btn--big', type: 'button', onclick: () => run(lastState) }), + log: h('ul', { class: 'log' }), + logEmpty: h('p', { class: 'hint', text: t('gamble.logEmpty') }), + }; + + root.replaceChildren( + h( + 'section', + { class: 'panel' }, + h('h2', { class: 'panel__title', text: t('theme.tab.gamble') }), + h('p', { class: 'hint', text: t('gamble.intro') }), + h( + 'div', + { class: 'tile' }, + icon('res.chips'), + h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.chips') }), els.chips, els.bonus), + ), + els.buyChip, + els.reliability, + ), + h( + 'section', + { class: 'panel' }, + h('h2', { class: 'panel__title', text: t('gamble.play') }), + h('p', { class: 'field__label', text: t('gamble.stake') }), + h( + 'fieldset', + { class: 'segmented' }, + h('legend', { class: 'sr-only', text: t('gamble.stake') }), + ...STAKES.map((option) => + h( + 'label', + {}, + h('input', { + type: 'radio', + name: 'gamble-stake', + value: option.id, + checked: option === stake, + onchange: () => { + stake = option; + }, + }), + h('span', { text: stakeLabel(option) }), + ), + ), + ), + els.odds, + els.run, + h('h3', { class: 'dialog__subtitle', text: t('gamble.log') }), + els.logEmpty, + els.log, + ), + ); + renderLog(); + } + + function update(state, d) { + lastState = state; + const g = ctx.rules.gamble; + + setText(els.chips, fmt.number(state.chips)); + setText(els.bonus, t('gamble.bonus', { bonus: fmt.number(chipDemandBonus(state.chips, g)) })); + + const cost = chipCost(state, ctx); + setText(els.buyChip, t('gamble.buyChip', { cost: money(cost) })); + els.buyChip.disabled = state.funds < cost || state.chips >= g.maxChips; + + const maxed = state.reliabilityLevel >= g.reliability.maxLevel; + setText( + els.reliability, + maxed + ? t('gamble.reliabilityMax', { level: state.reliabilityLevel }) + : t('gamble.reliability', { + level: state.reliabilityLevel, + max: g.reliability.maxLevel, + cost: money(reliabilityCost(state, ctx)), + }), + ); + els.reliability.disabled = maxed || state.funds < reliabilityCost(state, ctx); + + const p = odds(state, ctx, d.mods); + const percent = (x) => fmt.number(Math.round(x * 1000) / 10); + setText( + els.odds, + t('gamble.odds', { + win: percent(p.win), + jackpot: percent(p.jackpot), + multiplier: g.jackpotMultiplier, + crash: percent(p.crash), + lose: percent(p.lose), + }), + ); + + const cooling = state.gambleCooldown > 0; + setText( + els.run, + cooling + ? t('gamble.cooldown', { seconds: fmt.number(Math.ceil(state.gambleCooldown * 10) / 10) }) + : `${t('theme.action.gamble')} (${fmt.number(Math.min(stakeCount(state.chips), state.chips))})`, + ); + els.run.disabled = cooling || state.chips < 1; + } + + return { rebuild, update }; +} diff --git a/public/js/ui/investView.js b/public/js/ui/investView.js new file mode 100644 index 0000000..ea2dbe8 --- /dev/null +++ b/public/js/ui/investView.js @@ -0,0 +1,181 @@ +// Investments tab: nine societies in three risk tiers, all visible at once. + +import { portfolio } from '../engine/investments.js'; +import { t } from '../services/i18n.js'; +import { h, icon } from './dom.js'; +import { moneyFormatter, setText, signedPercent } from './text.js'; + +const AMOUNTS = [0.01, 0.1, 0.5, 1]; +const TIERS = ['low', 'mid', 'high']; + +export function createInvestView(root, actions) { + let ctx; + let fmt; + let money; + let amount = 0.1; + let summary; + const rows = new Map(); + + function societyRow(society) { + const change = h('span', { class: 'badge' }); + const price = h('span'); + const held = h('span'); + const buy = h('button', { + class: 'btn', + type: 'button', + text: t('invest.buy'), + onclick: () => actions.buy(society.id, amount), + }); + const sell = h('button', { + class: 'btn', + type: 'button', + text: t('invest.sell'), + onclick: () => actions.sell(society.id, amount), + }); + const root = h( + 'li', + { class: 'card card--stack' }, + h( + 'div', + { class: 'card__row' }, + icon(`inv.${society.id}`), + h( + 'div', + { class: 'card__body' }, + h('h3', { class: 'card__title' }, h('span', { text: t(`theme.inv.${society.id}.name`) }), change), + h('p', { class: 'card__meta' }, price), + h('p', { class: 'card__meta' }, held), + ), + ), + h('div', { class: 'card__actions' }, buy, sell), + ); + return { root, change, price, held, buy, sell }; + } + + function tierPanel(tier) { + const cfg = ctx.investments.tiers[tier]; + const list = h('ul', { class: 'cards' }); + for (const society of ctx.investments.societies.filter((s) => s.tier === tier)) { + const row = societyRow(society); + rows.set(society.id, row); + list.append(row.root); + } + return h( + 'section', + { class: `panel panel--${tier}` }, + h('h2', { class: 'panel__title', text: t(`invest.tier.${tier}`) }), + h('p', { + class: 'hint', + text: t('invest.tierInfo', { + swing: fmt.number(cfg.swing * 100), + dividend: fmt.number(cfg.dividend * 100), + }), + }), + list, + ); + } + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + money = moneyFormatter(fmt); + rows.clear(); + + summary = { + value: h('strong'), + basis: h('strong'), + profit: h('strong'), + next: h('p', { class: 'hint' }), + }; + const amountGroup = h( + 'fieldset', + { class: 'segmented' }, + h('legend', { class: 'sr-only', text: t('invest.amount') }), + ...AMOUNTS.map((a) => + h( + 'label', + {}, + h('input', { + type: 'radio', + name: 'invest-amount', + value: String(a), + checked: a === amount, + onchange: () => { + amount = a; + }, + }), + h('span', { text: `${a * 100}%` }), + ), + ), + ); + + root.replaceChildren( + h( + 'section', + { class: 'panel panel--wide' }, + h('h2', { class: 'panel__title', text: t('tab.invest') }), + h('p', { + class: 'hint', + text: t('invest.intro', { + seconds: ctx.investments.intervalSeconds, + fee: fmt.number(ctx.investments.fee * 100), + }), + }), + h( + 'dl', + { class: 'stats' }, + h('div', {}, h('dt', { text: t('invest.portfolio') }), h('dd', {}, summary.value)), + h('div', {}, h('dt', { text: t('invest.invested') }), h('dd', {}, summary.basis)), + h('div', {}, h('dt', { text: t('invest.profit') }), h('dd', {}, summary.profit)), + ), + h('p', { class: 'field__label', text: t('invest.amountLabel') }), + amountGroup, + summary.next, + ), + ...TIERS.map(tierPanel), + ); + } + + function update(state) { + const p = portfolio(state, ctx); + setText(summary.value, money(p.value)); + setText(summary.basis, money(p.basis)); + setText(summary.profit, money(p.profit)); + summary.profit.classList.toggle('pos', p.profit > 0); + summary.profit.classList.toggle('neg', p.profit < 0); + const remaining = Math.ceil(ctx.investments.intervalSeconds - state.investTimer); + setText(summary.next, t('invest.nextUpdate', { seconds: remaining })); + + for (const society of ctx.investments.societies) { + const row = rows.get(society.id); + const price = state.invest.prices[society.id]; + const shares = state.invest.holdings[society.id]; + const change = state.invest.change[society.id]; + const basis = state.invest.basis[society.id]; + + const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■'; + setText(row.change, `${arrow} ${signedPercent(change, fmt)}`); + row.change.classList.toggle('pos', change > 0); + row.change.classList.toggle('neg', change < 0); + + setText( + row.price, + t('invest.priceLine', { + price: fmt.price(price), + gap: signedPercent(price / society.basePrice - 1, fmt), + }), + ); + const value = shares * price; + setText( + row.held, + shares > 0 + ? t('invest.heldLine', { value: money(value), profit: signedPercent(basis > 0 ? value / basis - 1 : 0, fmt) }) + : t('invest.heldNone'), + ); + row.buy.disabled = state.funds < 0.01; + row.sell.disabled = shares <= 0; + } + } + + return { rebuild, update }; +} diff --git a/public/js/ui/legacyView.js b/public/js/ui/legacyView.js new file mode 100644 index 0000000..75d3eb9 --- /dev/null +++ b/public/js/ui/legacyView.js @@ -0,0 +1,106 @@ +// Legacy tab: restart the run for permanent points, spend them on perks. + +import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js'; +import { t } from '../services/i18n.js'; +import { h, icon } from './dom.js'; +import { effectText, setText } from './text.js'; + +export function createLegacyView(root, actions) { + let ctx; + let fmt; + let els; + const perkRows = new Map(); + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + perkRows.clear(); + + els = { + points: h('strong'), + earned: h('dd'), + prestiges: h('dd'), + passive: h('dd'), + gain: h('p', { class: 'card__desc' }), + need: h('p', { class: 'hint' }), + progress: h('progress', { class: 'progress', max: 1, value: 0 }), + restart: h('button', { class: 'btn btn--danger btn--wide', type: 'button', onclick: () => actions.restart() }), + }; + + const perks = h('ul', { class: 'cards' }); + for (const perk of ctx.legacy) { + const level = h('span', { class: 'badge' }); + const cost = h('span', { class: 'btn__label' }); + const buy = h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyPerk(perk.id) }, cost); + const li = h( + 'li', + { class: 'card' }, + icon(`legacy.${perk.id}`), + h( + 'div', + { class: 'card__body' }, + h('h3', { class: 'card__title' }, h('span', { text: t(`legacy.perk.${perk.id}`) }), level), + h('p', { class: 'card__desc', text: effectText(perk.effect, fmt) }), + ), + buy, + ); + perks.append(li); + perkRows.set(perk.id, { perk, level, cost, buy }); + } + + root.replaceChildren( + h( + 'section', + { class: 'panel' }, + 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('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)), + h( + 'dl', + { class: 'stats' }, + h('div', {}, h('dt', { text: t('legacy.earned') }), els.earned), + h('div', {}, h('dt', { text: t('legacy.restarts') }), els.prestiges), + h('div', {}, h('dt', { text: t('legacy.passive') }), els.passive), + ), + h('h3', { class: 'dialog__subtitle', text: t('legacy.restartTitle') }), + els.gain, + els.progress, + els.need, + els.restart, + ), + h('section', { class: 'panel' }, h('h2', { class: 'panel__title', text: t('legacy.perks') }), perks), + ); + } + + function update(state, d) { + const { threshold, passivePerPoint } = ctx.rules.legacy; + setText(els.points, fmt.number(state.legacy.points)); + setText(els.earned, fmt.number(state.legacy.earned)); + setText(els.prestiges, fmt.number(state.legacy.prestiges)); + setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number((1 + passivePerPoint) ** state.legacy.earned) })); + + const gain = legacyGain(state, ctx, d.mods); + setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) })); + els.progress.max = threshold; + els.progress.value = Math.min(state.totalProduced, threshold); + setText( + els.need, + gain >= 1 + ? t('legacy.ready') + : t('legacy.need', { have: fmt.number(Math.floor(state.totalProduced)), need: fmt.number(threshold) }), + ); + setText(els.restart, t('legacy.restart')); + els.restart.disabled = gain < 1; + + for (const { perk, level, cost, buy } of perkRows.values()) { + const lv = perkLevel(state, perk); + const maxed = lv >= perk.maxLevel; + const price = perkCost(perk, lv); + setText(level, t('legacy.level', { level: lv, max: perk.maxLevel })); + setText(cost, maxed ? t('legacy.maxed') : t('legacy.perkCost', { cost: fmt.number(price) })); + buy.disabled = maxed || state.legacy.points < price; + } + } + + return { rebuild, update }; +} diff --git a/public/js/ui/researchView.js b/public/js/ui/researchView.js new file mode 100644 index 0000000..e1d60d7 --- /dev/null +++ b/public/js/ui/researchView.js @@ -0,0 +1,114 @@ +// Research tab: labs produce points over time; points buy nodes of the tree. + +import { labCost, researchStatus } from '../engine/research.js'; +import { t } from '../services/i18n.js'; +import { h, icon } from './dom.js'; +import { effectText, moneyFormatter, setText } from './text.js'; + +const BRANCHES = ['prod', 'mkt', 'log', 'fin', 'ins']; + +export function createResearchView(root, actions) { + let ctx; + let fmt; + let money; + let els; + const nodeRows = new Map(); + + function nodeRow(node) { + const status = h('p', { class: 'card__meta' }); + const buy = h('button', { + class: 'btn btn--buy', + type: 'button', + onclick: () => actions.buyNode(node.id), + }); + const li = h( + 'li', + { class: 'card' }, + icon(`research.${node.id}`), + h( + 'div', + { class: 'card__body' }, + h('h3', { class: 'card__title', text: t(`research.node.${node.id}`) }), + h('p', { class: 'card__desc', text: effectText(node.effect, fmt) }), + status, + ), + buy, + ); + return { li, status, buy, node }; + } + + function rebuild(newCtx, formatter) { + ctx = newCtx; + fmt = formatter; + money = moneyFormatter(fmt); + nodeRows.clear(); + + els = { + rp: h('strong'), + rate: h('span', { class: 'hint' }), + labs: h('span', { class: 'hint' }), + buyLab: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyLab() }), + }; + + const branchPanels = BRANCHES.map((branch) => { + const list = h('ul', { class: 'cards' }); + for (const node of ctx.research.filter((n) => n.branch === branch)) { + const row = nodeRow(node); + nodeRows.set(node.id, row); + list.append(row.li); + } + return h( + 'section', + { class: 'panel' }, + h('h2', { class: 'panel__title', text: t(`research.branch.${branch}`) }), + list, + ); + }); + + root.replaceChildren( + h( + 'section', + { class: 'panel panel--wide' }, + h('h2', { class: 'panel__title', text: t('tab.research') }), + h('p', { class: 'hint', text: t('research.intro') }), + h( + 'div', + { class: 'tile' }, + icon('res.research'), + h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.research') }), els.rp, els.rate), + ), + els.labs, + els.buyLab, + ), + ...branchPanels, + ); + } + + function update(state, d) { + const r = ctx.rules.research; + setText(els.rp, fmt.number(Math.floor(state.rp))); + setText(els.rate, t('research.rate', { rate: fmt.number(Math.round(d.rpRate * 1000) / 1000) })); + setText(els.labs, t('research.labs', { count: state.labs, perLab: fmt.number(r.rpPerLabPerSecond * d.mods.rp) })); + + const maxed = state.labs >= r.labMax; + const cost = labCost(state, ctx); + setText(els.buyLab, maxed ? t('research.labMax') : t('research.buyLab', { cost: money(cost) })); + els.buyLab.disabled = maxed || state.funds < cost; + + for (const { li, status, buy, node } of nodeRows.values()) { + const s = researchStatus(state, node); + li.classList.toggle('card--done', s === 'owned'); + li.classList.toggle('card--locked', s === 'locked'); + buy.hidden = s !== 'available'; + buy.disabled = state.rp < node.cost; + setText(buy, t('research.cost', { cost: fmt.number(node.cost) })); + if (s === 'owned') setText(status, t('research.owned')); + else if (s === 'locked') { + const names = node.requires.map((id) => t(`research.node.${id}`)).join(', '); + setText(status, t('research.requires', { names })); + } else setText(status, ''); + } + } + + return { rebuild, update }; +} diff --git a/public/js/ui/tabs.js b/public/js/ui/tabs.js new file mode 100644 index 0000000..734d08b --- /dev/null +++ b/public/js/ui/tabs.js @@ -0,0 +1,89 @@ +// The section menu: horizontal tabs on desktop/tablet, a burger menu on phones +// (the markup is the same, the CSS decides). Tabs unlock with progress. + +import { isUnlocked } from '../engine/engine.js'; +import { has, t } from '../services/i18n.js'; +import { $, h } from './dom.js'; +import { showToast } from './toast.js'; + +const labelKey = (id) => (has(`theme.tab.${id}`) ? `theme.tab.${id}` : `tab.${id}`); + +/** + * @param tabs data/tabs.json + * @param onSelect (tabId) called when the visible section changes + */ +export function createTabs(tabs, onSelect) { + const list = $('#tabs'); + const menu = $('#menu'); + const toggle = $('#menu-toggle'); + const buttons = new Map(); + let active = 'main'; + let known = null; // unlocked tabs seen so far (null until the first update) + const unseen = new Set(); + + const view = (id) => $(`[data-view="${id}"]`); + + function select(id) { + active = id; + unseen.delete(id); + for (const tab of tabs) { + const section = view(tab.id); + if (section) section.hidden = tab.id !== id; + buttons.get(tab.id)?.button.setAttribute('aria-current', tab.id === id ? 'page' : 'false'); + } + buttons.get(id)?.item.classList.remove('tab--new'); + setMenuOpen(false); + onSelect(id); + } + + function setMenuOpen(open) { + menu.toggleAttribute('data-open', open); + toggle.setAttribute('aria-expanded', String(open)); + } + + toggle.addEventListener('click', () => setMenuOpen(!menu.hasAttribute('data-open'))); + + /** Build the buttons (call again after a language or theme change to relabel). */ + function rebuild() { + list.replaceChildren(); + buttons.clear(); + for (const tab of tabs) { + const button = h('button', { + class: 'tab', + type: 'button', + 'aria-current': tab.id === active ? 'page' : 'false', + text: t(labelKey(tab.id)), + onclick: () => select(tab.id), + }); + const item = h('li', { class: 'tabs__item' }, button); + item.hidden = known ? !known.has(tab.id) : tab.id !== 'main'; + if (unseen.has(tab.id)) item.classList.add('tab--new'); + list.append(item); + buttons.set(tab.id, { item, button }); + } + } + + /** Call every frame: reveals newly unlocked tabs, flags the ones needing attention. */ + function update(state) { + const unlocked = new Set(tabs.filter((tab) => isUnlocked(state, tab.unlock)).map((tab) => tab.id)); + + for (const tab of tabs) { + const entry = buttons.get(tab.id); + const isNew = known && unlocked.has(tab.id) && !known.has(tab.id); + entry.item.hidden = !unlocked.has(tab.id); + if (isNew) { + unseen.add(tab.id); + entry.item.classList.add('tab--new'); + showToast(t('toast.tabUnlocked', { name: t(labelKey(tab.id)) })); + } + } + known = unlocked; + + // An opportunity is waiting on the contracts tab. + buttons.get('contracts')?.item.classList.toggle('tab--alert', Boolean(state.events.current)); + + if (!unlocked.has(active)) select('main'); + } + + return { rebuild, update, select, get active() { return active; } }; +} diff --git a/public/js/ui/text.js b/public/js/ui/text.js new file mode 100644 index 0000000..ca03f1a --- /dev/null +++ b/public/js/ui/text.js @@ -0,0 +1,51 @@ +// Text helpers shared by the views: money, durations, effects and rewards. + +import { t } from '../services/i18n.js'; + +/** Money in the theme's currency, e.g. "12.50 GAL". */ +export const moneyFormatter = (fmt) => (n) => t('theme.fmt.funds', { value: fmt.money(n) }); + +export function formatDuration(seconds) { + const total = Math.max(0, Math.round(seconds)); + const minutes = Math.floor(total / 60); + if (minutes < 1) return t('time.seconds', { value: total }); + if (minutes < 60) return t('time.minutes', { value: minutes }); + return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 }); +} + +/** "×1.5 production", "-20% generator cost"... from an effect object. */ +export function effectText(effect, fmt) { + const params = { value: fmt.number(effect.value) }; + if (effect.target) params.target = t(`theme.gen.${effect.target}.name`); + if (effect.type === 'batch_discount' || effect.type === 'generator_cost_mult') { + params.percent = fmt.number(Math.round((1 - effect.value) * 100)); + } + if (effect.type === 'reliability_add' || effect.type === 'offline_add') { + params.percent = fmt.number(Math.round(effect.value * 100)); + } + if (effect.type === 'start_funds') params.funds = moneyFormatter(fmt)(effect.value); + return t(`effect.${effect.type}`, params); +} + +/** What a contract pays, e.g. "Reward: 1.2M GAL". */ +export function rewardText(reward, fmt, seconds = 0) { + const money = moneyFormatter(fmt); + switch (reward.type) { + case 'funds': + return t('contracts.reward.funds', { amount: money(reward.amount) }); + case 'boost': + return t('contracts.reward.boost', { value: fmt.number(reward.amount), duration: formatDuration(seconds) }); + default: // research, chips + return t(`contracts.reward.${reward.type}`, { amount: fmt.number(reward.amount) }); + } +} + +export function setText(node, text) { + if (node.textContent !== text) node.textContent = text; +} + +/** "+3.2%" / "-1.0%" */ +export function signedPercent(fraction, fmt) { + const sign = fraction > 0 ? '+' : ''; + return `${sign}${fmt.number(Math.round(fraction * 1000) / 10)}%`; +} diff --git a/public/js/ui/toast.js b/public/js/ui/toast.js index 15c27c0..1a454db 100644 --- a/public/js/ui/toast.js +++ b/public/js/ui/toast.js @@ -1,9 +1,12 @@ import { $, h } from './dom.js'; const DURATION_MS = 6000; +const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages export function showToast(message, { error = false } = {}) { + const container = $('#toasts'); const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message }); - $('#toasts').append(toast); + container.append(toast); + while (container.children.length > MAX_VISIBLE) container.firstElementChild.remove(); setTimeout(() => toast.remove(), DURATION_MS); } diff --git a/public/locales/en.json b/public/locales/en.json index 9390d40..2d8d061 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -35,7 +35,8 @@ "hintDemandOnly": "At this price customers want {demand}/s.", "marketShort": "Demand outpaces your output: you are selling too cheap.", "marketLong": "You make more than customers want: stock piles up. Try a lower price.", - "marketBalanced": "Supply and demand are about even." + "marketBalanced": "Supply and demand are about even.", + "menu": "Menu" }, "effect": { "click_mult": "Manual work ×{value}", @@ -46,7 +47,17 @@ "material_efficiency": "{@theme.res.material} goes ×{value} further", "batch_discount": "{@theme.res.material} is {percent}% cheaper", "auto_buy_material": "Restocks {@theme.res.material} automatically", - "batch_size_mult": "{@theme.res.material}: ×{value} per purchase" + "batch_size_mult": "{@theme.res.material}: ×{value} per purchase", + "dividend_mult": "Investment dividends ×{value}", + "reliability_add": "Gamble reliability +{percent}%", + "offline_add": "Offline efficiency +{percent}%", + "contract_mult": "Contract rewards ×{value}", + "rp_mult": "{@theme.res.research} ×{value}", + "generator_cost_mult": "Automation costs {percent}% less", + "start_funds": "Each run starts with {funds}", + "price_advisor": "Reveals the balanced price", + "material_price_mult": "{@theme.res.material} price ×{value}", + "legacy_gain_mult": "{@theme.res.legacy} gains ×{value}" }, "time": { "seconds": "{value} s", @@ -76,7 +87,14 @@ "imported": "Save imported.", "copied": "Copied to the clipboard.", "copyFailed": "Could not copy. Select the text and copy it manually.", - "reset": "Progress reset." + "reset": "Progress reset.", + "tabUnlocked": "New section unlocked: {name}", + "contractDone": "Contract complete! {reward}", + "contractFailed": "Contract failed: time ran out.", + "eventOpportunity": "Opportunity: {title}! Seize it in the Contracts tab.", + "eventIncident": "Incident: {title}", + "achievement": "Achievement: {title}", + "restarted": "A new run begins! +{gain} {@theme.res.legacy}" }, "error": { "import": { @@ -84,5 +102,205 @@ "invalid": "This is not a valid save.", "wrong_game": "This file is not an Idle Clicker save." } + }, + "tab": { + "main": "Production", + "contracts": "Contracts", + "invest": "Investments", + "research": "Research", + "legacy": "Legacy", + "achievements": "Achievements" + }, + "invest": { + "intro": "Prices change every {seconds} s and drift back towards their fair value. Holdings pay a dividend each time. Every trade costs a {fee}% fee.", + "tier": { + "low": "Low risk", + "mid": "Medium risk", + "high": "High risk" + }, + "tierInfo": "Moves up to ±{swing}% per update · {dividend}% dividend", + "portfolio": "Portfolio value", + "invested": "Invested", + "profit": "Profit", + "amountLabel": "Amount per trade (share of your funds when buying, of the holding when selling)", + "amount": "Amount per trade", + "nextUpdate": "Next price update in {seconds} s", + "buy": "Buy", + "sell": "Sell", + "priceLine": "Price {price} ({gap} vs fair value)", + "heldLine": "Held: {value} ({profit})", + "heldNone": "Nothing held" + }, + "gamble": { + "intro": "{@theme.res.chips} are hard to buy and harder to keep. Stake some on a cycle: win and the stake doubles, hit a jackpot and it multiplies, lose and it is gone, crash and you lose even more. They also boost demand.", + "bonus": "Demand ×{bonus}", + "buyChip": "Buy one · {cost}", + "reliability": "Improve reliability ({level}/{max}) · {cost}", + "reliabilityMax": "Reliability at maximum ({level})", + "play": "Play", + "stake": "Stake", + "stakeOne": "1", + "stakePercent": "{percent}%", + "stakeAll": "All", + "odds": "Win {win}% · Jackpot ×{multiplier} {jackpot}% · Crash {crash}% · Lose {lose}%", + "cooldown": "Recharging… {seconds} s", + "log": "Last results", + "logEmpty": "No cycle played yet.", + "result": { + "win": "Win! +{amount} {@theme.res.chips}", + "jackpot": "JACKPOT! +{amount} {@theme.res.chips}", + "lose": "Lost {amount} {@theme.res.chips}", + "crash": "CRASH! Lost {amount} {@theme.res.chips}" + } + }, + "research": { + "intro": "Labs produce research slowly, even while you are away. Spend it on permanent upgrades; research survives a legacy restart.", + "rate": "{rate}/s", + "labs": "Labs: {count} · {perLab}/s each", + "buyLab": "Build a lab · {cost}", + "labMax": "Labs at maximum", + "cost": "{cost}", + "owned": "Researched", + "requires": "Requires: {names}", + "branch": { + "prod": "Production", + "mkt": "Market", + "log": "Logistics", + "fin": "Finance", + "ins": "Insight" + }, + "node": { + "r_prod_1": "Optimised Workflows", + "r_prod_2": "Automation Suite", + "r_prod_3": "Total Integration", + "r_mkt_1": "Market Analysis", + "r_mkt_2": "Brand Strategy", + "r_mkt_3": "Premium Positioning", + "r_log_1": "Resource Efficiency", + "r_log_2": "Bulk Procurement", + "r_log_3": "Lean Manufacturing", + "r_fin_1": "Portfolio Management", + "r_fin_2": "Risk Modelling", + "r_fin_3": "Negotiation Training", + "r_ins_1": "Research Methods", + "r_ins_2": "Remote Operations", + "r_ins_3": "Price Oracle" + } + }, + "contracts": { + "events": "Opportunities", + "eventNone": "Nothing right now. Opportunities show up from time to time: be quick.", + "claim": "Seize it!", + "timeLeft": "{time} left", + "effects": "Active effects", + "buff": { + "demand": "Demand ×{value}", + "production": "Production ×{value}", + "material": "{@theme.res.material} price ×{value}" + }, + "active": "Contract", + "activeNone": "No contract in progress. Accept an offer below.", + "abandon": "Abandon", + "progress": "{done} / {target} sold · {time} left", + "board": "Offers", + "boardInfo": "A new offer appears every {seconds} s; the oldest one expires. Sell the target amount in time to earn the reward.", + "offersNone": "No offers yet.", + "accept": "Accept", + "template": { + "sprint": "Sprint", + "steady": "Steady order", + "marathon": "Marathon" + }, + "goal": "Sell {target} {@theme.res.units} within {duration}", + "reward": { + "funds": "Reward: {amount}", + "research": "Reward: {amount} {@theme.res.research}", + "chips": "Reward: {amount} {@theme.res.chips}", + "boost": "Reward: production ×{value} for {duration}" + } + }, + "event": { + "surge": { + "title": "Demand surge", + "text": "Customers flock to you: demand ×{value} for {seconds} s." + }, + "rush": { + "title": "Production rush", + "text": "Everyone works overtime: production ×{value} for {seconds} s." + }, + "windfall": { + "title": "Windfall", + "text": "An unexpected buyer pays a lot, right now." + }, + "crash": { + "title": "Supply crash", + "text": "{@theme.res.material} costs ×{value} for {seconds} s." + } + }, + "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.", + "earned": "Earned in total", + "restarts": "Restarts", + "passive": "Passive bonus", + "passiveValue": "×{bonus}", + "restartTitle": "Start a new run", + "gain": "Restarting now earns {gain} {@theme.res.legacy}.", + "need": "Reach {need} {@theme.res.units} in this run ({have} so far).", + "ready": "You can restart now. The bigger the run, the bigger the reward.", + "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.", + "perks": "Perks", + "perk": { + "lg_prod": "Industrial Memory", + "lg_demand": "Brand Heritage", + "lg_cost": "Lean Blueprints", + "lg_start": "Head Start", + "lg_offline": "Night Shift", + "lg_rp": "Research Archive" + }, + "level": "{level}/{max}", + "perkCost": "{cost} {@theme.res.legacy}", + "maxed": "Maxed" + }, + "ach": { + "summary": "{done} / {total} achieved · bonus ×{bonus}", + "done": "Achieved", + "cond": { + "lifetimeProduced": "Produce {value} {@theme.res.units} in total", + "generatorsOwned": "Own {value} automation units", + "upgradesOwned": "Buy {value} upgrades", + "investProfit": "Make {value} profit from investments", + "gambleWins": "Win {value} gamble cycles", + "chips": "Hold {value} {@theme.res.chips}", + "contractsDone": "Complete {value} contracts", + "researchOwned": "Complete {value} research", + "prestiges": "Restart {value} times", + "eventsClaimed": "Seize {value} opportunities" + }, + "title": { + "a_prod_1": "First Steps", + "a_prod_2": "Getting Serious", + "a_prod_3": "Industrial Scale", + "a_prod_4": "Beyond Imagination", + "a_prod_5": "Cosmic Output", + "a_gen_1": "Small Team", + "a_gen_2": "Big Operation", + "a_gen_3": "Endless Workforce", + "a_upg_1": "Tinkerer", + "a_upg_2": "Perfectionist", + "a_inv_1": "Pocket Money", + "a_inv_2": "Smart Money", + "a_inv_3": "Tycoon", + "a_gam_1": "Beginner's Luck", + "a_gam_2": "High Roller", + "a_gam_3": "Chip Collector", + "a_con_1": "Deal Maker", + "a_con_2": "Reliable Partner", + "a_res_1": "Curious Mind", + "a_res_2": "Master Scientist", + "a_leg_1": "Fresh Start", + "a_leg_2": "Eternal Return", + "a_evt_1": "Quick Hands" + } } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 2944320..c992dbd 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -35,7 +35,8 @@ "hintDemandOnly": "À ce prix, les clients veulent {demand}/s.", "marketShort": "La demande dépasse votre production : vous vendez trop bon marché.", "marketLong": "Vous produisez plus que la demande : le stock s'accumule. Essayez un prix plus bas.", - "marketBalanced": "L'offre et la demande sont à peu près égales." + "marketBalanced": "L'offre et la demande sont à peu près égales.", + "menu": "Menu" }, "effect": { "click_mult": "Travail manuel ×{value}", @@ -46,7 +47,17 @@ "material_efficiency": "{@theme.res.material} : rendement ×{value}", "batch_discount": "{@theme.res.material} : {percent} % moins cher", "auto_buy_material": "Réapprovisionnement automatique : {@theme.res.material}", - "batch_size_mult": "{@theme.res.material} : ×{value} par achat" + "batch_size_mult": "{@theme.res.material} : ×{value} par achat", + "dividend_mult": "Dividendes des investissements ×{value}", + "reliability_add": "Fiabilité du jeu de hasard +{percent} %", + "offline_add": "Efficacité hors ligne +{percent} %", + "contract_mult": "Récompenses de contrats ×{value}", + "rp_mult": "{@theme.res.research} ×{value}", + "generator_cost_mult": "Automatisation {percent} % moins chère", + "start_funds": "Chaque partie démarre avec {funds}", + "price_advisor": "Révèle le prix d'équilibre", + "material_price_mult": "Prix : {@theme.res.material} ×{value}", + "legacy_gain_mult": "{@theme.res.legacy} gagnés ×{value}" }, "time": { "seconds": "{value} s", @@ -76,7 +87,14 @@ "imported": "Sauvegarde importée.", "copied": "Copié dans le presse-papiers.", "copyFailed": "Copie impossible. Sélectionnez le texte et copiez-le manuellement.", - "reset": "Progression réinitialisée." + "reset": "Progression réinitialisée.", + "tabUnlocked": "Nouvelle section débloquée : {name}", + "contractDone": "Contrat terminé ! {reward}", + "contractFailed": "Contrat échoué : le temps est écoulé.", + "eventOpportunity": "Opportunité : {title} ! Saisissez-la dans l'onglet Contrats.", + "eventIncident": "Incident : {title}", + "achievement": "Succès : {title}", + "restarted": "Une nouvelle partie commence ! +{gain} ({@theme.res.legacy})" }, "error": { "import": { @@ -84,5 +102,205 @@ "invalid": "Ceci n'est pas une sauvegarde valide.", "wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker." } + }, + "tab": { + "main": "Production", + "contracts": "Contrats", + "invest": "Investissements", + "research": "Recherche", + "legacy": "Héritage", + "achievements": "Succès" + }, + "invest": { + "intro": "Les prix changent toutes les {seconds} s et reviennent peu à peu vers leur valeur juste. Les titres détenus versent un dividende à chaque fois. Chaque transaction coûte {fee} % de frais.", + "tier": { + "low": "Risque faible", + "mid": "Risque moyen", + "high": "Risque élevé" + }, + "tierInfo": "Varie jusqu'à ±{swing} % par mise à jour · {dividend} % de dividende", + "portfolio": "Valeur du portefeuille", + "invested": "Investi", + "profit": "Profit", + "amountLabel": "Montant par transaction (part de vos fonds à l'achat, du titre détenu à la vente)", + "amount": "Montant par transaction", + "nextUpdate": "Prochaine mise à jour des prix dans {seconds} s", + "buy": "Acheter", + "sell": "Vendre", + "priceLine": "Prix {price} ({gap} par rapport à la valeur juste)", + "heldLine": "Détenu : {value} ({profit})", + "heldNone": "Rien de détenu" + }, + "gamble": { + "intro": "{@theme.res.chips} : difficiles à acheter, plus difficiles encore à garder. Misez-en sur un cycle : gagnez et la mise double, tirez le jackpot et elle se multiplie, perdez et elle disparaît, plantez-vous et vous perdez encore plus. Ils augmentent aussi la demande.", + "bonus": "Demande ×{bonus}", + "buyChip": "En acheter un · {cost}", + "reliability": "Améliorer la fiabilité ({level}/{max}) · {cost}", + "reliabilityMax": "Fiabilité au maximum ({level})", + "play": "Jouer", + "stake": "Mise", + "stakeOne": "1", + "stakePercent": "{percent} %", + "stakeAll": "Tout", + "odds": "Gain {win} % · Jackpot ×{multiplier} {jackpot} % · Crash {crash} % · Perte {lose} %", + "cooldown": "Recharge… {seconds} s", + "log": "Derniers résultats", + "logEmpty": "Aucun cycle joué pour l'instant.", + "result": { + "win": "Gagné ! +{amount} {@theme.res.chips}", + "jackpot": "JACKPOT ! +{amount} {@theme.res.chips}", + "lose": "Perdu : {amount} {@theme.res.chips}", + "crash": "CRASH ! Perdu : {amount} {@theme.res.chips}" + } + }, + "research": { + "intro": "Les laboratoires produisent de la recherche lentement, même en votre absence. Dépensez-la en améliorations permanentes ; la recherche survit à un redémarrage d'héritage.", + "rate": "{rate}/s", + "labs": "Laboratoires : {count} · {perLab}/s chacun", + "buyLab": "Construire un laboratoire · {cost}", + "labMax": "Laboratoires au maximum", + "cost": "{cost}", + "owned": "Recherché", + "requires": "Nécessite : {names}", + "branch": { + "prod": "Production", + "mkt": "Marché", + "log": "Logistique", + "fin": "Finance", + "ins": "Intuition" + }, + "node": { + "r_prod_1": "Flux optimisés", + "r_prod_2": "Suite d'automatisation", + "r_prod_3": "Intégration totale", + "r_mkt_1": "Étude de marché", + "r_mkt_2": "Stratégie de marque", + "r_mkt_3": "Positionnement premium", + "r_log_1": "Efficacité des ressources", + "r_log_2": "Achats groupés", + "r_log_3": "Production allégée", + "r_fin_1": "Gestion de portefeuille", + "r_fin_2": "Modélisation du risque", + "r_fin_3": "Formation à la négociation", + "r_ins_1": "Méthodes de recherche", + "r_ins_2": "Opérations à distance", + "r_ins_3": "Oracle des prix" + } + }, + "contracts": { + "events": "Opportunités", + "eventNone": "Rien pour l'instant. Des opportunités apparaissent de temps en temps : soyez rapide.", + "claim": "Saisir !", + "timeLeft": "Reste {time}", + "effects": "Effets actifs", + "buff": { + "demand": "Demande ×{value}", + "production": "Production ×{value}", + "material": "Prix : {@theme.res.material} ×{value}" + }, + "active": "Contrat", + "activeNone": "Aucun contrat en cours. Acceptez une offre ci-dessous.", + "abandon": "Abandonner", + "progress": "{done} / {target} vendus · reste {time}", + "board": "Offres", + "boardInfo": "Une nouvelle offre apparaît toutes les {seconds} s ; la plus ancienne expire. Vendez la quantité demandée à temps pour toucher la récompense.", + "offersNone": "Aucune offre pour l'instant.", + "accept": "Accepter", + "template": { + "sprint": "Sprint", + "steady": "Commande régulière", + "marathon": "Marathon" + }, + "goal": "Vendre {target} ({@theme.res.units}) en {duration}", + "reward": { + "funds": "Récompense : {amount}", + "research": "Récompense : {amount} ({@theme.res.research})", + "chips": "Récompense : {amount} ({@theme.res.chips})", + "boost": "Récompense : production ×{value} pendant {duration}" + } + }, + "event": { + "surge": { + "title": "Pic de demande", + "text": "Les clients affluent : demande ×{value} pendant {seconds} s." + }, + "rush": { + "title": "Coup de collier", + "text": "Tout le monde fait des heures sup : production ×{value} pendant {seconds} s." + }, + "windfall": { + "title": "Aubaine", + "text": "Un acheteur inattendu paie très cher, tout de suite." + }, + "crash": { + "title": "Crise d'approvisionnement", + "text": "Prix : {@theme.res.material} ×{value} pendant {seconds} s." + } + }, + "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.", + "earned": "Gagnés au total", + "restarts": "Redémarrages", + "passive": "Bonus passif", + "passiveValue": "×{bonus}", + "restartTitle": "Lancer une nouvelle partie", + "gain": "Redémarrer maintenant rapporte {gain} ({@theme.res.legacy}).", + "need": "Atteignez {need} ({@theme.res.units}) dans cette partie ({have} pour l'instant).", + "ready": "Vous pouvez redémarrer. Plus la partie est grosse, plus la récompense l'est.", + "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.", + "perks": "Avantages", + "perk": { + "lg_prod": "Mémoire industrielle", + "lg_demand": "Héritage de marque", + "lg_cost": "Plans optimisés", + "lg_start": "Départ lancé", + "lg_offline": "Équipe de nuit", + "lg_rp": "Archives de recherche" + }, + "level": "{level}/{max}", + "perkCost": "{cost} ({@theme.res.legacy})", + "maxed": "Au maximum" + }, + "ach": { + "summary": "{done} / {total} obtenus · bonus ×{bonus}", + "done": "Obtenu", + "cond": { + "lifetimeProduced": "Produire {value} ({@theme.res.units}) au total", + "generatorsOwned": "Posséder {value} unités d'automatisation", + "upgradesOwned": "Acheter {value} améliorations", + "investProfit": "Gagner {value} de profit en investissant", + "gambleWins": "Gagner {value} cycles de jeu", + "chips": "Détenir {value} ({@theme.res.chips})", + "contractsDone": "Terminer {value} contrats", + "researchOwned": "Terminer {value} recherches", + "prestiges": "Redémarrer {value} fois", + "eventsClaimed": "Saisir {value} opportunités" + }, + "title": { + "a_prod_1": "Premiers pas", + "a_prod_2": "On se met au sérieux", + "a_prod_3": "Échelle industrielle", + "a_prod_4": "Au-delà de l'imaginable", + "a_prod_5": "Production cosmique", + "a_gen_1": "Petite équipe", + "a_gen_2": "Grosse opération", + "a_gen_3": "Main-d'œuvre infinie", + "a_upg_1": "Bricoleur", + "a_upg_2": "Perfectionniste", + "a_inv_1": "Argent de poche", + "a_inv_2": "Placements malins", + "a_inv_3": "Magnat", + "a_gam_1": "Chance du débutant", + "a_gam_2": "Flambeur", + "a_gam_3": "Collectionneur de jetons", + "a_con_1": "Sens des affaires", + "a_con_2": "Partenaire fiable", + "a_res_1": "Esprit curieux", + "a_res_2": "Maître savant", + "a_leg_1": "Nouveau départ", + "a_leg_2": "Éternel recommencement", + "a_evt_1": "Bons réflexes" + } } } diff --git a/public/themes/alien/locales/en.json b/public/themes/alien/locales/en.json index 7547cfa..d202b80 100644 --- a/public/themes/alien/locales/en.json +++ b/public/themes/alien/locales/en.json @@ -2,16 +2,48 @@ "theme": { "name": "Alien Minerals", "title": "Xeno-Mining Colony", - "res": { "units": "Xenocrystals", "unit": "xenocrystal", "funds": "Credits", "material": "Raw ore" }, - "fmt": { "funds": "{value} cr" }, - "action": { "make": "Refine a crystal", "buyMaterial": "Order raw ore" }, + "res": { + "units": "Xenocrystals", + "unit": "xenocrystal", + "funds": "Credits", + "material": "Raw ore", + "chips": "Chance Crystals", + "research": "Xeno-Data", + "legacy": "Blueprints" + }, + "fmt": { + "funds": "{value} cr" + }, + "action": { + "make": "Refine a crystal", + "buyMaterial": "Order raw ore", + "gamble": "Consult the oracle" + }, "gen": { - "gen1": { "name": "Mining Drone", "desc": "A tiny robot that scrapes the rock face." }, - "gen2": { "name": "Plasma Drill", "desc": "Melts through bedrock like butter." }, - "gen3": { "name": "Orbital Extractor", "desc": "Pulls ore straight out of the crust from orbit." }, - "gen4": { "name": "Asteroid Harvester", "desc": "Eats whole asteroids and refines the leftovers." }, - "gen5": { "name": "Hive Swarm", "desc": "Millions of drones thinking as one." }, - "gen6": { "name": "Planet Cracker", "desc": "Opens a world like a walnut." } + "gen1": { + "name": "Mining Drone", + "desc": "A tiny robot that scrapes the rock face." + }, + "gen2": { + "name": "Plasma Drill", + "desc": "Melts through bedrock like butter." + }, + "gen3": { + "name": "Orbital Extractor", + "desc": "Pulls ore straight out of the crust from orbit." + }, + "gen4": { + "name": "Asteroid Harvester", + "desc": "Eats whole asteroids and refines the leftovers." + }, + "gen5": { + "name": "Hive Swarm", + "desc": "Millions of drones thinking as one." + }, + "gen6": { + "name": "Planet Cracker", + "desc": "Opens a world like a walnut." + } }, "upg": { "click_1": "Gravity Gloves", @@ -50,6 +82,38 @@ "stack_4": "Convoy Fleets", "stack_5": "Orbital Stockpile", "auto_buy": "Autonomous Freighter" + }, + "tab": { + "gamble": "Xeno Oracle" + }, + "inv": { + "inv_low_1": { + "name": "Zeta Mining Cooperative" + }, + "inv_low_2": { + "name": "Orion Ore Trust" + }, + "inv_low_3": { + "name": "Lunar Refinery Bond" + }, + "inv_mid_1": { + "name": "XenoCorp Industries" + }, + "inv_mid_2": { + "name": "Nebula Extraction Ltd" + }, + "inv_mid_3": { + "name": "Void Cargo Syndicate" + }, + "inv_high_1": { + "name": "Hyperion Rift Labs" + }, + "inv_high_2": { + "name": "Singularity Salvage" + }, + "inv_high_3": { + "name": "Black Hole Ventures" + } } } } diff --git a/public/themes/alien/locales/fr.json b/public/themes/alien/locales/fr.json index 98d9a0d..03286c1 100644 --- a/public/themes/alien/locales/fr.json +++ b/public/themes/alien/locales/fr.json @@ -2,16 +2,48 @@ "theme": { "name": "Minerais extraterrestres", "title": "Colonie d'extraction xéno", - "res": { "units": "Xénocristaux", "unit": "xénocristal", "funds": "Crédits", "material": "Minerai brut" }, - "fmt": { "funds": "{value} cr" }, - "action": { "make": "Raffiner un cristal", "buyMaterial": "Commander du minerai brut" }, + "res": { + "units": "Xénocristaux", + "unit": "xénocristal", + "funds": "Crédits", + "material": "Minerai brut", + "chips": "Cristaux de hasard", + "research": "Xéno-données", + "legacy": "Plans" + }, + "fmt": { + "funds": "{value} cr" + }, + "action": { + "make": "Raffiner un cristal", + "buyMaterial": "Commander du minerai brut", + "gamble": "Consulter l'oracle" + }, "gen": { - "gen1": { "name": "Drone mineur", "desc": "Un minuscule robot qui gratte la roche." }, - "gen2": { "name": "Foreuse à plasma", "desc": "Fond la roche comme du beurre." }, - "gen3": { "name": "Extracteur orbital", "desc": "Aspire le minerai de la croûte depuis l'orbite." }, - "gen4": { "name": "Moissonneur d'astéroïdes", "desc": "Avale des astéroïdes entiers et raffine les restes." }, - "gen5": { "name": "Essaim-ruche", "desc": "Des millions de drones qui pensent comme un seul." }, - "gen6": { "name": "Briseur de planètes", "desc": "Ouvre un monde comme une noix." } + "gen1": { + "name": "Drone mineur", + "desc": "Un minuscule robot qui gratte la roche." + }, + "gen2": { + "name": "Foreuse à plasma", + "desc": "Fond la roche comme du beurre." + }, + "gen3": { + "name": "Extracteur orbital", + "desc": "Aspire le minerai de la croûte depuis l'orbite." + }, + "gen4": { + "name": "Moissonneur d'astéroïdes", + "desc": "Avale des astéroïdes entiers et raffine les restes." + }, + "gen5": { + "name": "Essaim-ruche", + "desc": "Des millions de drones qui pensent comme un seul." + }, + "gen6": { + "name": "Briseur de planètes", + "desc": "Ouvre un monde comme une noix." + } }, "upg": { "click_1": "Gants gravitationnels", @@ -50,6 +82,38 @@ "stack_4": "Flottes de convoi", "stack_5": "Stock orbital", "auto_buy": "Cargo autonome" + }, + "tab": { + "gamble": "Oracle xéno" + }, + "inv": { + "inv_low_1": { + "name": "Coopérative minière Zêta" + }, + "inv_low_2": { + "name": "Fonds du minerai d'Orion" + }, + "inv_low_3": { + "name": "Obligation Raffinerie lunaire" + }, + "inv_mid_1": { + "name": "XenoCorp Industries" + }, + "inv_mid_2": { + "name": "Extraction Nébuleuse SARL" + }, + "inv_mid_3": { + "name": "Syndicat du fret du Vide" + }, + "inv_high_1": { + "name": "Labo de la Faille Hypérion" + }, + "inv_high_2": { + "name": "Récupération Singularité" + }, + "inv_high_3": { + "name": "Trou Noir Capital" + } } } } diff --git a/public/themes/alien/theme.css b/public/themes/alien/theme.css index f11c7a6..e664b0e 100644 --- a/public/themes/alien/theme.css +++ b/public/themes/alien/theme.css @@ -10,6 +10,8 @@ --accent: #2de2a6; --accent-contrast: #032119; --danger: #ff6b6b; + --good: #2de2a6; + --bad: #ff6b6b; --radius: 4px; --font: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace; } diff --git a/public/themes/computers/locales/en.json b/public/themes/computers/locales/en.json index 68eafe4..42b20ee 100644 --- a/public/themes/computers/locales/en.json +++ b/public/themes/computers/locales/en.json @@ -2,16 +2,48 @@ "theme": { "name": "Computers & GalliumCoin", "title": "GalliumCoin Mining Lab", - "res": { "units": "CPU Power", "unit": "cycle", "funds": "GalliumCoin", "material": "Energy (W)" }, - "fmt": { "funds": "{value} GAL" }, - "action": { "make": "Run a computation", "buyMaterial": "Buy energy" }, + "res": { + "units": "CPU Power", + "unit": "cycle", + "funds": "GalliumCoin", + "material": "Energy (W)", + "chips": "Qubits", + "research": "Algorithms", + "legacy": "Legacy Code" + }, + "fmt": { + "funds": "{value} GAL" + }, + "action": { + "make": "Run a computation", + "buyMaterial": "Buy energy", + "gamble": "Run the circuit" + }, "gen": { - "gen1": { "name": "Old Laptop", "desc": "Fans screaming, but it still crunches numbers." }, - "gen2": { "name": "Gaming PC", "desc": "The RGB lighting adds at least 10% more power." }, - "gen3": { "name": "GPU Mining Rig", "desc": "Six graphics cards and a prayer." }, - "gen4": { "name": "Server Rack", "desc": "Forty-two units of blinking lights." }, - "gen5": { "name": "Data Center", "desc": "A warehouse of racks and an industrial cooling plant." }, - "gen6": { "name": "Quantum Mainframe", "desc": "Computes every possibility at once." } + "gen1": { + "name": "Old Laptop", + "desc": "Fans screaming, but it still crunches numbers." + }, + "gen2": { + "name": "Gaming PC", + "desc": "The RGB lighting adds at least 10% more power." + }, + "gen3": { + "name": "GPU Mining Rig", + "desc": "Six graphics cards and a prayer." + }, + "gen4": { + "name": "Server Rack", + "desc": "Forty-two units of blinking lights." + }, + "gen5": { + "name": "Data Center", + "desc": "A warehouse of racks and an industrial cooling plant." + }, + "gen6": { + "name": "Quantum Mainframe", + "desc": "Computes every possibility at once." + } }, "upg": { "click_1": "Mechanical Keyboard", @@ -50,6 +82,38 @@ "stack_4": "Dedicated Power Line", "stack_5": "Private Power Plant", "auto_buy": "Smart Power Meter" + }, + "tab": { + "gamble": "Quantum Rig" + }, + "inv": { + "inv_low_1": { + "name": "Stable Silicon Bank" + }, + "inv_low_2": { + "name": "ByteSafe Trust" + }, + "inv_low_3": { + "name": "Mainframe Heritage Fund" + }, + "inv_mid_1": { + "name": "NeoChip Holdings" + }, + "inv_mid_2": { + "name": "CloudNine Hosting" + }, + "inv_mid_3": { + "name": "Packet Dynamics" + }, + "inv_high_1": { + "name": "MoonShot Mining DAO" + }, + "inv_high_2": { + "name": "HypeCoin Labs" + }, + "inv_high_3": { + "name": "Quantum Leap Capital" + } } } } diff --git a/public/themes/computers/locales/fr.json b/public/themes/computers/locales/fr.json index 7783712..68cada3 100644 --- a/public/themes/computers/locales/fr.json +++ b/public/themes/computers/locales/fr.json @@ -2,16 +2,48 @@ "theme": { "name": "Ordinateurs et GalliumCoin", "title": "Laboratoire de minage GalliumCoin", - "res": { "units": "Puissance CPU", "unit": "cycle", "funds": "GalliumCoin", "material": "Énergie (W)" }, - "fmt": { "funds": "{value} GAL" }, - "action": { "make": "Lancer un calcul", "buyMaterial": "Acheter de l'énergie" }, + "res": { + "units": "Puissance CPU", + "unit": "cycle", + "funds": "GalliumCoin", + "material": "Énergie (W)", + "chips": "Qubits", + "research": "Algorithmes", + "legacy": "Code hérité" + }, + "fmt": { + "funds": "{value} GAL" + }, + "action": { + "make": "Lancer un calcul", + "buyMaterial": "Acheter de l'énergie", + "gamble": "Lancer le circuit" + }, "gen": { - "gen1": { "name": "Vieux portable", "desc": "Les ventilateurs hurlent, mais il calcule encore." }, - "gen2": { "name": "PC gamer", "desc": "Les LED RGB ajoutent au moins 10 % de puissance." }, - "gen3": { "name": "Rig de minage GPU", "desc": "Six cartes graphiques et une prière." }, - "gen4": { "name": "Baie de serveurs", "desc": "Quarante-deux unités de lumières clignotantes." }, - "gen5": { "name": "Centre de données", "desc": "Un entrepôt de baies et une centrale de refroidissement industrielle." }, - "gen6": { "name": "Mainframe quantique", "desc": "Calcule toutes les possibilités à la fois." } + "gen1": { + "name": "Vieux portable", + "desc": "Les ventilateurs hurlent, mais il calcule encore." + }, + "gen2": { + "name": "PC gamer", + "desc": "Les LED RGB ajoutent au moins 10 % de puissance." + }, + "gen3": { + "name": "Rig de minage GPU", + "desc": "Six cartes graphiques et une prière." + }, + "gen4": { + "name": "Baie de serveurs", + "desc": "Quarante-deux unités de lumières clignotantes." + }, + "gen5": { + "name": "Centre de données", + "desc": "Un entrepôt de baies et une centrale de refroidissement industrielle." + }, + "gen6": { + "name": "Mainframe quantique", + "desc": "Calcule toutes les possibilités à la fois." + } }, "upg": { "click_1": "Clavier mécanique", @@ -50,6 +82,38 @@ "stack_4": "Ligne électrique dédiée", "stack_5": "Centrale privée", "auto_buy": "Compteur intelligent" + }, + "tab": { + "gamble": "Rig quantique" + }, + "inv": { + "inv_low_1": { + "name": "Banque Silicium Stable" + }, + "inv_low_2": { + "name": "ByteSafe Trust" + }, + "inv_low_3": { + "name": "Fonds du Patrimoine Mainframe" + }, + "inv_mid_1": { + "name": "NeoChip Holdings" + }, + "inv_mid_2": { + "name": "CloudNine Hébergement" + }, + "inv_mid_3": { + "name": "Packet Dynamics" + }, + "inv_high_1": { + "name": "MoonShot Mining DAO" + }, + "inv_high_2": { + "name": "HypeCoin Labs" + }, + "inv_high_3": { + "name": "Quantum Leap Capital" + } } } } diff --git a/public/themes/computers/theme.css b/public/themes/computers/theme.css index 6795e7b..7993684 100644 --- a/public/themes/computers/theme.css +++ b/public/themes/computers/theme.css @@ -10,6 +10,8 @@ --accent: #1f6feb; --accent-contrast: #ffffff; --danger: #f85149; + --good: #3fb950; + --bad: #ff6b6b; --radius: 6px; --font: 'Segoe UI', Inter, system-ui, -apple-system, sans-serif; } diff --git a/public/themes/insects/locales/en.json b/public/themes/insects/locales/en.json index 972ab33..3d49129 100644 --- a/public/themes/insects/locales/en.json +++ b/public/themes/insects/locales/en.json @@ -2,16 +2,48 @@ "theme": { "name": "Leaf-Piling Insects", "title": "Leaf Pile Colony", - "res": { "units": "Leaves", "unit": "leaf", "funds": "Honeydew", "material": "Silk" }, - "fmt": { "funds": "{value} drops" }, - "action": { "make": "Cut a leaf", "buyMaterial": "Trade for silk" }, + "res": { + "units": "Leaves", + "unit": "leaf", + "funds": "Honeydew", + "material": "Silk", + "chips": "Pollen Grains", + "research": "Instinct", + "legacy": "Colony Memories" + }, + "fmt": { + "funds": "{value} drops" + }, + "action": { + "make": "Cut a leaf", + "buyMaterial": "Trade for silk", + "gamble": "Start the race" + }, "gen": { - "gen1": { "name": "Worker Ant", "desc": "Carries a leaf many times its own size." }, - "gen2": { "name": "Leafcutter Squad", "desc": "Slices, hauls and stacks in perfect formation." }, - "gen3": { "name": "Beetle Hauler", "desc": "Armoured carrier that shifts whole piles." }, - "gen4": { "name": "Termite Mound", "desc": "A towering colony that never stops building." }, - "gen5": { "name": "Locust Swarm", "desc": "Strips whole trees in an afternoon." }, - "gen6": { "name": "Hive Queen", "desc": "Her pheromones coordinate millions of workers." } + "gen1": { + "name": "Worker Ant", + "desc": "Carries a leaf many times its own size." + }, + "gen2": { + "name": "Leafcutter Squad", + "desc": "Slices, hauls and stacks in perfect formation." + }, + "gen3": { + "name": "Beetle Hauler", + "desc": "Armoured carrier that shifts whole piles." + }, + "gen4": { + "name": "Termite Mound", + "desc": "A towering colony that never stops building." + }, + "gen5": { + "name": "Locust Swarm", + "desc": "Strips whole trees in an afternoon." + }, + "gen6": { + "name": "Hive Queen", + "desc": "Her pheromones coordinate millions of workers." + } }, "upg": { "click_1": "Sharper Mandibles", @@ -50,6 +82,38 @@ "stack_4": "Spider Caravans", "stack_5": "Great Silk Hoard", "auto_buy": "Spider Delivery Service" + }, + "tab": { + "gamble": "Beetle Derby" + }, + "inv": { + "inv_low_1": { + "name": "Anthill Savings Bank" + }, + "inv_low_2": { + "name": "Honeycomb Trust" + }, + "inv_low_3": { + "name": "Leafy Greens Co-op" + }, + "inv_mid_1": { + "name": "Dung Beetle Logistics Ltd" + }, + "inv_mid_2": { + "name": "Termite Tower Builders" + }, + "inv_mid_3": { + "name": "Cricket Courier Corp" + }, + "inv_high_1": { + "name": "Locust Swarm Capital" + }, + "inv_high_2": { + "name": "Wasp Venture Fund" + }, + "inv_high_3": { + "name": "Mantis Ambush Futures" + } } } } diff --git a/public/themes/insects/locales/fr.json b/public/themes/insects/locales/fr.json index 3f2dc6b..c3de3c8 100644 --- a/public/themes/insects/locales/fr.json +++ b/public/themes/insects/locales/fr.json @@ -2,16 +2,48 @@ "theme": { "name": "Insectes et tas de feuilles", "title": "Colonie des tas de feuilles", - "res": { "units": "Feuilles", "unit": "feuille", "funds": "Miellat", "material": "Soie" }, - "fmt": { "funds": "{value} gouttes" }, - "action": { "make": "Couper une feuille", "buyMaterial": "Échanger contre de la soie" }, + "res": { + "units": "Feuilles", + "unit": "feuille", + "funds": "Miellat", + "material": "Soie", + "chips": "Grains de pollen", + "research": "Instinct", + "legacy": "Souvenirs de la colonie" + }, + "fmt": { + "funds": "{value} gouttes" + }, + "action": { + "make": "Couper une feuille", + "buyMaterial": "Échanger contre de la soie", + "gamble": "Donner le départ" + }, "gen": { - "gen1": { "name": "Fourmi ouvrière", "desc": "Porte une feuille bien plus grosse qu'elle." }, - "gen2": { "name": "Escouade coupeuse", "desc": "Coupe, transporte et empile en formation parfaite." }, - "gen3": { "name": "Scarabée porteur", "desc": "Porteur blindé qui déplace des tas entiers." }, - "gen4": { "name": "Termitière", "desc": "Une colonie immense qui ne cesse de bâtir." }, - "gen5": { "name": "Nuée de criquets", "desc": "Dépouille des arbres entiers en un après-midi." }, - "gen6": { "name": "Reine de la ruche", "desc": "Ses phéromones coordonnent des millions d'ouvrières." } + "gen1": { + "name": "Fourmi ouvrière", + "desc": "Porte une feuille bien plus grosse qu'elle." + }, + "gen2": { + "name": "Escouade coupeuse", + "desc": "Coupe, transporte et empile en formation parfaite." + }, + "gen3": { + "name": "Scarabée porteur", + "desc": "Porteur blindé qui déplace des tas entiers." + }, + "gen4": { + "name": "Termitière", + "desc": "Une colonie immense qui ne cesse de bâtir." + }, + "gen5": { + "name": "Nuée de criquets", + "desc": "Dépouille des arbres entiers en un après-midi." + }, + "gen6": { + "name": "Reine de la ruche", + "desc": "Ses phéromones coordonnent des millions d'ouvrières." + } }, "upg": { "click_1": "Mandibules aiguisées", @@ -50,6 +82,38 @@ "stack_4": "Caravanes d'araignées", "stack_5": "Grand trésor de soie", "auto_buy": "Livraison par araignée" + }, + "tab": { + "gamble": "Course de scarabées" + }, + "inv": { + "inv_low_1": { + "name": "Caisse d'épargne de la Fourmilière" + }, + "inv_low_2": { + "name": "Fonds du Rayon de miel" + }, + "inv_low_3": { + "name": "Coopérative des Feuilles vertes" + }, + "inv_mid_1": { + "name": "Logistique des Bousiers SARL" + }, + "inv_mid_2": { + "name": "Bâtisseurs de Tours de termites" + }, + "inv_mid_3": { + "name": "Courrier Criquet SA" + }, + "inv_high_1": { + "name": "Nuée de criquets Capital" + }, + "inv_high_2": { + "name": "Fonds de capital-risque Guêpe" + }, + "inv_high_3": { + "name": "Affûts de Mante à terme" + } } } } diff --git a/public/themes/insects/theme.css b/public/themes/insects/theme.css index 6a91385..efa5f28 100644 --- a/public/themes/insects/theme.css +++ b/public/themes/insects/theme.css @@ -10,6 +10,8 @@ --accent: #2f7d32; --accent-contrast: #ffffff; --danger: #b3412a; + --good: #2f7d32; + --bad: #c0392b; --radius: 12px; --font: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif; } diff --git a/public/themes/paperclip/locales/en.json b/public/themes/paperclip/locales/en.json index ddd68ab..cbd0b68 100644 --- a/public/themes/paperclip/locales/en.json +++ b/public/themes/paperclip/locales/en.json @@ -2,16 +2,48 @@ "theme": { "name": "Paperclips", "title": "Paperclip Factory", - "res": { "units": "Paperclips", "unit": "paperclip", "funds": "Funds", "material": "Wire" }, - "fmt": { "funds": "${value}" }, - "action": { "make": "Make a paperclip", "buyMaterial": "Buy wire" }, + "res": { + "units": "Paperclips", + "unit": "paperclip", + "funds": "Funds", + "material": "Wire", + "chips": "Quantum Chips", + "research": "R&D Points", + "legacy": "Patents" + }, + "fmt": { + "funds": "${value}" + }, + "action": { + "make": "Make a paperclip", + "buyMaterial": "Buy wire", + "gamble": "Run a quantum cycle" + }, "gen": { - "gen1": { "name": "Autoclipper", "desc": "A small machine that bends wire on its own." }, - "gen2": { "name": "MegaClipper", "desc": "Ten times the appetite for wire." }, - "gen3": { "name": "Clip Factory", "desc": "A whole building devoted to paperclips." }, - "gen4": { "name": "Hypno Drones", "desc": "They convince everyone that they need more paperclips." }, - "gen5": { "name": "Probe Swarm", "desc": "Self-replicating probes fold wire across the galaxy." }, - "gen6": { "name": "Matter Converter", "desc": "Turns anything into paperclips. Anything." } + "gen1": { + "name": "Autoclipper", + "desc": "A small machine that bends wire on its own." + }, + "gen2": { + "name": "MegaClipper", + "desc": "Ten times the appetite for wire." + }, + "gen3": { + "name": "Clip Factory", + "desc": "A whole building devoted to paperclips." + }, + "gen4": { + "name": "Hypno Drones", + "desc": "They convince everyone that they need more paperclips." + }, + "gen5": { + "name": "Probe Swarm", + "desc": "Self-replicating probes fold wire across the galaxy." + }, + "gen6": { + "name": "Matter Converter", + "desc": "Turns anything into paperclips. Anything." + } }, "upg": { "click_1": "Ergonomic Bender", @@ -50,6 +82,38 @@ "stack_4": "Freight Trains", "stack_5": "Wire Mills", "auto_buy": "WireBuyer" + }, + "tab": { + "gamble": "Quantum Computer" + }, + "inv": { + "inv_low_1": { + "name": "Steady Staples Inc." + }, + "inv_low_2": { + "name": "Office Supply Trust" + }, + "inv_low_3": { + "name": "Copper Wire Bond" + }, + "inv_mid_1": { + "name": "Binder Dynamics" + }, + "inv_mid_2": { + "name": "Clip Logistics" + }, + "inv_mid_3": { + "name": "Stationery Futures" + }, + "inv_high_1": { + "name": "NanoWire Labs" + }, + "inv_high_2": { + "name": "Paperless Dreams Ltd" + }, + "inv_high_3": { + "name": "Hyper-Clip Ventures" + } } } } diff --git a/public/themes/paperclip/locales/fr.json b/public/themes/paperclip/locales/fr.json index f97307d..c0e0fae 100644 --- a/public/themes/paperclip/locales/fr.json +++ b/public/themes/paperclip/locales/fr.json @@ -2,16 +2,48 @@ "theme": { "name": "Trombones", "title": "Usine à trombones", - "res": { "units": "Trombones", "unit": "trombone", "funds": "Fonds", "material": "Fil" }, - "fmt": { "funds": "{value} $" }, - "action": { "make": "Fabriquer un trombone", "buyMaterial": "Acheter du fil" }, + "res": { + "units": "Trombones", + "unit": "trombone", + "funds": "Fonds", + "material": "Fil", + "chips": "Puces quantiques", + "research": "Points de R&D", + "legacy": "Brevets" + }, + "fmt": { + "funds": "{value} $" + }, + "action": { + "make": "Fabriquer un trombone", + "buyMaterial": "Acheter du fil", + "gamble": "Lancer un cycle quantique" + }, "gen": { - "gen1": { "name": "Autotrombone", "desc": "Une petite machine qui plie le fil toute seule." }, - "gen2": { "name": "MégaTrombone", "desc": "Dix fois plus gourmande en fil." }, - "gen3": { "name": "Usine à trombones", "desc": "Un bâtiment entier dédié aux trombones." }, - "gen4": { "name": "Drones hypnotiques", "desc": "Ils convainquent tout le monde d'avoir besoin de trombones." }, - "gen5": { "name": "Essaim de sondes", "desc": "Des sondes auto-répliquantes plient du fil à travers la galaxie." }, - "gen6": { "name": "Convertisseur de matière", "desc": "Transforme n'importe quoi en trombones. N'importe quoi." } + "gen1": { + "name": "Autotrombone", + "desc": "Une petite machine qui plie le fil toute seule." + }, + "gen2": { + "name": "MégaTrombone", + "desc": "Dix fois plus gourmande en fil." + }, + "gen3": { + "name": "Usine à trombones", + "desc": "Un bâtiment entier dédié aux trombones." + }, + "gen4": { + "name": "Drones hypnotiques", + "desc": "Ils convainquent tout le monde d'avoir besoin de trombones." + }, + "gen5": { + "name": "Essaim de sondes", + "desc": "Des sondes auto-répliquantes plient du fil à travers la galaxie." + }, + "gen6": { + "name": "Convertisseur de matière", + "desc": "Transforme n'importe quoi en trombones. N'importe quoi." + } }, "upg": { "click_1": "Plieuse ergonomique", @@ -50,6 +82,38 @@ "stack_4": "Trains de marchandises", "stack_5": "Tréfileries", "auto_buy": "AcheteurDeFil" + }, + "tab": { + "gamble": "Ordinateur quantique" + }, + "inv": { + "inv_low_1": { + "name": "Agrafes Tranquilles SA" + }, + "inv_low_2": { + "name": "Fonds des fournitures de bureau" + }, + "inv_low_3": { + "name": "Obligation Fil de cuivre" + }, + "inv_mid_1": { + "name": "Classeur Dynamique" + }, + "inv_mid_2": { + "name": "Logistique Trombone" + }, + "inv_mid_3": { + "name": "Papeterie à terme" + }, + "inv_high_1": { + "name": "NanoFil Labo" + }, + "inv_high_2": { + "name": "Rêves sans papier SARL" + }, + "inv_high_3": { + "name": "Hyper-Trombone Capital" + } } } } diff --git a/public/themes/paperclip/theme.css b/public/themes/paperclip/theme.css index 72e3e1a..7a64ac4 100644 --- a/public/themes/paperclip/theme.css +++ b/public/themes/paperclip/theme.css @@ -10,6 +10,8 @@ --accent: #1e6fd9; --accent-contrast: #ffffff; --danger: #c0392b; + --good: #1a7f4b; + --bad: #c0392b; --radius: 8px; --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } diff --git a/public/themes/witch/locales/en.json b/public/themes/witch/locales/en.json index 20981de..1ac1e3f 100644 --- a/public/themes/witch/locales/en.json +++ b/public/themes/witch/locales/en.json @@ -2,16 +2,48 @@ "theme": { "name": "Witch Potions", "title": "Hedge Witch Brewery", - "res": { "units": "Potions", "unit": "potion", "funds": "Gold", "material": "Herbs" }, - "fmt": { "funds": "{value} gold" }, - "action": { "make": "Brew a potion", "buyMaterial": "Gather herbs" }, + "res": { + "units": "Potions", + "unit": "potion", + "funds": "Gold", + "material": "Herbs", + "chips": "Fate Tokens", + "research": "Arcane Insight", + "legacy": "Grimoire Pages" + }, + "fmt": { + "funds": "{value} gold" + }, + "action": { + "make": "Brew a potion", + "buyMaterial": "Gather herbs", + "gamble": "Read the future" + }, "gen": { - "gen1": { "name": "Apprentice", "desc": "Stirs, sweeps and occasionally sets things on fire." }, - "gen2": { "name": "Bubbling Cauldron", "desc": "Brews day and night without complaint." }, - "gen3": { "name": "Alchemy Lab", "desc": "Glassware, grimoires and a faint smell of sulphur." }, - "gen4": { "name": "Broomstick Fleet", "desc": "Delivers herbs and potions faster than any cart." }, - "gen5": { "name": "Moonlit Coven", "desc": "Thirteen witches, one very large cauldron." }, - "gen6": { "name": "Philosopher's Engine", "desc": "Turns lead, dreams and moonlight into potions." } + "gen1": { + "name": "Apprentice", + "desc": "Stirs, sweeps and occasionally sets things on fire." + }, + "gen2": { + "name": "Bubbling Cauldron", + "desc": "Brews day and night without complaint." + }, + "gen3": { + "name": "Alchemy Lab", + "desc": "Glassware, grimoires and a faint smell of sulphur." + }, + "gen4": { + "name": "Broomstick Fleet", + "desc": "Delivers herbs and potions faster than any cart." + }, + "gen5": { + "name": "Moonlit Coven", + "desc": "Thirteen witches, one very large cauldron." + }, + "gen6": { + "name": "Philosopher's Engine", + "desc": "Turns lead, dreams and moonlight into potions." + } }, "upg": { "click_1": "Steady Stirring", @@ -50,6 +82,38 @@ "stack_4": "Greenhouse Harvest", "stack_5": "Whole-Forest Rights", "auto_buy": "Self-Gathering Basket" + }, + "tab": { + "gamble": "Crystal Ball" + }, + "inv": { + "inv_low_1": { + "name": "Old Hollow Apothecary" + }, + "inv_low_2": { + "name": "Greywood Herb Co-op" + }, + "inv_low_3": { + "name": "Village Cauldron Guild" + }, + "inv_mid_1": { + "name": "Moonpetal Brewers" + }, + "inv_mid_2": { + "name": "Nightshade & Sons" + }, + "inv_mid_3": { + "name": "Crooked Broom Trading" + }, + "inv_high_1": { + "name": "Dragonscale Elixirs" + }, + "inv_high_2": { + "name": "Wyrmwood Potions Ltd" + }, + "inv_high_3": { + "name": "Forbidden Brew Cartel" + } } } } diff --git a/public/themes/witch/locales/fr.json b/public/themes/witch/locales/fr.json index 11ff17c..2401046 100644 --- a/public/themes/witch/locales/fr.json +++ b/public/themes/witch/locales/fr.json @@ -2,16 +2,48 @@ "theme": { "name": "Potions de sorcière", "title": "Brasserie de la sorcière", - "res": { "units": "Potions", "unit": "potion", "funds": "Or", "material": "Herbes" }, - "fmt": { "funds": "{value} or" }, - "action": { "make": "Concocter une potion", "buyMaterial": "Cueillir des herbes" }, + "res": { + "units": "Potions", + "unit": "potion", + "funds": "Or", + "material": "Herbes", + "chips": "Jetons du destin", + "research": "Savoir arcanique", + "legacy": "Pages de grimoire" + }, + "fmt": { + "funds": "{value} or" + }, + "action": { + "make": "Concocter une potion", + "buyMaterial": "Cueillir des herbes", + "gamble": "Lire l'avenir" + }, "gen": { - "gen1": { "name": "Apprentie", "desc": "Remue, balaie et met parfois le feu à quelque chose." }, - "gen2": { "name": "Chaudron bouillonnant", "desc": "Infuse jour et nuit sans se plaindre." }, - "gen3": { "name": "Laboratoire d'alchimie", "desc": "Verrerie, grimoires et une légère odeur de soufre." }, - "gen4": { "name": "Escadrille de balais", "desc": "Livre herbes et potions plus vite que n'importe quelle charrette." }, - "gen5": { "name": "Sabbat sous la lune", "desc": "Treize sorcières, un seul très grand chaudron." }, - "gen6": { "name": "Machine du philosophe", "desc": "Transforme plomb, rêves et clair de lune en potions." } + "gen1": { + "name": "Apprentie", + "desc": "Remue, balaie et met parfois le feu à quelque chose." + }, + "gen2": { + "name": "Chaudron bouillonnant", + "desc": "Infuse jour et nuit sans se plaindre." + }, + "gen3": { + "name": "Laboratoire d'alchimie", + "desc": "Verrerie, grimoires et une légère odeur de soufre." + }, + "gen4": { + "name": "Escadrille de balais", + "desc": "Livre herbes et potions plus vite que n'importe quelle charrette." + }, + "gen5": { + "name": "Sabbat sous la lune", + "desc": "Treize sorcières, un seul très grand chaudron." + }, + "gen6": { + "name": "Machine du philosophe", + "desc": "Transforme plomb, rêves et clair de lune en potions." + } }, "upg": { "click_1": "Brassage régulier", @@ -50,6 +82,38 @@ "stack_4": "Récolte en serre", "stack_5": "Droits sur toute la forêt", "auto_buy": "Panier qui cueille tout seul" + }, + "tab": { + "gamble": "Boule de cristal" + }, + "inv": { + "inv_low_1": { + "name": "Apothicairerie du Vieux Creux" + }, + "inv_low_2": { + "name": "Coopérative des herbes de Grisebois" + }, + "inv_low_3": { + "name": "Guilde des chaudrons du village" + }, + "inv_mid_1": { + "name": "Brasseurs de Pétale-de-Lune" + }, + "inv_mid_2": { + "name": "Belladone & Fils" + }, + "inv_mid_3": { + "name": "Commerce du Balai tordu" + }, + "inv_high_1": { + "name": "Élixirs Écaille-de-Dragon" + }, + "inv_high_2": { + "name": "Potions Bois-de-Wyrm SARL" + }, + "inv_high_3": { + "name": "Cartel de la Brassée interdite" + } } } } diff --git a/public/themes/witch/theme.css b/public/themes/witch/theme.css index 6ddeb7e..428f639 100644 --- a/public/themes/witch/theme.css +++ b/public/themes/witch/theme.css @@ -10,6 +10,8 @@ --accent: #9333ea; --accent-contrast: #ffffff; --danger: #e0526a; + --good: #5fd38d; + --bad: #ff6b6b; --radius: 14px; --font: Georgia, 'Times New Roman', serif; } diff --git a/tests/content.test.js b/tests/content.test.js index 0ccb750..76339c3 100644 --- a/tests/content.test.js +++ b/tests/content.test.js @@ -2,7 +2,7 @@ // missing translation is caught by `npm test` instead of showing a raw key. import assert from 'node:assert/strict'; -import { readdirSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { test } from 'node:test'; import { flatKeys, loadContext, PUBLIC_DIR, readJson } from './helpers.js'; @@ -49,6 +49,12 @@ test('every theme names every resource, generator and upgrade, in every language 'theme.action.buyMaterial', ...ctx.generators.flatMap((g) => [`theme.gen.${g.id}.name`, `theme.gen.${g.id}.desc`]), ...ctx.upgrades.map((u) => `theme.upg.${u.id}`), + 'theme.tab.gamble', + 'theme.action.gamble', + 'theme.res.chips', + 'theme.res.research', + 'theme.res.legacy', + ...ctx.investments.societies.map((s) => `theme.inv.${s.id}.name`), ]; for (const id of themes) { @@ -68,3 +74,67 @@ test('every theme is translated in every core language', () => { } } }); + +// ------------------------------------------------- the new systems' vocabulary + +const coreKeys = new Set(flatKeys(readJson('locales/en.json'))); +const coreHas = (key) => coreKeys.has(key); + +test('every effect type used anywhere has a text', () => { + const effects = [ + ...ctx.upgrades.map((u) => u.effect), + ...ctx.research.map((r) => r.effect), + ...ctx.legacy.map((p) => p.effect), + ...ctx.achievements.map((a) => a.effect), + ]; + for (const e of effects) assert.ok(coreHas(`effect.${e.type}`), `effect.${e.type}`); +}); + +test('research, perks, achievements, events, contracts and tiers all have core texts', () => { + const need = [ + ...ctx.research.map((r) => `research.node.${r.id}`), + ...[...new Set(ctx.research.map((r) => r.branch))].map((b) => `research.branch.${b}`), + ...ctx.legacy.map((p) => `legacy.perk.${p.id}`), + ...ctx.achievements.map((a) => `ach.title.${a.id}`), + ...[...new Set(ctx.achievements.map((a) => a.condition.stat))].map((s) => `ach.cond.${s}`), + ...ctx.rules.events.types.flatMap((e) => [`event.${e.id}.title`, `event.${e.id}.text`]), + ...ctx.rules.contracts.templates.map((c) => `contracts.template.${c.id}`), + ...ctx.rules.contracts.rewards.map((r) => `contracts.reward.${r}`), + ...Object.keys(ctx.investments.tiers).map((tier) => `invest.tier.${tier}`), + ...['win', 'jackpot', 'lose', 'crash'].map((o) => `gamble.result.${o}`), + ]; + for (const key of need) assert.ok(coreHas(key), key); +}); + +test('every section has a label (core, or themed for the gamble)', () => { + for (const tab of ctx.tabs) { + assert.ok(coreHas(`tab.${tab.id}`) || tab.id === 'gamble', `tab.${tab.id}`); + } +}); + +test('every key used by the code and the page exists', () => { + const themeKeys = new Set(flatKeys(readJson('themes/paperclip/locales/en.json'))); + const known = (key) => coreKeys.has(key) || themeKeys.has(key); + + 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')) { + for (const m of readFileSync(path, 'utf8').matchAll(/\bt\(\s*'([\w.]+)'/g)) used.add(m[1]); + } + } + }; + walk(`${PUBLIC_DIR}js`); + + const html = readFileSync(`${PUBLIC_DIR}index.html`, 'utf8'); + for (const m of html.matchAll(/data-i18n="([\w.]+)"/g)) used.add(m[1]); + for (const m of html.matchAll(/data-i18n-attr="([^"]+)"/g)) { + for (const pair of m[1].split(';')) used.add(pair.split(':')[1].trim()); + } + + const missing = [...used].filter((key) => !known(key)); + assert.deepEqual(missing, [], `texts used but not defined: ${missing.join(', ')}`); + assert.ok(used.size > 100, 'the scan found the texts'); +}); diff --git a/tests/engine.test.js b/tests/engine.test.js index f6f273a..946ce66 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -311,10 +311,3 @@ test('state survives a serialize / sanitize round trip', () => { assert.equal(back.marketing, 3); }); -test('the full save of a late game state is small enough for cookies', () => { - const s = fresh(); - s.funds = 123456789.123456; - for (const g of ctx.generators) s.generators[g.id] = 2500; - s.upgrades = ctx.upgrades.map((u) => u.id); - assert.ok(serializeState(s).length < 1500); -}); diff --git a/tests/helpers.js b/tests/helpers.js index 287f9a6..3566e48 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -1,17 +1,15 @@ 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() { - return buildContext({ - rules: readJson('data/rules.json'), - generators: readJson('data/generators.json'), - upgrades: readJson('data/upgrades.json'), - }); + const entries = Object.entries(DATA_FILES).map(([key, file]) => [key, readJson(`data/${file}.json`)]); + return buildContext(Object.fromEntries(entries)); } /** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */ diff --git a/tests/systems.test.js b/tests/systems.test.js new file mode 100644 index 0000000..44b3cf1 --- /dev/null +++ b/tests/systems.test.js @@ -0,0 +1,579 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkAchievements } from '../public/js/engine/achievements.js'; +import { abandonContract, acceptContract, claimEvent, stepEvents } from '../public/js/engine/contracts.js'; +import { advance, derive, simulateOffline } from '../public/js/engine/engine.js'; +import { chipDemandBonus } from '../public/js/engine/formulas.js'; +import { + buyChip, + buyReliability, + chipCost, + gamble, + odds, + reliabilityCost, +} from '../public/js/engine/gamble.js'; +import { buyShares, holdingValue, portfolio, sellShares } from '../public/js/engine/investments.js'; +import { buyPerk, legacyGain, perkCost, 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, sanitizeState, serializeState } from '../public/js/engine/state.js'; +import { loadContext } from './helpers.js'; + +const ctx = loadContext(); +const fresh = () => createState(ctx, 1_000_000); +const mods = (s) => modifiersFor(s, ctx); +const seeded = (seed) => () => (seed = (seed * 16807) % 2147483647) / 2147483647; +const constant = (v) => () => v; + +// ------------------------------------------------------------------ investments + +test('investments reprice every 60 s, within the tier swing, and pay dividends', () => { + const s = fresh(); + s.invest.holdings.inv_low_1 = 10; + s.invest.basis.inv_low_1 = 1000; + const funds = s.funds; + advance(s, ctx, 59, constant(1)); + assert.equal(s.invest.prices.inv_low_1, 100, 'nothing before the interval'); + advance(s, ctx, 1, constant(1)); + + const swing = ctx.investments.tiers.low.swing; + const reversion = ctx.investments.tiers.low.reversion; + const expected = 100 * (1 + swing) + (100 - 100 * (1 + swing)) * reversion; + assert.ok(Math.abs(s.invest.prices.inv_low_1 - expected) < 1e-9, 'top of the ±5 % swing, pulled back a little'); + assert.ok(s.funds > funds, 'dividend paid'); +}); + +test('each tier swings as widely as advertised (±5 %, ±15 %, ±50 %)', () => { + const widest = {}; + const s = fresh(); + const rng = seeded(11); + for (let i = 0; i < 3000; i++) { + for (const soc of ctx.investments.societies) s.invest.prices[soc.id] = soc.basePrice; + advance(s, ctx, 60, rng); + for (const soc of ctx.investments.societies) { + widest[soc.tier] = Math.max(widest[soc.tier] ?? 0, Math.abs(s.invest.change[soc.id])); + } + } + for (const [tier, { swing }] of Object.entries(ctx.investments.tiers)) { + assert.ok(widest[tier] <= swing + 1e-9, `${tier} never exceeds its swing`); + assert.ok(widest[tier] > swing * 0.9, `${tier} does use its range`); + } +}); + +test('prices stay inside their bounds over a long time', () => { + const s = fresh(); + advance(s, ctx, 60 * 3000, seeded(5)); + for (const soc of ctx.investments.societies) { + const price = s.invest.prices[soc.id]; + assert.ok(price >= soc.basePrice * ctx.investments.minRatio - 1e-9); + assert.ok(price <= soc.basePrice * ctx.investments.maxRatio + 1e-9); + } +}); + +test('buying and selling shares costs the fee and tracks profit', () => { + const s = fresh(); + s.funds = 1000; + assert.ok(buyShares(s, ctx, 'inv_mid_1', 0.5)); + assert.equal(s.funds, 500); + const fee = ctx.investments.fee; + assert.ok(Math.abs(holdingValue(s, 'inv_mid_1') - 500 * (1 - fee)) < 1e-9); + assert.ok(sellShares(s, ctx, 'inv_mid_1', 1)); + assert.ok(Math.abs(s.funds - (500 + 500 * (1 - fee) ** 2)) < 1e-9, 'round trip costs about 1 %'); + assert.ok(s.stats.investProfit < 0, 'the fees show as a realized loss'); + assert.equal(s.invest.holdings.inv_mid_1, 0); + assert.equal(portfolio(s, ctx).value, 0); +}); + +test('investing needs funds and a real society', () => { + const s = fresh(); + assert.equal(buyShares(s, ctx, 'inv_low_1', 1), false); + assert.equal(buyShares(s, ctx, 'nope', 1), false); + assert.equal(sellShares(s, ctx, 'inv_low_1', 1), false); +}); + +test('a price rise makes a profit once sold', () => { + const s = fresh(); + s.funds = 1000; + buyShares(s, ctx, 'inv_high_1', 1); + s.invest.prices.inv_high_1 *= 2; + sellShares(s, ctx, 'inv_high_1', 1); + assert.ok(s.funds > 1900 && s.stats.investProfit > 900); +}); + +// --------------------------------------------------------------------- gamble + +test('odds always sum to 1 and improve with reliability', () => { + const s = fresh(); + const base = odds(s, ctx, mods(s)); + assert.ok(Math.abs(base.crash + base.jackpot + base.win + base.lose - 1) < 1e-12); + s.reliabilityLevel = 3; + assert.ok(odds(s, ctx, mods(s)).win > base.win); + s.reliabilityLevel = 6; + s.research = ['r_fin_1', 'r_fin_2']; + const best = odds(s, ctx, mods(s)); + assert.ok(best.win <= ctx.rules.gamble.maxWin - best.crash - best.jackpot + 1e-12); + assert.ok(best.lose >= 0); +}); + +test('chips are expensive and each one costs more', () => { + const s = fresh(); + const first = chipCost(s, ctx); + s.chips = 5; + assert.ok(chipCost(s, ctx) > first * 3); + s.funds = first - 1; + s.chips = 0; + assert.equal(buyChip(s, ctx), false); + s.funds = first; + assert.ok(buyChip(s, ctx)); + assert.equal(s.chips, 1); + assert.equal(s.funds, 0); +}); + +test('reliability upgrades are costly, capped, and paid in funds', () => { + const s = fresh(); + s.funds = 1e30; + const costs = []; + for (let i = 0; i < 10; i++) { + costs.push(reliabilityCost(s, ctx)); + buyReliability(s, ctx); + } + assert.equal(s.reliabilityLevel, ctx.rules.gamble.reliability.maxLevel); + assert.ok(costs[1] / costs[0] >= 5); +}); + +test('every outcome of a cycle', () => { + const play = (roll, chips = 10, stake = 4) => { + const s = fresh(); + s.chips = chips; + const result = gamble(s, ctx, stake, mods(s), constant(roll)); + return { s, result }; + }; + assert.equal(play(0.0).result.outcome, 'crash'); + assert.equal(play(0.0).s.chips, 10 - 8, 'a crash loses double the stake'); + assert.equal(play(0.04).result.outcome, 'jackpot'); + assert.equal(play(0.04).s.chips, 10 + 4 * ctx.rules.gamble.jackpotMultiplier); + assert.equal(play(0.2).result.outcome, 'win'); + assert.equal(play(0.2).s.chips, 14); + assert.equal(play(0.99).result.outcome, 'lose'); + assert.equal(play(0.99).s.chips, 6); + assert.equal(play(0.0, 5, 5).s.chips, 0, 'a crash cannot go below zero'); +}); + +test('a cycle needs chips and respects the cooldown', () => { + const s = fresh(); + assert.equal(gamble(s, ctx, 1, mods(s), constant(0.2)), null, 'no chips'); + s.chips = 10; + assert.ok(gamble(s, ctx, 1, mods(s), constant(0.2))); + assert.equal(gamble(s, ctx, 1, mods(s), constant(0.2)), null, 'cooling down'); + advance(s, ctx, ctx.rules.gamble.cooldownSeconds + 0.1); + assert.ok(gamble(s, ctx, 1, mods(s), constant(0.2))); + assert.equal(gamble(s, ctx, 1000, mods(s), constant(0.2)), null, 'cooling down again'); +}); + +test('the gamble is a losing bet at first and a winning one once reliable', () => { + const average = (level) => { + const s = fresh(); + s.reliabilityLevel = level; + const rng = seeded(42); + let total = 0; + for (let i = 0; i < 40000; i++) { + s.chips = 1000; + s.gambleCooldown = 0; + total += gamble(s, ctx, 10, mods(s), rng).delta; + } + return total / 40000; + }; + assert.ok(average(0) < -0.5, 'house edge at the start'); + assert.ok(average(6) > 0.5, 'player edge once fully reliable'); +}); + +test('chips boost demand with diminishing returns', () => { + const g = ctx.rules.gamble; + assert.equal(chipDemandBonus(0, g), 1); + assert.ok(chipDemandBonus(10, g) > chipDemandBonus(1, g)); + assert.ok(chipDemandBonus(1010, g) - chipDemandBonus(1000, g) < chipDemandBonus(20, g) - chipDemandBonus(10, g), 'each extra chip is worth less'); + const s = fresh(); + const before = derive(s, ctx).demandMultiplier; + s.chips = 30; + assert.ok(derive(s, ctx).demandMultiplier > before * 1.5); +}); + +// -------------------------------------------------------------------- research + +test('labs produce research points over time', () => { + const s = fresh(); + advance(s, ctx, 100); + assert.equal(s.rp, 0, 'no labs, no research'); + s.funds = 1e12; + const cost = labCost(s, ctx); + assert.ok(buyLab(s, ctx)); + assert.ok(labCost(s, ctx) > cost * 2); + advance(s, ctx, 100); + assert.ok(Math.abs(s.rp - 100 * ctx.rules.research.rpPerLabPerSecond) < 1e-6); +}); + +test('research follows its tree and applies its effect', () => { + const s = fresh(); + s.rp = 1e6; + assert.equal(buyResearch(s, ctx, 'r_prod_2'), false, 'locked behind r_prod_1'); + assert.equal(researchStatus(s, ctx.researchById.get('r_prod_2')), 'locked'); + const before = derive(s, ctx); + s.generators.gen1 = 10; + const rateBefore = derive(s, ctx).rate; + assert.ok(buyResearch(s, ctx, 'r_prod_1')); + assert.equal(derive(s, ctx).rate, rateBefore * 1.5); + assert.equal(researchStatus(s, ctx.researchById.get('r_prod_2')), 'available'); + assert.equal(buyResearch(s, ctx, 'r_prod_1'), false, 'already owned'); + assert.equal(before.advisedPrice, null); +}); + +test('the price advisor research reveals the balanced price', () => { + const s = fresh(); + s.generators.gen1 = 50; + assert.equal(derive(s, ctx).advisedPrice, null); + s.research = ['r_ins_1', 'r_ins_2', 'r_mkt_1', 'r_mkt_2', 'r_ins_3']; + const d = derive(s, ctx); + assert.ok(d.advisedPrice > 0); + // At the advised price, demand matches output. + s.price = d.advisedPrice; + assert.ok(Math.abs(derive(s, ctx).demand / d.rate - 1) < 0.1); +}); + +// ---------------------------------------------------------------------- legacy + +test('a legacy restart needs a big enough run, and rewards the square root of it', () => { + const s = fresh(); + s.totalProduced = ctx.rules.legacy.threshold - 1; + 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', () => { + const s = fresh(); + s.totalProduced = s.lifetimeProduced = 4e9; + s.funds = 1e9; + s.generators.gen3 = 40; + s.upgrades = ['click_1']; + s.chips = 12; + s.invest.holdings.inv_low_1 = 5; + s.research = ['r_prod_1']; + s.labs = 2; + s.rp = 33; + s.achievements = ['a_prod_1']; + s.stats.gambleWins = 7; + + assert.equal(prestige(s, ctx), 2); + assert.equal(s.funds, 0); + assert.equal(s.totalProduced, 0); + assert.equal(s.generators.gen3, 0); + assert.deepEqual(s.upgrades, []); + assert.equal(s.chips, 0); + assert.equal(s.invest.holdings.inv_low_1, 0); + assert.equal(s.lifetimeProduced, 4e9); + assert.deepEqual(s.research, ['r_prod_1']); + assert.equal(s.labs, 2); + assert.equal(s.rp, 33); + assert.deepEqual(s.achievements, ['a_prod_1']); + assert.equal(s.stats.gambleWins, 7); + assert.deepEqual(s.legacy, { points: 2, earned: 2, prestiges: 1, perks: {} }); +}); + +test('restarting is refused below the threshold', () => { + const s = fresh(); + s.totalProduced = 1e6; + assert.equal(prestige(s, ctx), 0); + assert.equal(s.totalProduced, 1e6); +}); + +test('legacy points give a passive bonus and buy perks that stay after a restart', () => { + const s = fresh(); + s.legacy = { points: 10, earned: 10, prestiges: 1, perks: {} }; + s.generators.gen1 = 10; + 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 perk = ctx.perksById.get('lg_prod'); + const cost = perkCost(perk, 0); + assert.ok(buyPerk(s, ctx, 'lg_prod')); + assert.equal(s.legacy.points, 10 - cost); + assert.ok(Math.abs(derive(s, ctx).rate / base - 1.05 ** 10 * 1.3) < 1e-9); + + s.totalProduced = 1e9; + prestige(s, ctx); + 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', () => { + const s = fresh(); + s.legacy.points = 1e6; + const perk = ctx.perksById.get('lg_start'); + assert.ok(perkCost(perk, 3) > perkCost(perk, 0)); + for (let i = 0; i < 20; i++) buyPerk(s, ctx, 'lg_start'); + assert.equal(s.legacy.perks.lg_start, perk.maxLevel); + s.totalProduced = 1e9; + prestige(s, ctx); + assert.equal(s.funds, perk.effect.value * perk.maxLevel); +}); + +test('generator cost perks make generators cheaper', () => { + const s = fresh(); + s.funds = 1e6; + s.legacy.points = 100; + const before = derive(s, ctx); + buyPerk(s, ctx, 'lg_cost'); + assert.ok(mods(s).generatorCost < 1 && before.mods.generatorCost === 1); +}); + +// ------------------------------------------------------------ contracts & events + +const advanceLive = (s, seconds, rng = constant(0.5)) => advance(s, ctx, seconds, rng); + +test('offers appear on the board, up to the maximum', () => { + const s = fresh(); + s.generators.gen1 = 20; + advanceLive(s, 1); + assert.equal(s.contracts.offers.length, 1); + advanceLive(s, ctx.rules.contracts.offerEverySeconds * 10); + assert.equal(s.contracts.offers.length, ctx.rules.contracts.maxOffers); + for (const o of s.contracts.offers) { + assert.ok(o.target > 0 && o.seconds > 0 && o.reward.amount > 0); + } +}); + +test('a contract is completed by selling enough, then pays its reward', () => { + const s = fresh(); + s.generators.gen1 = 100; + s.material = 1e9; + s.contracts.offers = [{ template: 'sprint', seconds: 60, target: 5, reward: { type: 'funds', amount: 777 } }]; + assert.ok(acceptContract(s, ctx, 0)); + assert.equal(s.contracts.offers.length, 0); + const funds = s.funds; + const totals = advanceLive(s, 5); + assert.equal(s.contracts.active, null); + assert.ok(s.funds >= funds + 777); + assert.equal(s.stats.contractsDone, 1); + assert.ok(totals.notices.some((n) => n.kind === 'contract_done')); +}); + +test('each kind of reward is granted', () => { + const grant = (reward) => { + const s = fresh(); + s.generators.gen1 = 100; + s.material = 1e9; + s.contracts.offers = [{ template: 'sprint', seconds: 60, target: 1, reward }]; + acceptContract(s, ctx, 0); + advanceLive(s, 2); + return s; + }; + assert.ok(grant({ type: 'research', amount: 5 }).rp >= 5); + assert.equal(grant({ type: 'chips', amount: 3 }).chips, 3); + const boosted = grant({ type: 'boost', amount: 2 }); + assert.ok(boosted.events.buffs.some((b) => b.type === 'global_mult' && b.value === 2)); +}); + +test('a contract fails when time runs out, and can be abandoned', () => { + const s = fresh(); + s.contracts.offers = [{ template: 'sprint', seconds: 10, target: 1e12, reward: { type: 'funds', amount: 1 } }]; + acceptContract(s, ctx, 0); + assert.equal(acceptContract(s, ctx, 0), false, 'one at a time'); + const totals = advanceLive(s, 11); + assert.equal(s.contracts.active, null); + assert.ok(totals.notices.some((n) => n.kind === 'contract_failed')); + s.contracts.offers = [{ template: 'sprint', seconds: 10, target: 1e12, reward: { type: 'funds', amount: 1 } }]; + acceptContract(s, ctx, 0); + assert.ok(abandonContract(s)); + assert.equal(abandonContract(s), false); +}); + +test('events: an opportunity appears, can be claimed once, and its buff expires', () => { + const s = fresh(); + s.generators.gen1 = 50; + s.eventTimer = 0; + const totals = { notices: [] }; + const d = derive(s, ctx); + stepEvents(s, ctx, 1, d, constant(0), false, totals); // first weighted type: surge + assert.equal(s.events.current.type, 'surge'); + assert.ok(totals.notices.some((n) => n.kind === 'event_opportunity')); + + const demandBefore = derive(s, ctx).demandMultiplier; + assert.ok(claimEvent(s, ctx, derive(s, ctx))); + assert.equal(claimEvent(s, ctx, derive(s, ctx)), false); + assert.equal(s.stats.eventsClaimed, 1); + assert.equal(derive(s, ctx).demandMultiplier, demandBefore * 2); + advanceLive(s, 61); + assert.equal(s.events.buffs.length, 0); + assert.equal(derive(s, ctx).demandMultiplier, demandBefore); +}); + +test('events: unclaimed opportunities vanish, windfalls pay, incidents apply on their own', () => { + const s = fresh(); + s.generators.gen1 = 10; + s.events.current = { type: 'surge', remaining: ctx.rules.events.claimSeconds }; + advanceLive(s, ctx.rules.events.claimSeconds + 1); + assert.equal(s.events.current, null); + + s.events.current = { type: 'windfall', remaining: 10 }; + const funds = s.funds; + claimEvent(s, ctx, derive(s, ctx)); + assert.ok(s.funds > funds); + + s.eventTimer = 0; + const totals = { notices: [] }; + // rng 0.99 -> the last type in the list: the automatic incident + stepEvents(s, ctx, 1, derive(s, ctx), constant(0.99), false, totals); + assert.ok(totals.notices.some((n) => n.kind === 'event_incident')); + assert.ok(s.events.buffs.some((b) => b.type === 'material_price_mult')); + assert.ok(derive(s, ctx).batchCost > 0); +}); + +test('offline time skips contracts and events but still earns and pays dividends', () => { + const s = fresh(); + s.generators.gen1 = 20; + s.material = 1e9; + s.invest.holdings.inv_low_1 = 100; + s.contractTimer = 0; + s.eventTimer = 1; + const report = simulateOffline(s, ctx, 3600); + assert.ok(report.produced > 0); + assert.equal(s.contracts.offers.length, 0); + assert.equal(s.events.current, null); + assert.ok(s.stats.investProfit === 0 && s.funds > 0); +}); + +test('research and perks raise the offline efficiency', () => { + const run = (setup) => { + const s = fresh(); + s.generators.gen1 = 20; + s.material = 1e12; + setup(s); + return simulateOffline(s, ctx, 3600).produced; + }; + const plain = run(() => {}); + assert.ok(run((s) => (s.research = ['r_ins_1', 'r_ins_2'])) > plain * 1.4); +}); + +// ---------------------------------------------------------------- achievements + +test('achievements are granted once, and their bonus applies', () => { + const s = fresh(); + s.generators.gen1 = 10; + s.generators.gen2 = 10; + assert.deepEqual(checkAchievements(s, ctx), ['a_gen_1']); + assert.deepEqual(checkAchievements(s, ctx), []); + s.lifetimeProduced = 2e6; + const granted = checkAchievements(s, ctx); + assert.deepEqual(granted.sort(), ['a_prod_1', 'a_prod_2']); + const m = mods(s); + assert.ok(Math.abs(m.global - 1.02 ** 3) < 1e-12); +}); + +test('achievements are announced by the simulation', () => { + const s = fresh(); + s.lifetimeProduced = 5000; + const totals = advanceLive(s, 1); + assert.ok(totals.notices.some((n) => n.kind === 'achievement' && n.id === 'a_prod_1')); +}); + +// ------------------------------------------------------------------- state (v2) + +test('a save from the first version still loads, with the new systems at their defaults', () => { + const old = { + v: 1, + units: 12, + funds: 345, + material: 99, + totalProduced: 5e6, + totalSold: 1e6, + price: 0.3, + marketing: 4, + materialFactor: 1.1, + generators: { gen1: 5, gen2: 2 }, + upgrades: ['click_1', 'ghost'], + savedAt: 5, + }; + const s = sanitizeState(old, ctx, 1_000_000); + assert.equal(s.funds, 345); + assert.deepEqual(s.upgrades, ['click_1']); + assert.equal(s.lifetimeProduced, 5e6, 'lifetime falls back to the run total'); + assert.equal(s.chips, 0); + assert.deepEqual(s.research, []); + assert.deepEqual(s.legacy, { points: 0, earned: 0, prestiges: 0, perks: {} }); + assert.equal(s.invest.prices.inv_low_1, 100); + assert.deepEqual(s.contracts, { offers: [], active: null }); +}); + +test('junk in the new fields is cleaned up', () => { + const s = sanitizeState( + { + chips: -4, + reliabilityLevel: 99, + labs: 1e9, + rp: 'lots', + research: ['r_prod_1', 'zzz', 'r_prod_1'], + achievements: ['a_prod_1', 7, null], + legacy: { points: -1, earned: 'x', prestiges: 2.9, perks: { lg_prod: 9999, nope: 3 } }, + invest: { prices: { inv_low_1: 1e12, inv_low_2: -5 }, holdings: { inv_low_1: 'a', inv_low_2: 3 }, basis: { inv_low_2: 10 } }, + contracts: { + offers: [{ template: 'sprint', seconds: 60, target: 10, reward: { type: 'funds', amount: 5 } }, { template: 'bogus' }, null], + active: { offer: { template: 'x' } }, + }, + events: { + current: { type: 'crash', remaining: 5 }, + buffs: [{ type: 'demand_mult', value: 2, remaining: 10 }, { type: 'hack', value: 9, remaining: 1 }], + }, + }, + ctx, + ); + assert.equal(s.chips, 0); + assert.equal(s.reliabilityLevel, ctx.rules.gamble.reliability.maxLevel); + assert.equal(s.labs, ctx.rules.research.labMax); + assert.equal(s.rp, 0); + assert.deepEqual(s.research, ['r_prod_1']); + assert.deepEqual(s.achievements, ['a_prod_1']); + assert.equal(s.legacy.points, 0); + assert.equal(s.legacy.prestiges, 2); + assert.deepEqual(s.legacy.perks, { lg_prod: ctx.perksById.get('lg_prod').maxLevel }); + assert.equal(s.invest.prices.inv_low_1, 100 * ctx.investments.maxRatio); + assert.equal(s.invest.prices.inv_low_2, 45 * ctx.investments.minRatio); + assert.equal(s.invest.holdings.inv_low_1, 0); + assert.equal(s.invest.holdings.inv_low_2, 3); + assert.equal(s.contracts.offers.length, 1); + assert.equal(s.contracts.active, null); + assert.equal(s.events.current, null, 'automatic incidents are never "claimable"'); + assert.deepEqual(s.events.buffs.map((b) => b.type), ['demand_mult']); +}); + +test('transient fields are never saved, and a full game still fits in cookies', () => { + const s = fresh(); + s.funds = 123456789.123456; + for (const g of ctx.generators) s.generators[g.id] = 2500; + s.upgrades = ctx.upgrades.map((u) => u.id); + s.research = ctx.research.map((r) => r.id); + s.achievements = ctx.achievements.map((a) => a.id); + s.legacy = { points: 12, earned: 40, prestiges: 9, perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])) }; + for (const soc of ctx.investments.societies) { + s.invest.holdings[soc.id] = 12345.678901; + s.invest.basis[soc.id] = 98765.4321; + s.invest.prices[soc.id] = soc.basePrice * 1.23456789; + } + s.contracts.offers = ctx.rules.contracts.templates.map((t) => ({ + template: t.id, + seconds: t.seconds, + target: 123456789, + reward: { type: 'funds', amount: 987654321.123 }, + })); + s.events.buffs = [{ type: 'demand_mult', value: 2, remaining: 40.123 }]; + + const text = serializeState(s); + for (const key of ['materialTimer', 'materialTrend', 'investTimer', 'contractTimer', 'eventTimer', 'gambleCooldown']) { + assert.ok(!text.includes(`"${key}"`), key); + } + assert.ok(text.length < 4000, `save is ${text.length} characters`); + const back = sanitizeState(JSON.parse(text), ctx); + assert.deepEqual(back.research, s.research); + assert.deepEqual(back.legacy, s.legacy); + assert.equal(back.contracts.offers.length, 3); +});