feat: New features (research, invest, gambling, new game+, trophies)

This commit is contained in:
gauvainboiche
2026-10-08 14:03:00 +02:00
parent 1caa4d92b9
commit fc0d5e3ba4
56 changed files with 4332 additions and 249 deletions
+23 -6
View File
@@ -1,6 +1,6 @@
# Idle Clicker # 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). 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/ public/
index.html page skeleton (texts come from i18n via data-i18n attributes) index.html page skeleton (texts come from i18n via data-i18n attributes)
css/ base (tokens) · layout (responsive grid) · components 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) locales/ core UI texts, one file per language (en, fr)
themes/<id>/ ONE FOLDER PER THEME themes/<id>/ ONE FOLDER PER THEME
theme.json order + (later) image paths theme.json order + (later) image paths
theme.css colours/fonts, as CSS variables theme.css colours/fonts, as CSS variables
locales/<lang>.json the theme's vocabulary: names of resources, generators, upgrades locales/<lang>.json the theme's vocabulary: names of resources, generators, upgrades
js/ 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 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 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 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 ## Add a theme
1. Copy `public/themes/paperclip/` to `public/themes/<your-id>/` (lowercase letters, digits, dashes). 1. Copy `public/themes/paperclip/` to `public/themes/<your-id>/` (lowercase letters, digits, dashes).
@@ -67,7 +84,7 @@ Keys: `res.units|funds|material`, `gen.<id>`, `upg.<id>`. 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. - 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. - **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`). - 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 ## Tuning the game
+2
View File
@@ -15,6 +15,8 @@
--accent: #2563eb; --accent: #2563eb;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
--danger: #c0392b; --danger: #c0392b;
--good: #1a7f4b; /* gains */
--bad: #c0392b; /* losses */
--radius: 10px; --radius: 10px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+219
View File
@@ -423,3 +423,222 @@ select,
background: var(--danger); background: var(--danger);
color: #fff; 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;
}
+111 -7
View File
@@ -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 { .topbar {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'title toggle'
'menu menu';
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--gap); gap: var(--gap);
padding: var(--gap) 1rem; padding: var(--gap) 1rem;
background: var(--surface); background: var(--surface);
@@ -12,22 +16,102 @@
} }
.topbar__title { .topbar__title {
grid-area: title;
font-size: 1.25rem; 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-end; align-items: flex-end;
gap: var(--gap); 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 { .game {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
max-width: 1400px;
margin: 0 auto;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-areas: grid-template-areas:
'stats' 'stats'
@@ -37,6 +121,14 @@
'upgrades'; 'upgrades';
} }
.view {
display: grid;
gap: 1rem;
padding: 1rem;
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.panel--stats { grid-area: stats; } .panel--stats { grid-area: stats; }
.panel--production { grid-area: production; } .panel--production { grid-area: production; }
.panel--market { grid-area: market; } .panel--market { grid-area: market; }
@@ -53,6 +145,14 @@
'generators upgrades'; 'generators upgrades';
align-items: start; align-items: start;
} }
.view {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel--wide {
grid-column: 1 / -1;
}
} }
/* Desktop */ /* Desktop */
@@ -65,4 +165,8 @@
'production generators upgrades' 'production generators upgrades'
'market generators upgrades'; 'market generators upgrades';
} }
.view {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
+25
View File
@@ -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}}
]
+70
View File
@@ -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
}
]
}
+8
View File
@@ -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}}
]
+17
View File
@@ -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"}}
]
+125 -6
View File
@@ -1,7 +1,16 @@
{ {
"start": { "units": 0, "funds": 0, "material": 100, "price": 0.25 }, "start": {
"click": { "yield": 1 }, "units": 0,
"unit": { "materialPerUnit": 1 }, "funds": 0,
"material": 100,
"price": 0.25
},
"click": {
"yield": 1
},
"unit": {
"materialPerUnit": 1
},
"market": { "market": {
"referencePrice": 0.25, "referencePrice": 0.25,
"baseDemand": 2.5, "baseDemand": 2.5,
@@ -25,7 +34,117 @@
"updateEverySeconds": 10, "updateEverySeconds": 10,
"autoBuyBelowFactor": 1.2 "autoBuyBelowFactor": 1.2
}, },
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 }, "marketing": {
"offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 }, "baseCost": 40,
"autosaveSeconds": 10 "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
}
}
]
}
} }
+9
View File
@@ -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"}
]
+35 -25
View File
@@ -14,7 +14,13 @@
<body> <body>
<header class="topbar"> <header class="topbar">
<h1 class="topbar__title" id="game-title" data-i18n="theme.title">Idle Clicker</h1> <h1 class="topbar__title" id="game-title" data-i18n="theme.title">Idle Clicker</h1>
<div class="topbar__controls"> <button class="btn btn--ghost menu-toggle" id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu">
<span aria-hidden="true">☰</span> <span data-i18n="ui.menu">Menu</span>
</button>
<!-- Tabs + controls: a burger menu on phones, a tab bar from tablet width (see css/layout.css) -->
<nav class="menu" id="menu" data-i18n-attr="aria-label:ui.menu">
<ul class="tabs" id="tabs"></ul>
<div class="menu__controls">
<label class="field"> <label class="field">
<span class="field__label" data-i18n="ui.theme">Theme</span> <span class="field__label" data-i18n="ui.theme">Theme</span>
<select id="theme-select"></select> <select id="theme-select"></select>
@@ -25,37 +31,32 @@
</label> </label>
<button class="btn btn--ghost" id="open-settings" type="button" data-i18n="ui.settings">Save &amp; settings</button> <button class="btn btn--ghost" id="open-settings" type="button" data-i18n="ui.settings">Save &amp; settings</button>
</div> </div>
</nav>
</header> </header>
<!-- Always visible, whichever section is open -->
<section class="resourcebar" aria-label="Resources">
<div class="res res--primary">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.units"></span>
<div><span class="res__label" data-i18n="theme.res.units"></span><strong id="v-units">0</strong></div>
</div>
<div class="res">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.funds"></span>
<div><span class="res__label" data-i18n="theme.res.funds"></span><strong id="v-funds">0</strong></div>
</div>
<div class="res">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.material"></span>
<div><span class="res__label" data-i18n="theme.res.material"></span><strong id="v-material">0</strong></div>
</div>
</section>
<p class="banner" id="cookie-warning" hidden data-i18n="toast.cookiesBlocked"></p> <p class="banner" id="cookie-warning" hidden data-i18n="toast.cookiesBlocked"></p>
<main class="game" id="game"> <main class="views">
<div class="game" id="game" data-view="main">
<!-- Overview --> <!-- Overview -->
<section class="panel panel--stats" aria-labelledby="h-stats"> <section class="panel panel--stats" aria-labelledby="h-stats">
<h2 class="panel__title" id="h-stats" data-i18n="ui.overview">Overview</h2> <h2 class="panel__title" id="h-stats" data-i18n="ui.overview">Overview</h2>
<div class="tiles">
<div class="tile tile--primary">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.units"></span>
<div>
<span class="tile__label" data-i18n="theme.res.units"></span>
<strong class="tile__value" id="v-units">0</strong>
</div>
</div>
<div class="tile">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.funds"></span>
<div>
<span class="tile__label" data-i18n="theme.res.funds"></span>
<strong class="tile__value" id="v-funds">0</strong>
</div>
</div>
<div class="tile">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.material"></span>
<div>
<span class="tile__label" data-i18n="theme.res.material"></span>
<strong class="tile__value" id="v-material">0</strong>
</div>
</div>
</div>
<dl class="stats"> <dl class="stats">
<div><dt data-i18n="ui.totalProduced"></dt><dd id="v-total">0</dd></div> <div><dt data-i18n="ui.totalProduced"></dt><dd id="v-total">0</dd></div>
<div><dt data-i18n="ui.production"></dt><dd id="v-rate">0</dd></div> <div><dt data-i18n="ui.production"></dt><dd id="v-rate">0</dd></div>
@@ -120,6 +121,15 @@
<p class="hint" id="upgrades-empty" data-i18n="ui.noUpgrades"></p> <p class="hint" id="upgrades-empty" data-i18n="ui.noUpgrades"></p>
<ul class="cards" id="upgrades"></ul> <ul class="cards" id="upgrades"></ul>
</section> </section>
</div>
<!-- The other sections are built by js/ui/*View.js; they unlock with progress -->
<div class="view" data-view="contracts" id="view-contracts" hidden></div>
<div class="view" data-view="invest" id="view-invest" hidden></div>
<div class="view" data-view="research" id="view-research" hidden></div>
<div class="view" data-view="gamble" id="view-gamble" hidden></div>
<div class="view" data-view="legacy" id="view-legacy" hidden></div>
<div class="view" data-view="achievements" id="view-achievements" hidden></div>
</main> </main>
<!-- Save & settings --> <!-- Save & settings -->
+35
View File
@@ -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;
}
+65 -17
View File
@@ -1,27 +1,75 @@
import { EFFECT_TYPES } from './effects.js'; import { EFFECT_TYPES } from './effects.js';
/** function byId(list, what) {
* Bundle the static game data (data/*.json) with lookup tables and validate it, const map = new Map(list.map((item) => [item.id, item]));
* so a typo in a JSON file fails loudly at startup instead of mid-game. if (map.size !== list.length) throw new Error(`Duplicate ${what} id`);
*/ return map;
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]));
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id'); function checkEffect(owner, effect, generatorsById) {
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id'); 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) { for (const u of upgrades) {
if (!EFFECT_TYPES.includes(u.effect?.type)) { checkEffect(`Upgrade "${u.id}"`, u.effect, generatorsById);
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}"`);
}
if (u.requires && !upgradesById.has(u.requires)) { if (u.requires && !upgradesById.has(u.requires)) {
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`); throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
} }
} }
for (const node of research) {
return { rules, generators, upgrades, generatorsById, upgradesById }; 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,
research,
legacy,
achievements,
investments,
tabs,
generatorsById,
upgradesById,
researchById,
perksById,
achievementsById,
societiesById,
};
} }
+144
View File
@@ -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;
}
+58 -16
View File
@@ -1,5 +1,7 @@
// Upgrade effects. To add a new effect type: register it here, add its // Effects: the single vocabulary used by upgrades, research, legacy perks,
// `effect.<type>` string to the core locale files, and use it in upgrades.json. // achievements, chips and temporary buffs. To add a new effect type: register
// it here, add its `effect.<type>` text to the core locale files, then use it
// in any data file.
export function newModifiers() { export function newModifiers() {
return { return {
@@ -12,29 +14,69 @@ export function newModifiers() {
batchDiscount: 1, batchDiscount: 1,
batchSize: 1, batchSize: 1,
autoBuyMaterial: false, 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 = { const APPLY = {
click_mult: (m, e) => void (m.click *= e.value), click_mult: mult('click'),
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value), generator_mult: (m, e, times) => {
global_mult: (m, e) => void (m.global *= e.value), m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value ** times;
demand_mult: (m, e) => void (m.demand *= e.value), },
value_mult: (m, e) => void (m.value *= e.value), global_mult: mult('global'),
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value), demand_mult: mult('demand'),
batch_discount: (m, e) => void (m.batchDiscount *= e.value), value_mult: mult('value'),
batch_size_mult: (m, e) => void (m.batchSize *= e.value), material_efficiency: mult('materialEfficiency'),
auto_buy_material: (m) => void (m.autoBuyMaterial = true), 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); export const EFFECT_TYPES = Object.keys(APPLY);
/** Fold every owned upgrade into a single set of multipliers. */ /** Fold a list of `{ effect, times }` entries into one set of multipliers. */
export function computeModifiers(ownedIds, upgradesById) { export function foldEffects(entries) {
const m = newModifiers(); const m = newModifiers();
for (const id of ownedIds) { for (const { effect, times = 1 } of entries) {
const effect = upgradesById.get(id)?.effect; if (effect && times > 0) APPLY[effect.type]?.(m, effect, times);
if (effect) APPLY[effect.type]?.(m, effect);
} }
return m; return m;
} }
+59 -18
View File
@@ -1,9 +1,23 @@
// Game rules. Every function takes the mutable `state` and the static `ctx` // 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 // (see context.js) and never touches the DOM, so it runs the same in the
// browser and in Node (tests, balance simulations). // 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 { checkAchievements } from './achievements.js';
import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.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; const MAX_STEP_SECONDS = 1;
@@ -11,6 +25,7 @@ const MAX_STEP_SECONDS = 1;
export function isUnlocked(state, condition = {}) { export function isUnlocked(state, condition = {}) {
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false; 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)) { if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
return false; return false;
} }
@@ -26,9 +41,8 @@ export function upgradeVisible(state, upgrade) {
// ------------------------------------------------------------------- derived // ------------------------------------------------------------------- derived
/** Everything computed from the state (rates, multipliers, current prices). */ /** 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 { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
const perGenerator = {}; const perGenerator = {};
let rate = 0; let rate = 0;
@@ -43,10 +57,11 @@ export function derive(state, ctx) {
state.totalProduced, state.totalProduced,
rate, rate,
state.materialFactor, state.materialFactor,
mods.batchDiscount, mods.batchDiscount * mods.materialPrice,
mods.batchSize, mods.batchSize,
); );
return { return {
mods,
clickYield: rules.click.yield * mods.click, clickYield: rules.click.yield * mods.click,
perGenerator, perGenerator,
rate, rate,
@@ -58,6 +73,15 @@ export function derive(state, ctx) {
batchCost: batch.cost, batchCost: batch.cost,
autoBuyMaterial: mods.autoBuyMaterial, autoBuyMaterial: mods.autoBuyMaterial,
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing), 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.material -= made * d.materialPerUnit;
state.units += made; state.units += made;
state.totalProduced += made; state.totalProduced += made;
state.lifetimeProduced += made;
return true; return true;
} }
@@ -90,12 +115,13 @@ export function buyMarketing(state, ctx) {
return true; return true;
} }
/** How many generators the player gets for `amount` ("max" or a number). */ /** How many generators the player gets for `amount` ("max" or a number), and what it costs. */
export function generatorQuote(state, ctx, id, amount) { export function generatorQuote(state, ctx, id, amount, mods = modifiersFor(state, ctx)) {
const g = ctx.generatorsById.get(id); const g = ctx.generatorsById.get(id);
const owned = state.generators[id] ?? 0; const owned = state.generators[id] ?? 0;
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount; const base = g.baseCost * mods.generatorCost;
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) }; 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) { 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); stepMaterialMarket(state, ctx, dt, rng);
const d = derive(state, ctx); const d = derive(state, ctx);
@@ -147,6 +173,7 @@ function step(state, ctx, dt, rng, totals) {
state.material -= made * d.materialPerUnit; state.material -= made * d.materialPerUnit;
state.units += made; state.units += made;
state.totalProduced += made; state.totalProduced += made;
state.lifetimeProduced += made;
// Automatic restock: patient while the price is high, urgent when nearly out. // Automatic restock: patient while the price is high, urgent when nearly out.
if (d.autoBuyMaterial && state.funds >= d.batchCost) { if (d.autoBuyMaterial && state.funds >= d.batchCost) {
@@ -165,28 +192,42 @@ function step(state, ctx, dt, rng, totals) {
totals.produced += made; totals.produced += made;
totals.sold += sold; totals.sold += sold;
totals.revenue += revenue; 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) { * Advance the simulation by `seconds`. Returns what happened during that time,
const totals = { produced: 0, sold: 0, revenue: 0 }; * 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); let left = Math.max(0, seconds);
while (left > 1e-9) { while (left > 1e-9) {
const dt = Math.min(left, MAX_STEP_SECONDS); const dt = Math.min(left, MAX_STEP_SECONDS);
step(state, ctx, dt, rng, totals); step(state, ctx, dt, rng, totals, offline);
left -= dt; left -= dt;
} }
return totals; return totals;
} }
/** /**
* Catch up on the time the game was closed. Deterministic (no price noise) * 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. * discounted by the offline efficiency (research and legacy raise it).
* Returns null if too short to matter.
*/ */
export function simulateOffline(state, ctx, elapsedSeconds) { export function simulateOffline(state, ctx, elapsedSeconds) {
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline; const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
if (elapsedSeconds < minSeconds) return null; if (elapsedSeconds < minSeconds) return null;
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency; const effective = Math.min(1, efficiency + modifiersFor(state, ctx).offline);
const totals = advance(state, ctx, seconds, () => 0.5); const seconds = Math.min(elapsedSeconds, maxSeconds) * effective;
const totals = advance(state, ctx, seconds, () => 0.5, { offline: true });
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) }; return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
} }
+8
View File
@@ -4,6 +4,14 @@ export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value)); 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. */ /** Price of the next item when `owned` items are already owned. */
export function unitCost(base, growth, owned) { export function unitCost(base, growth, owned) {
return base * growth ** owned; return base * growth ** owned;
+77
View File
@@ -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 };
}
+75
View File
@@ -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;
}
+52
View File
@@ -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;
}
+36
View File
@@ -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));
}
+38
View File
@@ -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;
}
+161 -13
View File
@@ -1,28 +1,85 @@
import { clamp } from './formulas.js'; import { clamp } from './formulas.js';
export const STATE_VERSION = 1; export const STATE_VERSION = 2;
const MAX_NUMBER = 1e300; const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300; 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()) { export function createState(ctx, now = Date.now()) {
const { start, market } = ctx.rules; const { start, market, events, contracts } = ctx.rules;
return { return {
v: STATE_VERSION, v: STATE_VERSION,
// --- the run (reset by a legacy restart)
units: start.units, units: start.units,
funds: start.funds, funds: start.funds,
material: start.material, material: start.material,
totalProduced: 0, totalProduced: 0,
totalSold: 0, totalSold: 0,
price: start.price ?? market.basePrice, price: start.price ?? market.referencePrice,
marketing: 0, marketing: 0,
materialFactor: 1, materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])), generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [], 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, 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)); 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. * 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. * 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()) { export function sanitizeState(raw, ctx, now = Date.now()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; 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 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 { return {
...base, ...base,
units: num(raw.units, base.units), units: num(raw.units, base.units),
funds: num(raw.funds, base.funds), funds: num(raw.funds, base.funds),
material: num(raw.material, base.material), material: num(raw.material, base.material),
totalProduced: num(raw.totalProduced, 0), totalProduced,
totalSold: num(raw.totalSold, 0), totalSold: num(raw.totalSold, 0),
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100, price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
marketing: int(raw.marketing, MAX_MARKETING), marketing: int(raw.marketing, MAX_MARKETING),
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor), materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators: Object.fromEntries( generators,
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]), upgrades: knownIds(raw.upgrades, ctx.upgradesById),
), invest: sanitizeInvest(raw.invest, ctx, base.invest),
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)), 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), savedAt: num(raw.savedAt, now, 0, now),
}; };
} }
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */ /** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) { 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) => return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value, typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
); );
+85 -11
View File
@@ -1,5 +1,6 @@
// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI. // Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
import { abandonContract, acceptContract, claimEvent } from './engine/contracts.js';
import { import {
advance, advance,
buyGenerator, buyGenerator,
@@ -11,6 +12,10 @@ import {
setPrice, setPrice,
simulateOffline, simulateOffline,
} from './engine/engine.js'; } 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 { createState, sanitizeState, serializeState } from './engine/state.js';
import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js'; import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
import { createFormatter } from './services/format.js'; import { createFormatter } from './services/format.js';
@@ -26,8 +31,16 @@ import {
takeLegacySaves, takeLegacySaves,
} from './services/storage.js'; } from './services/storage.js';
import { applyThemeStyles } from './services/themes.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 { $, 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 { 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 { showToast } from './ui/toast.js';
import { createView } from './ui/view.js'; import { createView } from './ui/view.js';
@@ -42,6 +55,7 @@ let themeId;
let locale; let locale;
let state; let state;
let fmt; let fmt;
let tabs;
let income = 0; let income = 0;
let lastSaveError = 0; let lastSaveError = 0;
const themeNames = new Map(); 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() { async function refreshTexts() {
await loadDictionary(locale, themeId); await loadDictionary(locale, themeId);
fmt = createFormatter(locale); fmt = createFormatter(locale);
@@ -123,6 +137,9 @@ async function refreshTexts() {
applyTranslations(); applyTranslations();
document.title = t('theme.title'); document.title = t('theme.title');
view.rebuild(ctx, fmt); view.rebuild(ctx, fmt);
for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt);
tabs.rebuild();
refreshStaticIcons();
await fillSelects(); await fillSelects();
} }
@@ -137,11 +154,26 @@ async function setTheme(id) {
// ------------------------------------------------------------------ the loop // ------------------------------------------------------------------ the loop
function formatDuration(seconds) { /** Tell the player about what just happened (see `notices` in engine.advance). */
const minutes = Math.floor(seconds / 60); function announce(notice) {
if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) }); switch (notice.kind) {
if (minutes < 60) return t('time.minutes', { value: minutes }); case 'contract_done':
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 }); 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. */ /** Apply progress made while the game was closed or the device asleep. */
@@ -152,7 +184,7 @@ function catchUp(elapsedSeconds) {
t('toast.welcomeBack', { t('toast.welcomeBack', {
duration: formatDuration(report.seconds), duration: formatDuration(report.seconds),
produced: fmt.number(Math.floor(report.produced)), 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) { if (dt >= ctx.rules.offline.minSeconds) {
catchUp(dt); // tab was frozen or the device slept catchUp(dt); // tab was frozen or the device slept
} else { } else {
const { revenue } = advance(state, ctx, dt); const totals = advance(state, ctx, dt);
income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS); income += (totals.revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
totals.notices.forEach(announce);
} }
render(); render();
} }
/** The resource bar and the unlocked tabs always; the open section only. */
function render() { 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 // ------------------------------------------------------------------- actions
@@ -195,6 +232,41 @@ const view = createView({
nudgePrice: (delta) => act(setPrice)(state.price + delta), 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({ createSettings({
getExport: () => ({ getExport: () => ({
text: buildExport(state), text: buildExport(state),
@@ -211,6 +283,7 @@ createSettings({
state = createState(ctx); state = createState(ctx);
income = 0; income = 0;
persist(); persist();
tabs.select('main');
render(); render();
}, },
}); });
@@ -226,6 +299,8 @@ async function boot() {
[ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]); [ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]);
if (themes.length === 0) throw new Error('No theme installed in public/themes/'); if (themes.length === 0) throw new Error('No theme installed in public/themes/');
tabs = createTabs(ctx.tabs, () => state && render());
const prefs = loadPrefs(); const prefs = loadPrefs();
locale = pickLocale(prefs.lang); locale = pickLocale(prefs.lang);
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0]; const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
@@ -240,7 +315,6 @@ async function boot() {
}); });
await setTheme(initialTheme.id); await setTheme(initialTheme.id);
refreshStaticIcons();
state = loadSavedState() ?? createState(ctx); state = loadSavedState() ?? createState(ctx);
catchUp((Date.now() - state.savedAt) / 1000); catchUp((Date.now() - state.savedAt) / 1000);
+16 -6
View File
@@ -8,13 +8,23 @@ async function getJson(url) {
return response.json(); 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() { export async function loadGameContext() {
const [rules, generators, upgrades] = await Promise.all([ const entries = await Promise.all(
getJson('data/rules.json'), Object.entries(DATA_FILES).map(async ([key, file]) => [key, await getJson(`data/${file}.json`)]),
getJson('data/generators.json'), );
getJson('data/upgrades.json'), return buildContext(Object.fromEntries(entries));
]);
return buildContext({ rules, generators, upgrades });
} }
/** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */ /** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */
+57
View File
@@ -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 };
}
+164
View File
@@ -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 };
}
+2 -5
View File
@@ -35,10 +35,7 @@ export function icon(key) {
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { 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) { export function refreshStaticIcons(root = document) {
for (const el of root.querySelectorAll('[data-icon]')) { for (const el of root.querySelectorAll('.resourcebar [data-icon]')) el.replaceWith(icon(el.dataset.icon));
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
el.replaceWith(icon(el.dataset.icon));
}
} }
+167
View File
@@ -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 };
}
+181
View File
@@ -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 };
}
+106
View File
@@ -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 };
}
+114
View File
@@ -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 };
}
+89
View File
@@ -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; } };
}
+51
View File
@@ -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)}%`;
}
+4 -1
View File
@@ -1,9 +1,12 @@
import { $, h } from './dom.js'; import { $, h } from './dom.js';
const DURATION_MS = 6000; const DURATION_MS = 6000;
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
export function showToast(message, { error = false } = {}) { export function showToast(message, { error = false } = {}) {
const container = $('#toasts');
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message }); 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); setTimeout(() => toast.remove(), DURATION_MS);
} }
+221 -3
View File
@@ -35,7 +35,8 @@
"hintDemandOnly": "At this price customers want {demand}/s.", "hintDemandOnly": "At this price customers want {demand}/s.",
"marketShort": "Demand outpaces your output: you are selling too cheap.", "marketShort": "Demand outpaces your output: you are selling too cheap.",
"marketLong": "You make more than customers want: stock piles up. Try a lower price.", "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": { "effect": {
"click_mult": "Manual work ×{value}", "click_mult": "Manual work ×{value}",
@@ -46,7 +47,17 @@
"material_efficiency": "{@theme.res.material} goes ×{value} further", "material_efficiency": "{@theme.res.material} goes ×{value} further",
"batch_discount": "{@theme.res.material} is {percent}% cheaper", "batch_discount": "{@theme.res.material} is {percent}% cheaper",
"auto_buy_material": "Restocks {@theme.res.material} automatically", "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": { "time": {
"seconds": "{value} s", "seconds": "{value} s",
@@ -76,7 +87,14 @@
"imported": "Save imported.", "imported": "Save imported.",
"copied": "Copied to the clipboard.", "copied": "Copied to the clipboard.",
"copyFailed": "Could not copy. Select the text and copy it manually.", "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": { "error": {
"import": { "import": {
@@ -84,5 +102,205 @@
"invalid": "This is not a valid save.", "invalid": "This is not a valid save.",
"wrong_game": "This file is not an Idle Clicker 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"
}
} }
} }
+221 -3
View File
@@ -35,7 +35,8 @@
"hintDemandOnly": "À ce prix, les clients veulent {demand}/s.", "hintDemandOnly": "À ce prix, les clients veulent {demand}/s.",
"marketShort": "La demande dépasse votre production : vous vendez trop bon marché.", "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.", "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": { "effect": {
"click_mult": "Travail manuel ×{value}", "click_mult": "Travail manuel ×{value}",
@@ -46,7 +47,17 @@
"material_efficiency": "{@theme.res.material} : rendement ×{value}", "material_efficiency": "{@theme.res.material} : rendement ×{value}",
"batch_discount": "{@theme.res.material} : {percent} % moins cher", "batch_discount": "{@theme.res.material} : {percent} % moins cher",
"auto_buy_material": "Réapprovisionnement automatique : {@theme.res.material}", "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": { "time": {
"seconds": "{value} s", "seconds": "{value} s",
@@ -76,7 +87,14 @@
"imported": "Sauvegarde importée.", "imported": "Sauvegarde importée.",
"copied": "Copié dans le presse-papiers.", "copied": "Copié dans le presse-papiers.",
"copyFailed": "Copie impossible. Sélectionnez le texte et copiez-le manuellement.", "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": { "error": {
"import": { "import": {
@@ -84,5 +102,205 @@
"invalid": "Ceci n'est pas une sauvegarde valide.", "invalid": "Ceci n'est pas une sauvegarde valide.",
"wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker." "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"
}
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Alien Minerals", "name": "Alien Minerals",
"title": "Xeno-Mining Colony", "title": "Xeno-Mining Colony",
"res": { "units": "Xenocrystals", "unit": "xenocrystal", "funds": "Credits", "material": "Raw ore" }, "res": {
"fmt": { "funds": "{value} cr" }, "units": "Xenocrystals",
"action": { "make": "Refine a crystal", "buyMaterial": "Order raw ore" }, "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": { "gen": {
"gen1": { "name": "Mining Drone", "desc": "A tiny robot that scrapes the rock face." }, "gen1": {
"gen2": { "name": "Plasma Drill", "desc": "Melts through bedrock like butter." }, "name": "Mining Drone",
"gen3": { "name": "Orbital Extractor", "desc": "Pulls ore straight out of the crust from orbit." }, "desc": "A tiny robot that scrapes the rock face."
"gen4": { "name": "Asteroid Harvester", "desc": "Eats whole asteroids and refines the leftovers." }, },
"gen5": { "name": "Hive Swarm", "desc": "Millions of drones thinking as one." }, "gen2": {
"gen6": { "name": "Planet Cracker", "desc": "Opens a world like a walnut." } "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": { "upg": {
"click_1": "Gravity Gloves", "click_1": "Gravity Gloves",
@@ -50,6 +82,38 @@
"stack_4": "Convoy Fleets", "stack_4": "Convoy Fleets",
"stack_5": "Orbital Stockpile", "stack_5": "Orbital Stockpile",
"auto_buy": "Autonomous Freighter" "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"
}
} }
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Minerais extraterrestres", "name": "Minerais extraterrestres",
"title": "Colonie d'extraction xéno", "title": "Colonie d'extraction xéno",
"res": { "units": "Xénocristaux", "unit": "xénocristal", "funds": "Crédits", "material": "Minerai brut" }, "res": {
"fmt": { "funds": "{value} cr" }, "units": "Xénocristaux",
"action": { "make": "Raffiner un cristal", "buyMaterial": "Commander du minerai brut" }, "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": { "gen": {
"gen1": { "name": "Drone mineur", "desc": "Un minuscule robot qui gratte la roche." }, "gen1": {
"gen2": { "name": "Foreuse à plasma", "desc": "Fond la roche comme du beurre." }, "name": "Drone mineur",
"gen3": { "name": "Extracteur orbital", "desc": "Aspire le minerai de la croûte depuis l'orbite." }, "desc": "Un minuscule robot qui gratte la roche."
"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." }, "gen2": {
"gen6": { "name": "Briseur de planètes", "desc": "Ouvre un monde comme une noix." } "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": { "upg": {
"click_1": "Gants gravitationnels", "click_1": "Gants gravitationnels",
@@ -50,6 +82,38 @@
"stack_4": "Flottes de convoi", "stack_4": "Flottes de convoi",
"stack_5": "Stock orbital", "stack_5": "Stock orbital",
"auto_buy": "Cargo autonome" "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"
}
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@
--accent: #2de2a6; --accent: #2de2a6;
--accent-contrast: #032119; --accent-contrast: #032119;
--danger: #ff6b6b; --danger: #ff6b6b;
--good: #2de2a6;
--bad: #ff6b6b;
--radius: 4px; --radius: 4px;
--font: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace; --font: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Computers & GalliumCoin", "name": "Computers & GalliumCoin",
"title": "GalliumCoin Mining Lab", "title": "GalliumCoin Mining Lab",
"res": { "units": "CPU Power", "unit": "cycle", "funds": "GalliumCoin", "material": "Energy (W)" }, "res": {
"fmt": { "funds": "{value} GAL" }, "units": "CPU Power",
"action": { "make": "Run a computation", "buyMaterial": "Buy energy" }, "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": { "gen": {
"gen1": { "name": "Old Laptop", "desc": "Fans screaming, but it still crunches numbers." }, "gen1": {
"gen2": { "name": "Gaming PC", "desc": "The RGB lighting adds at least 10% more power." }, "name": "Old Laptop",
"gen3": { "name": "GPU Mining Rig", "desc": "Six graphics cards and a prayer." }, "desc": "Fans screaming, but it still crunches numbers."
"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." }, "gen2": {
"gen6": { "name": "Quantum Mainframe", "desc": "Computes every possibility at once." } "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": { "upg": {
"click_1": "Mechanical Keyboard", "click_1": "Mechanical Keyboard",
@@ -50,6 +82,38 @@
"stack_4": "Dedicated Power Line", "stack_4": "Dedicated Power Line",
"stack_5": "Private Power Plant", "stack_5": "Private Power Plant",
"auto_buy": "Smart Power Meter" "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"
}
} }
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Ordinateurs et GalliumCoin", "name": "Ordinateurs et GalliumCoin",
"title": "Laboratoire de minage GalliumCoin", "title": "Laboratoire de minage GalliumCoin",
"res": { "units": "Puissance CPU", "unit": "cycle", "funds": "GalliumCoin", "material": "Énergie (W)" }, "res": {
"fmt": { "funds": "{value} GAL" }, "units": "Puissance CPU",
"action": { "make": "Lancer un calcul", "buyMaterial": "Acheter de l'énergie" }, "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": { "gen": {
"gen1": { "name": "Vieux portable", "desc": "Les ventilateurs hurlent, mais il calcule encore." }, "gen1": {
"gen2": { "name": "PC gamer", "desc": "Les LED RGB ajoutent au moins 10 % de puissance." }, "name": "Vieux portable",
"gen3": { "name": "Rig de minage GPU", "desc": "Six cartes graphiques et une prière." }, "desc": "Les ventilateurs hurlent, mais il calcule encore."
"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." }, "gen2": {
"gen6": { "name": "Mainframe quantique", "desc": "Calcule toutes les possibilités à la fois." } "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": { "upg": {
"click_1": "Clavier mécanique", "click_1": "Clavier mécanique",
@@ -50,6 +82,38 @@
"stack_4": "Ligne électrique dédiée", "stack_4": "Ligne électrique dédiée",
"stack_5": "Centrale privée", "stack_5": "Centrale privée",
"auto_buy": "Compteur intelligent" "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"
}
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@
--accent: #1f6feb; --accent: #1f6feb;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
--danger: #f85149; --danger: #f85149;
--good: #3fb950;
--bad: #ff6b6b;
--radius: 6px; --radius: 6px;
--font: 'Segoe UI', Inter, system-ui, -apple-system, sans-serif; --font: 'Segoe UI', Inter, system-ui, -apple-system, sans-serif;
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Leaf-Piling Insects", "name": "Leaf-Piling Insects",
"title": "Leaf Pile Colony", "title": "Leaf Pile Colony",
"res": { "units": "Leaves", "unit": "leaf", "funds": "Honeydew", "material": "Silk" }, "res": {
"fmt": { "funds": "{value} drops" }, "units": "Leaves",
"action": { "make": "Cut a leaf", "buyMaterial": "Trade for silk" }, "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": { "gen": {
"gen1": { "name": "Worker Ant", "desc": "Carries a leaf many times its own size." }, "gen1": {
"gen2": { "name": "Leafcutter Squad", "desc": "Slices, hauls and stacks in perfect formation." }, "name": "Worker Ant",
"gen3": { "name": "Beetle Hauler", "desc": "Armoured carrier that shifts whole piles." }, "desc": "Carries a leaf many times its own size."
"gen4": { "name": "Termite Mound", "desc": "A towering colony that never stops building." }, },
"gen5": { "name": "Locust Swarm", "desc": "Strips whole trees in an afternoon." }, "gen2": {
"gen6": { "name": "Hive Queen", "desc": "Her pheromones coordinate millions of workers." } "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": { "upg": {
"click_1": "Sharper Mandibles", "click_1": "Sharper Mandibles",
@@ -50,6 +82,38 @@
"stack_4": "Spider Caravans", "stack_4": "Spider Caravans",
"stack_5": "Great Silk Hoard", "stack_5": "Great Silk Hoard",
"auto_buy": "Spider Delivery Service" "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"
}
} }
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Insectes et tas de feuilles", "name": "Insectes et tas de feuilles",
"title": "Colonie des tas de feuilles", "title": "Colonie des tas de feuilles",
"res": { "units": "Feuilles", "unit": "feuille", "funds": "Miellat", "material": "Soie" }, "res": {
"fmt": { "funds": "{value} gouttes" }, "units": "Feuilles",
"action": { "make": "Couper une feuille", "buyMaterial": "Échanger contre de la soie" }, "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": { "gen": {
"gen1": { "name": "Fourmi ouvrière", "desc": "Porte une feuille bien plus grosse qu'elle." }, "gen1": {
"gen2": { "name": "Escouade coupeuse", "desc": "Coupe, transporte et empile en formation parfaite." }, "name": "Fourmi ouvrière",
"gen3": { "name": "Scarabée porteur", "desc": "Porteur blindé qui déplace des tas entiers." }, "desc": "Porte une feuille bien plus grosse qu'elle."
"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." }, "gen2": {
"gen6": { "name": "Reine de la ruche", "desc": "Ses phéromones coordonnent des millions d'ouvrières." } "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": { "upg": {
"click_1": "Mandibules aiguisées", "click_1": "Mandibules aiguisées",
@@ -50,6 +82,38 @@
"stack_4": "Caravanes d'araignées", "stack_4": "Caravanes d'araignées",
"stack_5": "Grand trésor de soie", "stack_5": "Grand trésor de soie",
"auto_buy": "Livraison par araignée" "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"
}
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@
--accent: #2f7d32; --accent: #2f7d32;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
--danger: #b3412a; --danger: #b3412a;
--good: #2f7d32;
--bad: #c0392b;
--radius: 12px; --radius: 12px;
--font: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif; --font: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif;
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Paperclips", "name": "Paperclips",
"title": "Paperclip Factory", "title": "Paperclip Factory",
"res": { "units": "Paperclips", "unit": "paperclip", "funds": "Funds", "material": "Wire" }, "res": {
"fmt": { "funds": "${value}" }, "units": "Paperclips",
"action": { "make": "Make a paperclip", "buyMaterial": "Buy wire" }, "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": { "gen": {
"gen1": { "name": "Autoclipper", "desc": "A small machine that bends wire on its own." }, "gen1": {
"gen2": { "name": "MegaClipper", "desc": "Ten times the appetite for wire." }, "name": "Autoclipper",
"gen3": { "name": "Clip Factory", "desc": "A whole building devoted to paperclips." }, "desc": "A small machine that bends wire on its own."
"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." }, "gen2": {
"gen6": { "name": "Matter Converter", "desc": "Turns anything into paperclips. Anything." } "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": { "upg": {
"click_1": "Ergonomic Bender", "click_1": "Ergonomic Bender",
@@ -50,6 +82,38 @@
"stack_4": "Freight Trains", "stack_4": "Freight Trains",
"stack_5": "Wire Mills", "stack_5": "Wire Mills",
"auto_buy": "WireBuyer" "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"
}
} }
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Trombones", "name": "Trombones",
"title": "Usine à trombones", "title": "Usine à trombones",
"res": { "units": "Trombones", "unit": "trombone", "funds": "Fonds", "material": "Fil" }, "res": {
"fmt": { "funds": "{value} $" }, "units": "Trombones",
"action": { "make": "Fabriquer un trombone", "buyMaterial": "Acheter du fil" }, "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": { "gen": {
"gen1": { "name": "Autotrombone", "desc": "Une petite machine qui plie le fil toute seule." }, "gen1": {
"gen2": { "name": "MégaTrombone", "desc": "Dix fois plus gourmande en fil." }, "name": "Autotrombone",
"gen3": { "name": "Usine à trombones", "desc": "Un bâtiment entier dédié aux trombones." }, "desc": "Une petite machine qui plie le fil toute seule."
"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." }, "gen2": {
"gen6": { "name": "Convertisseur de matière", "desc": "Transforme n'importe quoi en trombones. N'importe quoi." } "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": { "upg": {
"click_1": "Plieuse ergonomique", "click_1": "Plieuse ergonomique",
@@ -50,6 +82,38 @@
"stack_4": "Trains de marchandises", "stack_4": "Trains de marchandises",
"stack_5": "Tréfileries", "stack_5": "Tréfileries",
"auto_buy": "AcheteurDeFil" "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"
}
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@
--accent: #1e6fd9; --accent: #1e6fd9;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
--danger: #c0392b; --danger: #c0392b;
--good: #1a7f4b;
--bad: #c0392b;
--radius: 8px; --radius: 8px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Witch Potions", "name": "Witch Potions",
"title": "Hedge Witch Brewery", "title": "Hedge Witch Brewery",
"res": { "units": "Potions", "unit": "potion", "funds": "Gold", "material": "Herbs" }, "res": {
"fmt": { "funds": "{value} gold" }, "units": "Potions",
"action": { "make": "Brew a potion", "buyMaterial": "Gather herbs" }, "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": { "gen": {
"gen1": { "name": "Apprentice", "desc": "Stirs, sweeps and occasionally sets things on fire." }, "gen1": {
"gen2": { "name": "Bubbling Cauldron", "desc": "Brews day and night without complaint." }, "name": "Apprentice",
"gen3": { "name": "Alchemy Lab", "desc": "Glassware, grimoires and a faint smell of sulphur." }, "desc": "Stirs, sweeps and occasionally sets things on fire."
"gen4": { "name": "Broomstick Fleet", "desc": "Delivers herbs and potions faster than any cart." }, },
"gen5": { "name": "Moonlit Coven", "desc": "Thirteen witches, one very large cauldron." }, "gen2": {
"gen6": { "name": "Philosopher's Engine", "desc": "Turns lead, dreams and moonlight into potions." } "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": { "upg": {
"click_1": "Steady Stirring", "click_1": "Steady Stirring",
@@ -50,6 +82,38 @@
"stack_4": "Greenhouse Harvest", "stack_4": "Greenhouse Harvest",
"stack_5": "Whole-Forest Rights", "stack_5": "Whole-Forest Rights",
"auto_buy": "Self-Gathering Basket" "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"
}
} }
} }
} }
+73 -9
View File
@@ -2,16 +2,48 @@
"theme": { "theme": {
"name": "Potions de sorcière", "name": "Potions de sorcière",
"title": "Brasserie de la sorcière", "title": "Brasserie de la sorcière",
"res": { "units": "Potions", "unit": "potion", "funds": "Or", "material": "Herbes" }, "res": {
"fmt": { "funds": "{value} or" }, "units": "Potions",
"action": { "make": "Concocter une potion", "buyMaterial": "Cueillir des herbes" }, "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": { "gen": {
"gen1": { "name": "Apprentie", "desc": "Remue, balaie et met parfois le feu à quelque chose." }, "gen1": {
"gen2": { "name": "Chaudron bouillonnant", "desc": "Infuse jour et nuit sans se plaindre." }, "name": "Apprentie",
"gen3": { "name": "Laboratoire d'alchimie", "desc": "Verrerie, grimoires et une légère odeur de soufre." }, "desc": "Remue, balaie et met parfois le feu à quelque chose."
"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." }, "gen2": {
"gen6": { "name": "Machine du philosophe", "desc": "Transforme plomb, rêves et clair de lune en potions." } "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": { "upg": {
"click_1": "Brassage régulier", "click_1": "Brassage régulier",
@@ -50,6 +82,38 @@
"stack_4": "Récolte en serre", "stack_4": "Récolte en serre",
"stack_5": "Droits sur toute la forêt", "stack_5": "Droits sur toute la forêt",
"auto_buy": "Panier qui cueille tout seul" "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"
}
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@
--accent: #9333ea; --accent: #9333ea;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
--danger: #e0526a; --danger: #e0526a;
--good: #5fd38d;
--bad: #ff6b6b;
--radius: 14px; --radius: 14px;
--font: Georgia, 'Times New Roman', serif; --font: Georgia, 'Times New Roman', serif;
} }
+71 -1
View File
@@ -2,7 +2,7 @@
// missing translation is caught by `npm test` instead of showing a raw key. // missing translation is caught by `npm test` instead of showing a raw key.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import { flatKeys, loadContext, PUBLIC_DIR, readJson } from './helpers.js'; 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', 'theme.action.buyMaterial',
...ctx.generators.flatMap((g) => [`theme.gen.${g.id}.name`, `theme.gen.${g.id}.desc`]), ...ctx.generators.flatMap((g) => [`theme.gen.${g.id}.name`, `theme.gen.${g.id}.desc`]),
...ctx.upgrades.map((u) => `theme.upg.${u.id}`), ...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) { 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');
});
-7
View File
@@ -311,10 +311,3 @@ test('state survives a serialize / sanitize round trip', () => {
assert.equal(back.marketing, 3); 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);
});
+3 -5
View File
@@ -1,17 +1,15 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { buildContext } from '../public/js/engine/context.js'; 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 PUBLIC_DIR = fileURLToPath(new URL('../public/', import.meta.url));
export const readJson = (relativePath) => JSON.parse(readFileSync(PUBLIC_DIR + relativePath, 'utf8')); export const readJson = (relativePath) => JSON.parse(readFileSync(PUBLIC_DIR + relativePath, 'utf8'));
export function loadContext() { export function loadContext() {
return buildContext({ const entries = Object.entries(DATA_FILES).map(([key, file]) => [key, readJson(`data/${file}.json`)]);
rules: readJson('data/rules.json'), return buildContext(Object.fromEntries(entries));
generators: readJson('data/generators.json'),
upgrades: readJson('data/upgrades.json'),
});
} }
/** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */ /** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */
+579
View File
@@ -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);
});