feat: New features (research, invest, gambling, new game+, trophies)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
]
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
+45
-35
@@ -14,48 +14,49 @@
|
|||||||
<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">
|
||||||
<label class="field">
|
<span aria-hidden="true">☰</span> <span data-i18n="ui.menu">Menu</span>
|
||||||
<span class="field__label" data-i18n="ui.theme">Theme</span>
|
</button>
|
||||||
<select id="theme-select"></select>
|
<!-- Tabs + controls: a burger menu on phones, a tab bar from tablet width (see css/layout.css) -->
|
||||||
</label>
|
<nav class="menu" id="menu" data-i18n-attr="aria-label:ui.menu">
|
||||||
<label class="field">
|
<ul class="tabs" id="tabs"></ul>
|
||||||
<span class="field__label" data-i18n="ui.language">Language</span>
|
<div class="menu__controls">
|
||||||
<select id="locale-select"></select>
|
<label class="field">
|
||||||
</label>
|
<span class="field__label" data-i18n="ui.theme">Theme</span>
|
||||||
<button class="btn btn--ghost" id="open-settings" type="button" data-i18n="ui.settings">Save & settings</button>
|
<select id="theme-select"></select>
|
||||||
</div>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label" data-i18n="ui.language">Language</span>
|
||||||
|
<select id="locale-select"></select>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--ghost" id="open-settings" type="button" data-i18n="ui.settings">Save & settings</button>
|
||||||
|
</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 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+64
-16
@@ -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) {
|
||||||
|
checkEffect(`Research "${node.id}"`, node.effect, generatorsById);
|
||||||
|
for (const req of node.requires) {
|
||||||
|
if (!researchById.has(req)) throw new Error(`Research "${node.id}": unknown requirement "${req}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const perk of legacy) checkEffect(`Perk "${perk.id}"`, perk.effect, generatorsById);
|
||||||
|
for (const a of achievements) checkEffect(`Achievement "${a.id}"`, a.effect, generatorsById);
|
||||||
|
for (const s of investments.societies) {
|
||||||
|
if (!investments.tiers[s.tier]) throw new Error(`Society "${s.id}": unknown tier "${s.tier}"`);
|
||||||
|
}
|
||||||
|
|
||||||
return { rules, generators, upgrades, generatorsById, upgradesById };
|
return {
|
||||||
|
rules,
|
||||||
|
generators,
|
||||||
|
upgrades,
|
||||||
|
research,
|
||||||
|
legacy,
|
||||||
|
achievements,
|
||||||
|
investments,
|
||||||
|
tabs,
|
||||||
|
generatorsById,
|
||||||
|
upgradesById,
|
||||||
|
researchById,
|
||||||
|
perksById,
|
||||||
|
achievementsById,
|
||||||
|
societiesById,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+58
-16
@@ -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
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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; } };
|
||||||
|
}
|
||||||
@@ -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)}%`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user