feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
.claude
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
tests
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude/
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# One small image runs the whole game: a zero-dependency Node server that
|
||||||
|
# serves the static frontend and lists the installed themes/languages.
|
||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Files stay owned by root and read-only for the runtime user: the app can't modify itself.
|
||||||
|
COPY package.json ./
|
||||||
|
COPY server ./server
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
# Run as the unprivileged "node" user (uid 1000) that ships with the image.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
||||||
|
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
@@ -1,2 +1,74 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
Zero runtime dependencies: a small Node server (`http` module only) plus a vanilla JS frontend (ES modules).
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # http://localhost:8080 (GAME_PORT=9000 to change the port)
|
||||||
|
```
|
||||||
|
|
||||||
|
The container is a single image running as the unprivileged `node` user, with a read-only filesystem, no capabilities and `no-new-privileges`.
|
||||||
|
|
||||||
|
Without Docker: `npm start` (Node 22+). Tests: `npm test`.
|
||||||
|
|
||||||
|
## How the project is organised
|
||||||
|
|
||||||
|
```
|
||||||
|
server/ static file server + /api/themes and /api/locales (auto-discovery)
|
||||||
|
public/
|
||||||
|
index.html page skeleton (texts come from i18n via data-i18n attributes)
|
||||||
|
css/ base (tokens) · layout (responsive grid) · components
|
||||||
|
data/ GAME CONTENT, theme-independent: rules, generators, upgrades (ids + numbers)
|
||||||
|
locales/ core UI texts, one file per language (en, fr)
|
||||||
|
themes/<id>/ ONE FOLDER PER THEME
|
||||||
|
theme.json order + (later) image paths
|
||||||
|
theme.css colours/fonts, as CSS variables
|
||||||
|
locales/<lang>.json the theme's vocabulary: names of resources, generators, upgrades
|
||||||
|
js/
|
||||||
|
engine/ game rules, pure JS, no DOM (also used by the tests)
|
||||||
|
services/ cookies · storage · save import/export · i18n · formatting · data loading
|
||||||
|
ui/ DOM helpers · main view · settings dialog · toasts
|
||||||
|
main.js wires everything together
|
||||||
|
tests/ node:test suites (engine, 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.
|
||||||
|
|
||||||
|
## Add a theme
|
||||||
|
|
||||||
|
1. Copy `public/themes/paperclip/` to `public/themes/<your-id>/` (lowercase letters, digits, dashes).
|
||||||
|
2. Edit `theme.css` (palette, font, radius), `theme.json` (`order`), and the texts in `locales/*.json`.
|
||||||
|
3. Reload. The server discovers the folder; no code change and no rebuild list. (With Docker, rebuild the image or mount the folder.)
|
||||||
|
|
||||||
|
`npm test` fails if a theme misses a name for any resource, generator or upgrade, or lacks a language the game supports.
|
||||||
|
|
||||||
|
## Add a language
|
||||||
|
|
||||||
|
1. Copy `public/locales/en.json` to `public/locales/<code>.json` and translate it (set `meta.name` to the language's own name).
|
||||||
|
2. Add `public/themes/<id>/locales/<code>.json` to every theme.
|
||||||
|
|
||||||
|
Missing keys fall back to English, then to the raw key. Strings accept `{param}` and `{@other.key}` (embeds another entry, which is how core texts use the theme's vocabulary, e.g. `{@theme.res.material}`).
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
Every icon is currently a grey square. To use real images, put files in the theme folder and list them in its `theme.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "order": 1, "assets": { "gen.gen1": "img/autoclipper.svg", "res.units": "img/clip.svg", "upg.click_1": "img/bender.svg" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys: `res.units|funds|material`, `gen.<id>`, `upg.<id>`. Anything not listed keeps the placeholder.
|
||||||
|
|
||||||
|
## Saves
|
||||||
|
|
||||||
|
- Each theme has its own save slot, stored in cookies (`ic_save_<theme>_*`, chunked and base64-encoded because a cookie holds ~4 KB), plus one preferences cookie (theme + language).
|
||||||
|
- **Save & settings** exports the current theme's save as `.json` or `.txt` (download or copy/paste) and imports either (file picker or pasted text). Imports are validated and sanitised: unknown ids are dropped, numbers clamped.
|
||||||
|
- Progress continues while the game is closed: up to 8 h, at 50 % efficiency (`public/data/rules.json`).
|
||||||
|
- Cookies are sent with every request to the server. A save is under 2 KB and there is one per theme, so this stays far below header limits, but it is the reason to keep saves compact.
|
||||||
|
|
||||||
|
## Tuning the game
|
||||||
|
|
||||||
|
Everything numeric is in `public/data/`: `rules.json` (market, marketing, offline, autosave), `generators.json`, `upgrades.json`. Customers want `baseDemand × demandMultiplier` units/s at the *fair price* and demand collapses above it, so: raise the price when supply is short, buy marketing/demand upgrades to sell more.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
game:
|
||||||
|
build: .
|
||||||
|
image: idle-clicker:latest
|
||||||
|
ports:
|
||||||
|
- "${GAME_PORT:-8080}:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
|
||||||
|
# Hardening: the game needs no writes, no privileges and no extra capabilities.
|
||||||
|
user: "1000:1000"
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 100
|
||||||
|
mem_limit: 128m
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "idle-clicker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A themeable idle game inspired by Universal Paperclips. Zero runtime dependencies.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Base: reset + the design tokens every theme can override.
|
||||||
|
* A theme (themes/<id>/theme.css) only redefines these custom properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--bg: #f3f4f6;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eceef2;
|
||||||
|
--text: #1d2330;
|
||||||
|
--text-muted: #5b6475;
|
||||||
|
--border: #d5d9e0;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--danger: #c0392b;
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
|
--gap: 0.75rem;
|
||||||
|
--icon-size: 3rem;
|
||||||
|
--placeholder: #9aa0a6; /* the grey square used until real images exist */
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 1rem/1.45 var(--font);
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
|
||||||
|
env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p,
|
||||||
|
dl,
|
||||||
|
dd,
|
||||||
|
ul,
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
/* Reusable UI pieces. Colours come from the tokens in base.css / the theme. */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- panels */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--gap);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- icons */
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex: none;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--placeholder {
|
||||||
|
display: block;
|
||||||
|
background: var(--placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- buttons */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary small {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--big {
|
||||||
|
min-height: 84px;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--wide {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--square {
|
||||||
|
width: 44px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--buy {
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 7.5rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn__label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- form fields */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
.price__input,
|
||||||
|
.textarea {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- overview */
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile--primary {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile__label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile__value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem var(--gap);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats dd {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- price */
|
||||||
|
|
||||||
|
.price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price__label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price__slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px; /* easy to grab on touch screens */
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- generators & upgrades */
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__desc,
|
||||||
|
.card__meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__meta {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow phones: the buy button drops under the text. */
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.card {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__body {
|
||||||
|
flex-basis: calc(100% - var(--icon-size) - var(--gap));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--buy {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 0;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input:checked + span {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input:focus-visible + span {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- dialog */
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: min(40rem, calc(100vw - 2rem));
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__subtitle {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- toasts */
|
||||||
|
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||||
|
left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
max-width: 32rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* Page layout. Mobile first: one column, then 2 and 3 columns as space allows. */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: var(--gap) 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
'stats'
|
||||||
|
'production'
|
||||||
|
'market'
|
||||||
|
'generators'
|
||||||
|
'upgrades';
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--stats { grid-area: stats; }
|
||||||
|
.panel--production { grid-area: production; }
|
||||||
|
.panel--market { grid-area: market; }
|
||||||
|
.panel--generators { grid-area: generators; }
|
||||||
|
.panel--upgrades { grid-area: upgrades; }
|
||||||
|
|
||||||
|
/* Tablet */
|
||||||
|
@media (min-width: 760px) {
|
||||||
|
.game {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-areas:
|
||||||
|
'stats stats'
|
||||||
|
'production market'
|
||||||
|
'generators upgrades';
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.game {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr);
|
||||||
|
grid-template-areas:
|
||||||
|
'stats stats stats'
|
||||||
|
'production generators upgrades'
|
||||||
|
'market generators upgrades';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{ "id": "gen1", "baseCost": 5, "costGrowth": 1.15, "baseRate": 1, "unlock": { "totalProduced": 10 } },
|
||||||
|
{ "id": "gen2", "baseCost": 75, "costGrowth": 1.15, "baseRate": 8, "unlock": { "totalProduced": 100 } },
|
||||||
|
{ "id": "gen3", "baseCost": 1000, "costGrowth": 1.15, "baseRate": 60, "unlock": { "totalProduced": 1000 } },
|
||||||
|
{ "id": "gen4", "baseCost": 12500, "costGrowth": 1.15, "baseRate": 440, "unlock": { "totalProduced": 10000 } },
|
||||||
|
{ "id": "gen5", "baseCost": 150000, "costGrowth": 1.15, "baseRate": 3200, "unlock": { "totalProduced": 100000 } },
|
||||||
|
{ "id": "gen6", "baseCost": 2000000, "costGrowth": 1.15, "baseRate": 24000, "unlock": { "totalProduced": 1000000 } }
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"start": { "units": 0, "funds": 0, "material": 100, "price": 0.25 },
|
||||||
|
"click": { "yield": 1 },
|
||||||
|
"unit": { "materialPerUnit": 1 },
|
||||||
|
"market": {
|
||||||
|
"fairPrice": 0.25,
|
||||||
|
"baseDemand": 2.5,
|
||||||
|
"elasticity": 2,
|
||||||
|
"minPrice": 0.01,
|
||||||
|
"maxPrice": 50,
|
||||||
|
"priceStep": 0.01
|
||||||
|
},
|
||||||
|
"materialMarket": {
|
||||||
|
"batchSize": 100,
|
||||||
|
"basePrice": 2,
|
||||||
|
"minFactor": 0.5,
|
||||||
|
"maxFactor": 2,
|
||||||
|
"volatility": 0.12,
|
||||||
|
"reversion": 0.05,
|
||||||
|
"updateEverySeconds": 2
|
||||||
|
},
|
||||||
|
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
|
||||||
|
"offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 },
|
||||||
|
"autosaveSeconds": 10
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{ "id": "click_1", "cost": 25, "unlock": { "totalProduced": 20 }, "effect": { "type": "click_mult", "value": 2 } },
|
||||||
|
{ "id": "click_2", "cost": 400, "unlock": { "totalProduced": 300 }, "effect": { "type": "click_mult", "value": 3 } },
|
||||||
|
{ "id": "click_3", "cost": 15000, "unlock": { "totalProduced": 10000 }, "effect": { "type": "click_mult", "value": 5 } },
|
||||||
|
|
||||||
|
{ "id": "g1_a", "cost": 100, "unlock": { "generator": "gen1", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen1", "value": 2 } },
|
||||||
|
{ "id": "g1_b", "cost": 1500, "unlock": { "generator": "gen1", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen1", "value": 2 } },
|
||||||
|
{ "id": "g2_a", "cost": 1500, "unlock": { "generator": "gen2", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen2", "value": 2 } },
|
||||||
|
{ "id": "g2_b", "cost": 20000, "unlock": { "generator": "gen2", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen2", "value": 2 } },
|
||||||
|
{ "id": "g3_a", "cost": 20000, "unlock": { "generator": "gen3", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen3", "value": 2 } },
|
||||||
|
{ "id": "g3_b", "cost": 250000, "unlock": { "generator": "gen3", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen3", "value": 2 } },
|
||||||
|
{ "id": "g4_a", "cost": 250000, "unlock": { "generator": "gen4", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen4", "value": 2 } },
|
||||||
|
{ "id": "g4_b", "cost": 3000000, "unlock": { "generator": "gen4", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen4", "value": 2 } },
|
||||||
|
{ "id": "g5_a", "cost": 3000000, "unlock": { "generator": "gen5", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen5", "value": 2 } },
|
||||||
|
{ "id": "g5_b", "cost": 40000000, "unlock": { "generator": "gen5", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen5", "value": 2 } },
|
||||||
|
{ "id": "g6_a", "cost": 40000000, "unlock": { "generator": "gen6", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen6", "value": 2 } },
|
||||||
|
{ "id": "g6_b", "cost": 500000000, "unlock": { "generator": "gen6", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen6", "value": 2 } },
|
||||||
|
|
||||||
|
{ "id": "global_1", "cost": 5000, "unlock": { "totalProduced": 5000 }, "effect": { "type": "global_mult", "value": 1.5 } },
|
||||||
|
{ "id": "global_2", "cost": 100000, "unlock": { "totalProduced": 50000 }, "effect": { "type": "global_mult", "value": 2 } },
|
||||||
|
{ "id": "global_3", "cost": 2000000, "unlock": { "totalProduced": 1000000 }, "effect": { "type": "global_mult", "value": 2 } },
|
||||||
|
{ "id": "global_4", "cost": 50000000, "unlock": { "totalProduced": 10000000 }, "effect": { "type": "global_mult", "value": 3 } },
|
||||||
|
|
||||||
|
{ "id": "demand_1", "cost": 300, "unlock": { "totalProduced": 200 }, "effect": { "type": "demand_mult", "value": 1.5 } },
|
||||||
|
{ "id": "demand_2", "cost": 8000, "unlock": { "totalProduced": 3000 }, "effect": { "type": "demand_mult", "value": 2 } },
|
||||||
|
{ "id": "demand_3", "cost": 200000, "unlock": { "totalProduced": 50000 }, "effect": { "type": "demand_mult", "value": 2 } },
|
||||||
|
{ "id": "demand_4", "cost": 5000000, "unlock": { "totalProduced": 500000 }, "effect": { "type": "demand_mult", "value": 3 } },
|
||||||
|
|
||||||
|
{ "id": "value_1", "cost": 1000, "unlock": { "totalProduced": 1000 }, "effect": { "type": "value_mult", "value": 1.25 } },
|
||||||
|
{ "id": "value_2", "cost": 50000, "unlock": { "totalProduced": 20000 }, "effect": { "type": "value_mult", "value": 1.5 } },
|
||||||
|
{ "id": "value_3", "cost": 3000000, "unlock": { "totalProduced": 500000 }, "effect": { "type": "value_mult", "value": 2 } },
|
||||||
|
|
||||||
|
{ "id": "mat_eff_1", "cost": 200, "unlock": { "totalProduced": 300 }, "effect": { "type": "material_efficiency", "value": 1.25 } },
|
||||||
|
{ "id": "mat_eff_2", "cost": 10000, "unlock": { "totalProduced": 5000 }, "requires": "mat_eff_1", "effect": { "type": "material_efficiency", "value": 1.5 } },
|
||||||
|
{ "id": "mat_disc_1", "cost": 500, "unlock": { "totalProduced": 500 }, "effect": { "type": "batch_discount", "value": 0.8 } },
|
||||||
|
{ "id": "mat_disc_2", "cost": 20000, "unlock": { "totalProduced": 8000 }, "requires": "mat_disc_1", "effect": { "type": "batch_discount", "value": 0.7 } },
|
||||||
|
{ "id": "auto_buy", "cost": 800, "unlock": { "totalProduced": 800 }, "effect": { "type": "auto_buy_material" } }
|
||||||
|
]
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Idle Clicker</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<!-- The theme stylesheet (themes/<id>/theme.css) is injected by js/services/themes.js -->
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1 class="topbar__title" id="game-title" data-i18n="theme.title">Idle Clicker</h1>
|
||||||
|
<div class="topbar__controls">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label" data-i18n="ui.theme">Theme</span>
|
||||||
|
<select id="theme-select"></select>
|
||||||
|
</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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="banner" id="cookie-warning" hidden data-i18n="toast.cookiesBlocked"></p>
|
||||||
|
|
||||||
|
<main class="game" id="game">
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="panel panel--stats" aria-labelledby="h-stats">
|
||||||
|
<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">
|
||||||
|
<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.demand"></dt><dd id="v-demand">0</dd></div>
|
||||||
|
<div><dt data-i18n="ui.income"></dt><dd id="v-income">0</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Manual production + raw material -->
|
||||||
|
<section class="panel panel--production" aria-labelledby="h-production">
|
||||||
|
<h2 class="panel__title" id="h-production" data-i18n="ui.manual">Manual work</h2>
|
||||||
|
<button class="btn btn--primary btn--big" id="make" type="button">
|
||||||
|
<span data-i18n="theme.action.make"></span>
|
||||||
|
<small id="make-yield"></small>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--wide" id="buy-material" type="button">
|
||||||
|
<span data-i18n="theme.action.buyMaterial"></span>
|
||||||
|
<small id="buy-material-info"></small>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Selling -->
|
||||||
|
<section class="panel panel--market" aria-labelledby="h-market">
|
||||||
|
<h2 class="panel__title" id="h-market" data-i18n="ui.market">Market</h2>
|
||||||
|
<div class="price">
|
||||||
|
<span class="price__label" data-i18n="ui.price"></span>
|
||||||
|
<div class="price__row">
|
||||||
|
<button class="btn btn--square" id="price-down" type="button" data-i18n-attr="aria-label:ui.priceDown">−</button>
|
||||||
|
<input class="price__input" id="price-input" type="number" inputmode="decimal" min="0.01" step="0.01">
|
||||||
|
<button class="btn btn--square" id="price-up" type="button" data-i18n-attr="aria-label:ui.priceUp">+</button>
|
||||||
|
</div>
|
||||||
|
<input class="price__slider" id="price-slider" type="range" min="0" max="1000" step="1" data-i18n-attr="aria-label:ui.price">
|
||||||
|
<p class="hint" id="price-hint"></p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--wide" id="buy-marketing" type="button">
|
||||||
|
<span data-i18n="ui.marketing"></span>
|
||||||
|
<small id="buy-marketing-info"></small>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Automation -->
|
||||||
|
<section class="panel panel--generators" aria-labelledby="h-generators">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title" id="h-generators" data-i18n="ui.automation">Automation</h2>
|
||||||
|
<fieldset class="segmented" id="buy-amount">
|
||||||
|
<legend class="sr-only" data-i18n="ui.buyAmount"></legend>
|
||||||
|
<label><input type="radio" name="buy-amount" value="1" checked><span>×1</span></label>
|
||||||
|
<label><input type="radio" name="buy-amount" value="10"><span>×10</span></label>
|
||||||
|
<label><input type="radio" name="buy-amount" value="100"><span>×100</span></label>
|
||||||
|
<label><input type="radio" name="buy-amount" value="max"><span data-i18n="ui.max"></span></label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<ul class="cards" id="generators"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Upgrades -->
|
||||||
|
<section class="panel panel--upgrades" aria-labelledby="h-upgrades">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title" id="h-upgrades" data-i18n="ui.upgrades">Upgrades</h2>
|
||||||
|
<span class="badge" id="upgrade-count"></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="upgrades-empty" data-i18n="ui.noUpgrades"></p>
|
||||||
|
<ul class="cards" id="upgrades"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Save & settings -->
|
||||||
|
<dialog class="dialog" id="settings" aria-labelledby="h-settings">
|
||||||
|
<form method="dialog" class="dialog__body">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title" id="h-settings" data-i18n="settings.title"></h2>
|
||||||
|
<button class="btn btn--ghost" value="close" data-i18n="ui.close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="dialog__subtitle" data-i18n="settings.export"></h3>
|
||||||
|
<p class="hint" data-i18n="settings.exportHelp"></p>
|
||||||
|
<textarea class="textarea" id="export-text" rows="5" readonly spellcheck="false" data-i18n-attr="aria-label:settings.export"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" type="button" id="export-json" data-i18n="settings.downloadJson"></button>
|
||||||
|
<button class="btn" type="button" id="export-txt" data-i18n="settings.downloadTxt"></button>
|
||||||
|
<button class="btn" type="button" id="export-copy" data-i18n="settings.copy"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="dialog__subtitle" data-i18n="settings.import"></h3>
|
||||||
|
<p class="hint" data-i18n="settings.importHelp"></p>
|
||||||
|
<div class="row">
|
||||||
|
<label class="btn" for="import-file" data-i18n="settings.importFile"></label>
|
||||||
|
<input class="sr-only" id="import-file" type="file" accept=".json,.txt,application/json,text/plain">
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea" id="import-text" rows="4" spellcheck="false" data-i18n-attr="aria-label:settings.import;placeholder:settings.pastePlaceholder"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" type="button" id="import-paste" data-i18n="settings.importText"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="dialog__subtitle" data-i18n="settings.danger"></h3>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn btn--danger" type="button" id="reset-game" data-i18n="settings.reset"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { EFFECT_TYPES } from './effects.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle the static game data (data/*.json) with lookup tables and validate it,
|
||||||
|
* so a typo in a JSON file fails loudly at startup instead of mid-game.
|
||||||
|
*/
|
||||||
|
export function buildContext({ rules, generators, upgrades }) {
|
||||||
|
const generatorsById = new Map(generators.map((g) => [g.id, g]));
|
||||||
|
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
|
||||||
|
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
|
||||||
|
|
||||||
|
for (const u of upgrades) {
|
||||||
|
if (!EFFECT_TYPES.includes(u.effect?.type)) {
|
||||||
|
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
|
||||||
|
}
|
||||||
|
if (u.effect.target && !generatorsById.has(u.effect.target)) {
|
||||||
|
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
|
||||||
|
}
|
||||||
|
if (u.requires && !upgradesById.has(u.requires)) {
|
||||||
|
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rules, generators, upgrades, generatorsById, upgradesById };
|
||||||
|
}
|
||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
// Upgrade effects. To add a new effect type: register it here, add its
|
||||||
|
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
|
||||||
|
|
||||||
|
export function newModifiers() {
|
||||||
|
return {
|
||||||
|
click: 1,
|
||||||
|
generators: {},
|
||||||
|
global: 1,
|
||||||
|
demand: 1,
|
||||||
|
value: 1,
|
||||||
|
materialEfficiency: 1,
|
||||||
|
batchDiscount: 1,
|
||||||
|
autoBuyMaterial: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPLY = {
|
||||||
|
click_mult: (m, e) => void (m.click *= e.value),
|
||||||
|
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
|
||||||
|
global_mult: (m, e) => void (m.global *= e.value),
|
||||||
|
demand_mult: (m, e) => void (m.demand *= e.value),
|
||||||
|
value_mult: (m, e) => void (m.value *= e.value),
|
||||||
|
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
|
||||||
|
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
|
||||||
|
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EFFECT_TYPES = Object.keys(APPLY);
|
||||||
|
|
||||||
|
/** Fold every owned upgrade into a single set of multipliers. */
|
||||||
|
export function computeModifiers(ownedIds, upgradesById) {
|
||||||
|
const m = newModifiers();
|
||||||
|
for (const id of ownedIds) {
|
||||||
|
const effect = upgradesById.get(id)?.effect;
|
||||||
|
if (effect) APPLY[effect.type]?.(m, effect);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Game rules. Every function takes the mutable `state` and the static `ctx`
|
||||||
|
// (see context.js) and never touches the DOM, so it runs the same in the
|
||||||
|
// browser and in Node (tests, balance simulations).
|
||||||
|
|
||||||
|
import { computeModifiers } from './effects.js';
|
||||||
|
import { bulkCost, clamp, demandPerSecond, maxAffordable } from './formulas.js';
|
||||||
|
|
||||||
|
const MAX_STEP_SECONDS = 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- conditions
|
||||||
|
|
||||||
|
export function isUnlocked(state, condition = {}) {
|
||||||
|
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false;
|
||||||
|
if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upgradeVisible(state, upgrade) {
|
||||||
|
if (state.upgrades.includes(upgrade.id)) return false;
|
||||||
|
if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false;
|
||||||
|
return isUnlocked(state, upgrade.unlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- derived
|
||||||
|
|
||||||
|
/** Everything computed from the state (rates, multipliers, current prices). */
|
||||||
|
export function derive(state, ctx) {
|
||||||
|
const { rules } = ctx;
|
||||||
|
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
|
||||||
|
|
||||||
|
const perGenerator = {};
|
||||||
|
let rate = 0;
|
||||||
|
for (const g of ctx.generators) {
|
||||||
|
perGenerator[g.id] = g.baseRate * (mods.generators[g.id] ?? 1) * mods.global;
|
||||||
|
rate += perGenerator[g.id] * (state.generators[g.id] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing;
|
||||||
|
return {
|
||||||
|
clickYield: rules.click.yield * mods.click,
|
||||||
|
perGenerator,
|
||||||
|
rate,
|
||||||
|
demandMultiplier,
|
||||||
|
demand: demandPerSecond(rules.market, state.price, demandMultiplier),
|
||||||
|
valueMultiplier: mods.value,
|
||||||
|
materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency,
|
||||||
|
batchSize: rules.materialMarket.batchSize,
|
||||||
|
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount),
|
||||||
|
autoBuyMaterial: mods.autoBuyMaterial,
|
||||||
|
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- actions
|
||||||
|
// Each returns true when the action was performed.
|
||||||
|
|
||||||
|
export function click(state, ctx) {
|
||||||
|
const d = derive(state, ctx);
|
||||||
|
if (state.material < d.materialPerUnit) return false;
|
||||||
|
const made = Math.min(d.clickYield, state.material / d.materialPerUnit);
|
||||||
|
state.material -= made * d.materialPerUnit;
|
||||||
|
state.units += made;
|
||||||
|
state.totalProduced += made;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buyMaterial(state, ctx) {
|
||||||
|
const d = derive(state, ctx);
|
||||||
|
if (state.funds < d.batchCost) return false;
|
||||||
|
state.funds -= d.batchCost;
|
||||||
|
state.material += d.batchSize;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buyMarketing(state, ctx) {
|
||||||
|
const d = derive(state, ctx);
|
||||||
|
if (state.funds < d.marketingCost) return false;
|
||||||
|
state.funds -= d.marketingCost;
|
||||||
|
state.marketing += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many generators the player gets for `amount` ("max" or a number). */
|
||||||
|
export function generatorQuote(state, ctx, id, amount) {
|
||||||
|
const g = ctx.generatorsById.get(id);
|
||||||
|
const owned = state.generators[id] ?? 0;
|
||||||
|
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
|
||||||
|
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buyGenerator(state, ctx, id, amount = 1) {
|
||||||
|
if (!ctx.generatorsById.has(id)) return false;
|
||||||
|
const { count, cost } = generatorQuote(state, ctx, id, amount);
|
||||||
|
if (count <= 0 || state.funds < cost) return false;
|
||||||
|
state.funds -= cost;
|
||||||
|
state.generators[id] += count;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buyUpgrade(state, ctx, id) {
|
||||||
|
const upgrade = ctx.upgradesById.get(id);
|
||||||
|
if (!upgrade || !upgradeVisible(state, upgrade) || state.funds < upgrade.cost) return false;
|
||||||
|
state.funds -= upgrade.cost;
|
||||||
|
state.upgrades.push(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPrice(state, ctx, price) {
|
||||||
|
const { minPrice, maxPrice } = ctx.rules.market;
|
||||||
|
if (!Number.isFinite(price)) return false;
|
||||||
|
state.price = Math.round(clamp(price, minPrice, maxPrice) * 100) / 100;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- time
|
||||||
|
|
||||||
|
function stepMaterialMarket(state, ctx, dt, rng) {
|
||||||
|
const m = ctx.rules.materialMarket;
|
||||||
|
state.materialTimer += dt;
|
||||||
|
while (state.materialTimer >= m.updateEverySeconds) {
|
||||||
|
state.materialTimer -= m.updateEverySeconds;
|
||||||
|
const shock = 1 + (rng() - 0.5) * 2 * m.volatility;
|
||||||
|
const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion;
|
||||||
|
state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(state, ctx, dt, rng, totals) {
|
||||||
|
stepMaterialMarket(state, ctx, dt, rng);
|
||||||
|
const d = derive(state, ctx);
|
||||||
|
|
||||||
|
// Production, limited by the material in stock.
|
||||||
|
const made = Math.min(d.rate * dt, state.material / d.materialPerUnit);
|
||||||
|
state.material -= made * d.materialPerUnit;
|
||||||
|
state.units += made;
|
||||||
|
state.totalProduced += made;
|
||||||
|
|
||||||
|
// Automatic restock when the stock runs low.
|
||||||
|
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) {
|
||||||
|
buyMaterial(state, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customers buy what they want, up to what is in stock.
|
||||||
|
const sold = Math.min(state.units, d.demand * dt);
|
||||||
|
const revenue = sold * state.price * d.valueMultiplier;
|
||||||
|
state.units -= sold;
|
||||||
|
state.funds += revenue;
|
||||||
|
state.totalSold += sold;
|
||||||
|
|
||||||
|
totals.produced += made;
|
||||||
|
totals.sold += sold;
|
||||||
|
totals.revenue += revenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance the simulation by `seconds`. Returns what happened during that time. */
|
||||||
|
export function advance(state, ctx, seconds, rng = Math.random) {
|
||||||
|
const totals = { produced: 0, sold: 0, revenue: 0 };
|
||||||
|
let left = Math.max(0, seconds);
|
||||||
|
while (left > 1e-9) {
|
||||||
|
const dt = Math.min(left, MAX_STEP_SECONDS);
|
||||||
|
step(state, ctx, dt, rng, totals);
|
||||||
|
left -= dt;
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch up on the time the game was closed. Deterministic (no price noise)
|
||||||
|
* and discounted by `offline.efficiency`. Returns null if too short to matter.
|
||||||
|
*/
|
||||||
|
export function simulateOffline(state, ctx, elapsedSeconds) {
|
||||||
|
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
|
||||||
|
if (elapsedSeconds < minSeconds) return null;
|
||||||
|
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
|
||||||
|
const totals = advance(state, ctx, seconds, () => 0.5);
|
||||||
|
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Pure math helpers. No state, no DOM.
|
||||||
|
|
||||||
|
export function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price of the next item when `owned` items are already owned. */
|
||||||
|
export function unitCost(base, growth, owned) {
|
||||||
|
return base * growth ** owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total price of buying `count` more items (geometric series, rounded up). */
|
||||||
|
export function bulkCost(base, growth, owned, count) {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
return Math.ceil((unitCost(base, growth, owned) * (growth ** count - 1)) / (growth - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many items `budget` can pay for, robust against float error. */
|
||||||
|
export function maxAffordable(base, growth, owned, budget) {
|
||||||
|
const first = unitCost(base, growth, owned);
|
||||||
|
if (Number.isNaN(budget) || budget < first) return 0;
|
||||||
|
let n = Math.floor(Math.log((budget * (growth - 1)) / first + 1) / Math.log(growth));
|
||||||
|
while (n > 0 && bulkCost(base, growth, owned, n) > budget) n--;
|
||||||
|
while (bulkCost(base, growth, owned, n + 1) <= budget) n++;
|
||||||
|
return Math.max(n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Units customers want to buy per second at `price`.
|
||||||
|
* Demand is maxed at or below the fair price and drops steeply above it, so
|
||||||
|
* extra production is only worth selling at a higher price while supply is
|
||||||
|
* short; to sell more at the fair price, the player needs more demand.
|
||||||
|
*/
|
||||||
|
export function demandPerSecond(market, price, multiplier) {
|
||||||
|
const overpriced = Math.max(1, price / market.fairPrice);
|
||||||
|
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { clamp } from './formulas.js';
|
||||||
|
|
||||||
|
export const STATE_VERSION = 1;
|
||||||
|
|
||||||
|
const MAX_NUMBER = 1e300;
|
||||||
|
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
|
||||||
|
const MAX_MARKETING = 300;
|
||||||
|
|
||||||
|
export function createState(ctx, now = Date.now()) {
|
||||||
|
const { start, market } = ctx.rules;
|
||||||
|
return {
|
||||||
|
v: STATE_VERSION,
|
||||||
|
units: start.units,
|
||||||
|
funds: start.funds,
|
||||||
|
material: start.material,
|
||||||
|
totalProduced: 0,
|
||||||
|
totalSold: 0,
|
||||||
|
price: start.price ?? market.basePrice,
|
||||||
|
marketing: 0,
|
||||||
|
materialFactor: 1,
|
||||||
|
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
|
||||||
|
upgrades: [],
|
||||||
|
savedAt: now,
|
||||||
|
materialTimer: 0, // transient, never saved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value, fallback, min = 0, max = MAX_NUMBER) {
|
||||||
|
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(value, max) {
|
||||||
|
return Math.floor(num(value, 0, 0, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function sanitizeState(raw, ctx, now = Date.now()) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
const { market, materialMarket } = ctx.rules;
|
||||||
|
const base = createState(ctx, now);
|
||||||
|
|
||||||
|
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
units: num(raw.units, base.units),
|
||||||
|
funds: num(raw.funds, base.funds),
|
||||||
|
material: num(raw.material, base.material),
|
||||||
|
totalProduced: num(raw.totalProduced, 0),
|
||||||
|
totalSold: num(raw.totalSold, 0),
|
||||||
|
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
|
||||||
|
marketing: int(raw.marketing, MAX_MARKETING),
|
||||||
|
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
|
||||||
|
generators: Object.fromEntries(
|
||||||
|
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
|
||||||
|
),
|
||||||
|
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
|
||||||
|
savedAt: num(raw.savedAt, now, 0, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
|
||||||
|
export function serializeState(state) {
|
||||||
|
const { materialTimer, ...persisted } = state;
|
||||||
|
return JSON.stringify(persisted, (_key, value) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
|
||||||
|
|
||||||
|
import {
|
||||||
|
advance,
|
||||||
|
buyGenerator,
|
||||||
|
buyMarketing,
|
||||||
|
buyMaterial,
|
||||||
|
buyUpgrade,
|
||||||
|
click,
|
||||||
|
derive,
|
||||||
|
setPrice,
|
||||||
|
simulateOffline,
|
||||||
|
} from './engine/engine.js';
|
||||||
|
import { createState, sanitizeState, serializeState } from './engine/state.js';
|
||||||
|
import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
|
||||||
|
import { createFormatter } from './services/format.js';
|
||||||
|
import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js';
|
||||||
|
import { buildExport, parseImport } from './services/saveio.js';
|
||||||
|
import { cookiesAvailable, loadGameText, loadPrefs, saveGameText, savePrefs, deleteGame } from './services/storage.js';
|
||||||
|
import { applyThemeStyles } from './services/themes.js';
|
||||||
|
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
|
||||||
|
import { createSettings } from './ui/settings.js';
|
||||||
|
import { showToast } from './ui/toast.js';
|
||||||
|
import { createView } from './ui/view.js';
|
||||||
|
|
||||||
|
const TICK_MS = 100;
|
||||||
|
const INCOME_SMOOTHING_SECONDS = 3;
|
||||||
|
const SAVE_ERROR_COOLDOWN_MS = 60_000;
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
let themes = [];
|
||||||
|
let locales = [];
|
||||||
|
let themeId;
|
||||||
|
let locale;
|
||||||
|
let state;
|
||||||
|
let fmt;
|
||||||
|
let income = 0;
|
||||||
|
let lastSaveError = 0;
|
||||||
|
const themeNames = new Map();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ saving
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!state) return;
|
||||||
|
state.savedAt = Date.now();
|
||||||
|
try {
|
||||||
|
saveGameText(themeId, serializeState(state));
|
||||||
|
} catch {
|
||||||
|
if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) {
|
||||||
|
lastSaveError = Date.now();
|
||||||
|
showToast(t('toast.saveFailed'), { error: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSavedState() {
|
||||||
|
const text = loadGameText(themeId);
|
||||||
|
if (!text) return null;
|
||||||
|
try {
|
||||||
|
return sanitizeState(JSON.parse(text), ctx);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPrefs() {
|
||||||
|
savePrefs({ theme: themeId, lang: locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ theme / language
|
||||||
|
|
||||||
|
function pickLocale(preferred) {
|
||||||
|
const codes = locales.map((l) => l.code);
|
||||||
|
if (codes.includes(preferred)) return preferred;
|
||||||
|
for (const tag of navigator.languages ?? [navigator.language]) {
|
||||||
|
const match = codes.find((code) => code === tag || code === tag?.split('-')[0]);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return FALLBACK_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeLabel(id) {
|
||||||
|
const key = `${id}:${locale}`;
|
||||||
|
if (!themeNames.has(key)) themeNames.set(key, await fetchThemeName(id, locale));
|
||||||
|
return themeNames.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillSelects() {
|
||||||
|
const themeSelect = $('#theme-select');
|
||||||
|
const labels = await Promise.all(themes.map((th) => themeLabel(th.id)));
|
||||||
|
themeSelect.replaceChildren(
|
||||||
|
...themes.map((th, i) => new Option(labels[i], th.id, false, th.id === themeId)),
|
||||||
|
);
|
||||||
|
$('#locale-select').replaceChildren(
|
||||||
|
...locales.map((l) => new Option(l.name, l.code, false, l.code === locale)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload texts, assets and the lists for the current theme + language. */
|
||||||
|
async function refreshTexts() {
|
||||||
|
await loadDictionary(locale, themeId);
|
||||||
|
fmt = createFormatter(locale);
|
||||||
|
setThemeAssets(themeId, themes.find((th) => th.id === themeId)?.assets);
|
||||||
|
applyTranslations();
|
||||||
|
document.title = t('theme.title');
|
||||||
|
view.rebuild(ctx, fmt);
|
||||||
|
await fillSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterTheme(id) {
|
||||||
|
persist(); // keep the theme we are leaving
|
||||||
|
themeId = id;
|
||||||
|
await applyThemeStyles(id);
|
||||||
|
await refreshTexts();
|
||||||
|
state = loadSavedState() ?? createState(ctx);
|
||||||
|
income = 0;
|
||||||
|
catchUp((Date.now() - state.savedAt) / 1000);
|
||||||
|
persistPrefs();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ the loop
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) });
|
||||||
|
if (minutes < 60) return t('time.minutes', { value: minutes });
|
||||||
|
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply progress made while the game was closed or the device asleep. */
|
||||||
|
function catchUp(elapsedSeconds) {
|
||||||
|
const report = simulateOffline(state, ctx, elapsedSeconds);
|
||||||
|
if (!report || (report.produced <= 0 && report.revenue <= 0)) return;
|
||||||
|
showToast(
|
||||||
|
t('toast.welcomeBack', {
|
||||||
|
duration: formatDuration(report.seconds),
|
||||||
|
produced: fmt.number(Math.floor(report.produced)),
|
||||||
|
funds: t('theme.fmt.funds', { value: fmt.money(report.revenue) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFrame = Date.now();
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = (now - lastFrame) / 1000;
|
||||||
|
lastFrame = now;
|
||||||
|
if (!state || dt <= 0) return;
|
||||||
|
|
||||||
|
if (dt >= ctx.rules.offline.minSeconds) {
|
||||||
|
catchUp(dt); // tab was frozen or the device slept
|
||||||
|
} else {
|
||||||
|
const { revenue } = advance(state, ctx, dt);
|
||||||
|
income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
view.update(state, derive(state, ctx), { income });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- actions
|
||||||
|
|
||||||
|
const act = (fn) => (...args) => {
|
||||||
|
fn(state, ctx, ...args);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = createView({
|
||||||
|
make: act(click),
|
||||||
|
buyMaterial: act(buyMaterial),
|
||||||
|
buyMarketing: act(buyMarketing),
|
||||||
|
buyGenerator: act(buyGenerator),
|
||||||
|
buyUpgrade: act(buyUpgrade),
|
||||||
|
setPrice: act(setPrice),
|
||||||
|
nudgePrice: (delta) => act(setPrice)(state.price + delta),
|
||||||
|
});
|
||||||
|
|
||||||
|
createSettings({
|
||||||
|
getExport: () => ({
|
||||||
|
text: buildExport(themeId, state),
|
||||||
|
name: `idle-clicker-${themeId}-${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
}),
|
||||||
|
async importText(text) {
|
||||||
|
const imported = parseImport(text, ctx, themes.map((th) => th.id)); // throws before any change
|
||||||
|
if (imported.themeId !== themeId) await enterTheme(imported.themeId);
|
||||||
|
state = imported.state;
|
||||||
|
persist();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
deleteGame(themeId);
|
||||||
|
state = createState(ctx);
|
||||||
|
income = 0;
|
||||||
|
persist();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- boot
|
||||||
|
|
||||||
|
function showFatal(error) {
|
||||||
|
console.error(error);
|
||||||
|
document.body.replaceChildren(Object.assign(document.createElement('pre'), { textContent: String(error) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
[ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]);
|
||||||
|
if (themes.length === 0) throw new Error('No theme installed in public/themes/');
|
||||||
|
|
||||||
|
const prefs = loadPrefs();
|
||||||
|
locale = pickLocale(prefs.lang);
|
||||||
|
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
|
||||||
|
|
||||||
|
$('#cookie-warning').hidden = cookiesAvailable();
|
||||||
|
$('#theme-select').addEventListener('change', (e) => enterTheme(e.target.value));
|
||||||
|
$('#locale-select').addEventListener('change', async (e) => {
|
||||||
|
locale = e.target.value;
|
||||||
|
persistPrefs();
|
||||||
|
await refreshTexts();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
await enterTheme(initialTheme.id);
|
||||||
|
refreshStaticIcons();
|
||||||
|
|
||||||
|
lastFrame = Date.now();
|
||||||
|
setInterval(frame, TICK_MS);
|
||||||
|
setInterval(persist, ctx.rules.autosaveSeconds * 1000);
|
||||||
|
document.addEventListener('visibilitychange', () => document.hidden && persist());
|
||||||
|
addEventListener('pagehide', persist);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot().catch(showFatal);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Thin wrapper over document.cookie. Values must already be cookie-safe
|
||||||
|
// (see storage.js, which base64url-encodes everything).
|
||||||
|
|
||||||
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export function readCookie(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
for (const part of document.cookie.split('; ')) {
|
||||||
|
if (part.startsWith(prefix)) return part.slice(prefix.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCookie(name, value) {
|
||||||
|
const secure = location.protocol === 'https:' ? '; Secure' : '';
|
||||||
|
document.cookie = `${name}=${value}; Max-Age=${ONE_YEAR}; Path=/; SameSite=Lax${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCookie(name) {
|
||||||
|
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Loads the static game data and the lists of installed themes / languages.
|
||||||
|
|
||||||
|
import { buildContext } from '../engine/context.js';
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGameContext() {
|
||||||
|
const [rules, generators, upgrades] = await Promise.all([
|
||||||
|
getJson('data/rules.json'),
|
||||||
|
getJson('data/generators.json'),
|
||||||
|
getJson('data/upgrades.json'),
|
||||||
|
]);
|
||||||
|
return buildContext({ rules, generators, upgrades });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */
|
||||||
|
export const fetchThemes = () => getJson('api/themes');
|
||||||
|
|
||||||
|
/** [{ code, name }] discovered from public/locales/ by the server. */
|
||||||
|
export const fetchLocales = () => getJson('api/locales');
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Locale-aware number formatting with compact notation for big values.
|
||||||
|
|
||||||
|
export function createFormatter(locale) {
|
||||||
|
const make = (options) => new Intl.NumberFormat(locale, options);
|
||||||
|
const decimals = make({ maximumFractionDigits: 2 });
|
||||||
|
const integer = make({ maximumFractionDigits: 0 });
|
||||||
|
const fixed2 = make({ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
const compact = make({ notation: 'compact', maximumFractionDigits: 2 });
|
||||||
|
const scientific = make({ notation: 'scientific', maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
function number(n) {
|
||||||
|
if (!Number.isFinite(n)) return '∞';
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
if (abs < 1000) return Number.isInteger(n) ? integer.format(n) : decimals.format(n);
|
||||||
|
if (abs < 1e6) return integer.format(n);
|
||||||
|
return abs < 1e15 ? compact.format(n) : scientific.format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amounts of money: always two decimals while small. */
|
||||||
|
function money(n) {
|
||||||
|
return Math.abs(n) < 1000 ? fixed2.format(n) : number(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { number, money, price: (n) => fixed2.format(n) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Internationalisation.
|
||||||
|
//
|
||||||
|
// Two kinds of locale files are merged into one flat dictionary:
|
||||||
|
// locales/<lang>.json core UI texts (shared by all themes)
|
||||||
|
// themes/<id>/locales/<lang>.json theme texts, all under the "theme." root
|
||||||
|
// Missing keys fall back to English, then to the key itself.
|
||||||
|
//
|
||||||
|
// Strings support {param} interpolation and {@some.key} to embed another entry
|
||||||
|
// (e.g. "{@theme.res.material}"), so core texts can use theme vocabulary.
|
||||||
|
|
||||||
|
export const FALLBACK_LOCALE = 'en';
|
||||||
|
const MAX_EMBED_DEPTH = 5;
|
||||||
|
|
||||||
|
let dictionary = {};
|
||||||
|
let currentLocale = FALLBACK_LOCALE;
|
||||||
|
|
||||||
|
export const getLocale = () => currentLocale;
|
||||||
|
|
||||||
|
function flatten(object, prefix = '', out = {}) {
|
||||||
|
for (const [key, value] of Object.entries(object ?? {})) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === 'object') flatten(value, path, out);
|
||||||
|
else out[path] = String(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.ok ? await response.json() : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDictionary(locale, themeId) {
|
||||||
|
const order = locale === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, locale];
|
||||||
|
const files = order.flatMap((l) => [`locales/${l}.json`, `themes/${themeId}/locales/${l}.json`]);
|
||||||
|
const parts = await Promise.all(files.map(fetchJson));
|
||||||
|
dictionary = Object.assign({}, ...parts.map((p) => flatten(p)));
|
||||||
|
currentLocale = locale;
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function has(key) {
|
||||||
|
return key in dictionary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key, params = {}, depth = 0) {
|
||||||
|
const text = dictionary[key];
|
||||||
|
if (text === undefined) return key;
|
||||||
|
return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => {
|
||||||
|
if (embed) return depth < MAX_EMBED_DEPTH ? t(name, params, depth + 1) : '';
|
||||||
|
return name in params ? String(params[name]) : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate static markup:
|
||||||
|
* data-i18n="key" -> textContent
|
||||||
|
* data-i18n-attr="title:key;aria-label:key" -> attributes
|
||||||
|
*/
|
||||||
|
export function applyTranslations(root = document) {
|
||||||
|
for (const el of root.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
||||||
|
for (const el of root.querySelectorAll('[data-i18n-attr]')) {
|
||||||
|
for (const pair of el.dataset.i18nAttr.split(';')) {
|
||||||
|
const [attr, key] = pair.split(':').map((s) => s.trim());
|
||||||
|
if (attr && key) el.setAttribute(attr, t(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display name of a theme in `locale` (falls back to English, then the id). */
|
||||||
|
export async function fetchThemeName(themeId, locale) {
|
||||||
|
for (const l of [locale, FALLBACK_LOCALE]) {
|
||||||
|
const file = await fetchJson(`themes/${themeId}/locales/${l}.json`);
|
||||||
|
if (file?.theme?.name) return file.theme.name;
|
||||||
|
}
|
||||||
|
return themeId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Export / import of a save as a JSON or TXT file (or pasted text).
|
||||||
|
// Works on every device: download via a Blob link, import via file input or paste.
|
||||||
|
|
||||||
|
import { sanitizeState, serializeState } from '../engine/state.js';
|
||||||
|
|
||||||
|
const GAME_ID = 'idle-clicker';
|
||||||
|
const FORMAT = 1;
|
||||||
|
|
||||||
|
export class ImportError extends Error {
|
||||||
|
/** @param {'empty'|'invalid'|'unknown_theme'|'wrong_game'} code */
|
||||||
|
constructor(code) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text a player downloads, copies or pastes back. */
|
||||||
|
export function buildExport(themeId, state, now = Date.now()) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
game: GAME_ID,
|
||||||
|
format: FORMAT,
|
||||||
|
theme: themeId,
|
||||||
|
exportedAt: new Date(now).toISOString(),
|
||||||
|
state: JSON.parse(serializeState(state)),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate imported text. Throws ImportError; never trusts the content. */
|
||||||
|
export function parseImport(text, ctx, knownThemeIds) {
|
||||||
|
const trimmed = String(text ?? '').trim().replace(/^/, '');
|
||||||
|
if (!trimmed) throw new ImportError('empty');
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new ImportError('invalid');
|
||||||
|
}
|
||||||
|
if (data?.game !== GAME_ID || data.format !== FORMAT) throw new ImportError('wrong_game');
|
||||||
|
if (!knownThemeIds.includes(data.theme)) throw new ImportError('unknown_theme');
|
||||||
|
|
||||||
|
const state = sanitizeState(data.state, ctx);
|
||||||
|
if (!state) throw new ImportError('invalid');
|
||||||
|
return { themeId: data.theme, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadText(filename, text, mime = 'application/json') {
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type: `${mime};charset=utf-8` }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileText(file) {
|
||||||
|
return file.text();
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Persistence in browser cookies.
|
||||||
|
//
|
||||||
|
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
|
||||||
|
// ic_save_<theme>_n -> number of chunks
|
||||||
|
// ic_save_<theme>_0.. -> the chunks
|
||||||
|
// Each theme has its own save slot. Preferences (theme, language) are stored
|
||||||
|
// in a single small cookie.
|
||||||
|
|
||||||
|
import { readCookie, removeCookie, writeCookie } from './cookies.js';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 3000;
|
||||||
|
const MAX_CHUNKS = 6;
|
||||||
|
const PREFS_KEY = 'ic_prefs';
|
||||||
|
const saveKey = (themeId) => `ic_save_${themeId}`;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ encoding
|
||||||
|
|
||||||
|
export function encode(text) {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
let binary = '';
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decode(encoded) {
|
||||||
|
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
|
||||||
|
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
|
||||||
|
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- chunked
|
||||||
|
|
||||||
|
function writeChunked(key, text) {
|
||||||
|
const encoded = encode(text);
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) chunks.push(encoded.slice(i, i + CHUNK_SIZE));
|
||||||
|
if (chunks.length > MAX_CHUNKS) throw new Error('save_too_large');
|
||||||
|
|
||||||
|
const previous = Number(readCookie(`${key}_n`)) || 0;
|
||||||
|
chunks.forEach((chunk, i) => writeCookie(`${key}_${i}`, chunk));
|
||||||
|
writeCookie(`${key}_n`, String(chunks.length));
|
||||||
|
for (let i = chunks.length; i < previous; i++) removeCookie(`${key}_${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChunked(key) {
|
||||||
|
const count = Number(readCookie(`${key}_n`));
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > MAX_CHUNKS) return null;
|
||||||
|
let encoded = '';
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const chunk = readCookie(`${key}_${i}`);
|
||||||
|
if (chunk == null) return null;
|
||||||
|
encoded += chunk;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decode(encoded);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChunked(key) {
|
||||||
|
const count = Number(readCookie(`${key}_n`)) || 0;
|
||||||
|
for (let i = 0; i < count; i++) removeCookie(`${key}_${i}`);
|
||||||
|
removeCookie(`${key}_n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- public
|
||||||
|
|
||||||
|
/** False when the browser refuses cookies (private mode, blocked, ...). */
|
||||||
|
export function cookiesAvailable() {
|
||||||
|
try {
|
||||||
|
writeCookie('ic_probe', '1');
|
||||||
|
const ok = readCookie('ic_probe') === '1';
|
||||||
|
removeCookie('ic_probe');
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws if the save does not fit in cookies. */
|
||||||
|
export function saveGameText(themeId, text) {
|
||||||
|
writeChunked(saveKey(themeId), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGameText(themeId) {
|
||||||
|
return readChunked(saveKey(themeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGame(themeId) {
|
||||||
|
removeChunked(saveKey(themeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePrefs(prefs) {
|
||||||
|
writeCookie(PREFS_KEY, encode(JSON.stringify(prefs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPrefs() {
|
||||||
|
try {
|
||||||
|
const raw = readCookie(PREFS_KEY);
|
||||||
|
const prefs = raw && JSON.parse(decode(raw));
|
||||||
|
return prefs && typeof prefs === 'object' ? prefs : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Applies a theme's stylesheet. Texts are handled by i18n.js, mechanics are
|
||||||
|
// shared (public/data); a theme is only a skin: colours, vocabulary, images.
|
||||||
|
|
||||||
|
export function applyThemeStyles(themeId) {
|
||||||
|
document.documentElement.dataset.theme = themeId;
|
||||||
|
|
||||||
|
let link = document.getElementById('theme-css');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.id = 'theme-css';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
document.head.append(link);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
link.onload = link.onerror = () => resolve();
|
||||||
|
link.href = `themes/${themeId}/theme.css`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
|
|
||||||
|
/** Create an element: h('button', { class: 'btn', text: 'Go', onclick }, ...children). */
|
||||||
|
export function h(tag, props = {}, ...children) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value == null || value === false) continue;
|
||||||
|
if (key === 'class') el.className = value;
|
||||||
|
else if (key === 'text') el.textContent = value;
|
||||||
|
else if (key === 'dataset') Object.assign(el.dataset, value);
|
||||||
|
else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||||
|
else el.setAttribute(key, value === true ? '' : value);
|
||||||
|
}
|
||||||
|
el.append(...children.flat().filter((c) => c != null && c !== false));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- icons
|
||||||
|
// Every icon is a grey square for now. When a theme provides images, list them
|
||||||
|
// in its theme.json ("assets": { "gen.gen1": "img/autoclipper.svg", ... }) and
|
||||||
|
// they replace the placeholders, with no change to the UI code.
|
||||||
|
|
||||||
|
let assetBase = '';
|
||||||
|
let assets = {};
|
||||||
|
|
||||||
|
export function setThemeAssets(themeId, themeAssets) {
|
||||||
|
assetBase = `themes/${themeId}/`;
|
||||||
|
assets = themeAssets ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key examples: "res.units", "gen.gen1", "upg.click_1" */
|
||||||
|
export function icon(key) {
|
||||||
|
const path = assets[key];
|
||||||
|
if (path) return h('img', { class: 'icon', src: assetBase + path, alt: '', loading: 'lazy' });
|
||||||
|
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-resolve the icons that live in index.html (resource tiles). */
|
||||||
|
export function refreshStaticIcons(root = document) {
|
||||||
|
for (const el of root.querySelectorAll('[data-icon]')) {
|
||||||
|
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
|
||||||
|
el.replaceWith(icon(el.dataset.icon));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// "Save & settings" dialog: export (file or text), import (file or pasted text), reset.
|
||||||
|
|
||||||
|
import { downloadText, ImportError, readFileText } from '../services/saveio.js';
|
||||||
|
import { t } from '../services/i18n.js';
|
||||||
|
import { $ } from './dom.js';
|
||||||
|
import { showToast } from './toast.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param hooks.getExport () => { text, name } current save as text + file base name
|
||||||
|
* @param hooks.importText (text) => void throws ImportError on bad input
|
||||||
|
* @param hooks.reset () => void
|
||||||
|
*/
|
||||||
|
export function createSettings(hooks) {
|
||||||
|
const dialog = $('#settings');
|
||||||
|
const exportBox = $('#export-text');
|
||||||
|
const importBox = $('#import-text');
|
||||||
|
const fileInput = $('#import-file');
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
exportBox.value = hooks.getExport().text;
|
||||||
|
importBox.value = '';
|
||||||
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
|
else dialog.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(extension, mime) {
|
||||||
|
const { text, name } = hooks.getExport();
|
||||||
|
downloadText(`${name}.${extension}`, text, mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
exportBox.value = hooks.getExport().text;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(exportBox.value);
|
||||||
|
} catch {
|
||||||
|
exportBox.select();
|
||||||
|
if (!document.execCommand?.('copy')) return showToast(t('toast.copyFailed'), { error: true });
|
||||||
|
}
|
||||||
|
showToast(t('toast.copied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport(text) {
|
||||||
|
try {
|
||||||
|
await hooks.importText(text);
|
||||||
|
dialog.close?.();
|
||||||
|
showToast(t('toast.imported'));
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof ImportError ? error.code : 'invalid';
|
||||||
|
showToast(t(`error.import.${code}`), { error: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#open-settings').addEventListener('click', open);
|
||||||
|
$('#export-json').addEventListener('click', () => download('json', 'application/json'));
|
||||||
|
$('#export-txt').addEventListener('click', () => download('txt', 'text/plain'));
|
||||||
|
$('#export-copy').addEventListener('click', copy);
|
||||||
|
$('#import-paste').addEventListener('click', () => runImport(importBox.value));
|
||||||
|
fileInput.addEventListener('change', async () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
fileInput.value = ''; // allow picking the same file again
|
||||||
|
if (file) await runImport(await readFileText(file));
|
||||||
|
});
|
||||||
|
$('#reset-game').addEventListener('click', () => {
|
||||||
|
if (!confirm(t('settings.resetConfirm'))) return;
|
||||||
|
hooks.reset();
|
||||||
|
dialog.close?.();
|
||||||
|
showToast(t('toast.reset'));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { open };
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { $, h } from './dom.js';
|
||||||
|
|
||||||
|
const DURATION_MS = 6000;
|
||||||
|
|
||||||
|
export function showToast(message, { error = false } = {}) {
|
||||||
|
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
|
||||||
|
$('#toasts').append(toast);
|
||||||
|
setTimeout(() => toast.remove(), DURATION_MS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Main game view. Static markup lives in index.html; this module binds the
|
||||||
|
// controls once, builds the generator/upgrade lists, and refreshes the numbers.
|
||||||
|
|
||||||
|
import { generatorQuote, isUnlocked, upgradeVisible } from '../engine/engine.js';
|
||||||
|
import { t } from '../services/i18n.js';
|
||||||
|
import { $, h, icon, refreshStaticIcons } from './dom.js';
|
||||||
|
|
||||||
|
const SLIDER_STEPS = 1000;
|
||||||
|
|
||||||
|
function setText(node, text) {
|
||||||
|
if (node.textContent !== text) node.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param actions callbacks into the game: make, buyMaterial, buyMarketing,
|
||||||
|
* buyGenerator(id, amount), buyUpgrade(id), setPrice(value)
|
||||||
|
*/
|
||||||
|
export function createView(actions) {
|
||||||
|
const el = {
|
||||||
|
units: $('#v-units'),
|
||||||
|
funds: $('#v-funds'),
|
||||||
|
material: $('#v-material'),
|
||||||
|
total: $('#v-total'),
|
||||||
|
rate: $('#v-rate'),
|
||||||
|
demand: $('#v-demand'),
|
||||||
|
income: $('#v-income'),
|
||||||
|
make: $('#make'),
|
||||||
|
makeYield: $('#make-yield'),
|
||||||
|
buyMaterial: $('#buy-material'),
|
||||||
|
buyMaterialInfo: $('#buy-material-info'),
|
||||||
|
buyMarketing: $('#buy-marketing'),
|
||||||
|
buyMarketingInfo: $('#buy-marketing-info'),
|
||||||
|
priceInput: $('#price-input'),
|
||||||
|
priceSlider: $('#price-slider'),
|
||||||
|
priceHint: $('#price-hint'),
|
||||||
|
generators: $('#generators'),
|
||||||
|
upgrades: $('#upgrades'),
|
||||||
|
upgradeCount: $('#upgrade-count'),
|
||||||
|
upgradesEmpty: $('#upgrades-empty'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
let fmt;
|
||||||
|
let buyAmount = 1;
|
||||||
|
let generatorRows = new Map();
|
||||||
|
let upgradeRows = new Map();
|
||||||
|
let upgradeSignature = null;
|
||||||
|
|
||||||
|
const money = (n) => t('theme.fmt.funds', { value: fmt.money(n) });
|
||||||
|
const perSecond = (n) => t('ui.perSecond', { value: fmt.number(n) });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- controls
|
||||||
|
|
||||||
|
el.make.addEventListener('click', () => actions.make());
|
||||||
|
el.buyMaterial.addEventListener('click', () => actions.buyMaterial());
|
||||||
|
el.buyMarketing.addEventListener('click', () => actions.buyMarketing());
|
||||||
|
|
||||||
|
const priceFromSlider = () => {
|
||||||
|
const { minPrice, maxPrice } = ctx.rules.market;
|
||||||
|
return minPrice * (maxPrice / minPrice) ** (Number(el.priceSlider.value) / SLIDER_STEPS);
|
||||||
|
};
|
||||||
|
const sliderFromPrice = (price) => {
|
||||||
|
const { minPrice, maxPrice } = ctx.rules.market;
|
||||||
|
return (SLIDER_STEPS * Math.log(price / minPrice)) / Math.log(maxPrice / minPrice);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.priceSlider.addEventListener('input', () => actions.setPrice(priceFromSlider()));
|
||||||
|
el.priceInput.addEventListener('change', () => actions.setPrice(Number(el.priceInput.value)));
|
||||||
|
$('#price-down').addEventListener('click', () => actions.nudgePrice(-ctx.rules.market.priceStep));
|
||||||
|
$('#price-up').addEventListener('click', () => actions.nudgePrice(ctx.rules.market.priceStep));
|
||||||
|
|
||||||
|
$('#buy-amount').addEventListener('change', (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
buyAmount = value === 'max' ? 'max' : Number(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- lists
|
||||||
|
|
||||||
|
function effectText(effect) {
|
||||||
|
const params = { value: fmt.number(effect.value) };
|
||||||
|
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
|
||||||
|
if (effect.type === 'batch_discount') params.percent = fmt.number(Math.round((1 - effect.value) * 100));
|
||||||
|
return t(`effect.${effect.type}`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatorRow(g) {
|
||||||
|
const owned = h('span', { class: 'badge' });
|
||||||
|
const rate = h('span');
|
||||||
|
const amount = h('span', { class: 'btn__label' });
|
||||||
|
const cost = h('small');
|
||||||
|
const buy = h(
|
||||||
|
'button',
|
||||||
|
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buyGenerator(g.id, buyAmount) },
|
||||||
|
amount,
|
||||||
|
cost,
|
||||||
|
);
|
||||||
|
const root = h(
|
||||||
|
'li',
|
||||||
|
{ class: 'card', hidden: true },
|
||||||
|
icon(`gen.${g.id}`),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'card__body' },
|
||||||
|
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.gen.${g.id}.name`) }), owned),
|
||||||
|
h('p', { class: 'card__desc', text: t(`theme.gen.${g.id}.desc`) }),
|
||||||
|
h('p', { class: 'card__meta' }, rate),
|
||||||
|
),
|
||||||
|
buy,
|
||||||
|
);
|
||||||
|
el.generators.append(root);
|
||||||
|
return { root, owned, rate, amount, cost, buy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeRow(u) {
|
||||||
|
const cost = h('small');
|
||||||
|
const root = h(
|
||||||
|
'li',
|
||||||
|
{ class: 'card' },
|
||||||
|
icon(`upg.${u.id}`),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'card__body' },
|
||||||
|
h('h3', { class: 'card__title', text: t(`theme.upg.${u.id}`) }),
|
||||||
|
h('p', { class: 'card__desc', text: effectText(u.effect) }),
|
||||||
|
),
|
||||||
|
h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyUpgrade(u.id) }, cost),
|
||||||
|
);
|
||||||
|
return { root, cost, buy: root.lastElementChild, upgrade: u };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ public
|
||||||
|
|
||||||
|
/** Call after the theme, language or game data changed. */
|
||||||
|
function rebuild(newCtx, formatter) {
|
||||||
|
ctx = newCtx;
|
||||||
|
fmt = formatter;
|
||||||
|
el.generators.replaceChildren();
|
||||||
|
el.upgrades.replaceChildren();
|
||||||
|
generatorRows = new Map(ctx.generators.map((g) => [g.id, generatorRow(g)]));
|
||||||
|
upgradeRows = new Map();
|
||||||
|
upgradeSignature = null;
|
||||||
|
el.priceInput.min = ctx.rules.market.minPrice;
|
||||||
|
el.priceInput.max = ctx.rules.market.maxPrice;
|
||||||
|
el.priceInput.step = ctx.rules.market.priceStep;
|
||||||
|
refreshStaticIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGenerators(state, d) {
|
||||||
|
for (const g of ctx.generators) {
|
||||||
|
const row = generatorRows.get(g.id);
|
||||||
|
const owned = state.generators[g.id];
|
||||||
|
const visible = owned > 0 || isUnlocked(state, g.unlock);
|
||||||
|
row.root.hidden = !visible;
|
||||||
|
if (!visible) continue;
|
||||||
|
|
||||||
|
let quote = generatorQuote(state, ctx, g.id, buyAmount);
|
||||||
|
if (quote.count <= 0) quote = generatorQuote(state, ctx, g.id, 1);
|
||||||
|
|
||||||
|
setText(row.owned, t('ui.owned', { count: fmt.number(owned) }));
|
||||||
|
setText(row.rate, t('ui.rateEach', { rate: fmt.number(d.perGenerator[g.id]) }));
|
||||||
|
setText(row.amount, t('ui.buyN', { count: fmt.number(quote.count) }));
|
||||||
|
setText(row.cost, money(quote.cost));
|
||||||
|
row.buy.disabled = state.funds < quote.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUpgrades(state) {
|
||||||
|
const visible = ctx.upgrades.filter((u) => upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
|
||||||
|
const signature = visible.map((u) => u.id).join();
|
||||||
|
if (signature !== upgradeSignature) {
|
||||||
|
upgradeSignature = signature;
|
||||||
|
upgradeRows = new Map(visible.map((u) => [u.id, upgradeRow(u)]));
|
||||||
|
el.upgrades.replaceChildren(...[...upgradeRows.values()].map((row) => row.root));
|
||||||
|
el.upgradesEmpty.hidden = visible.length > 0;
|
||||||
|
}
|
||||||
|
for (const row of upgradeRows.values()) {
|
||||||
|
setText(row.cost, money(row.upgrade.cost));
|
||||||
|
row.buy.disabled = state.funds < row.upgrade.cost;
|
||||||
|
}
|
||||||
|
setText(
|
||||||
|
el.upgradeCount,
|
||||||
|
t('ui.upgradeCount', { owned: state.upgrades.length, total: ctx.upgrades.length }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarket(state, d) {
|
||||||
|
const { fairPrice, minPrice } = ctx.rules.market;
|
||||||
|
if (document.activeElement !== el.priceInput) el.priceInput.value = state.price.toFixed(2);
|
||||||
|
if (document.activeElement !== el.priceSlider) {
|
||||||
|
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
|
||||||
|
}
|
||||||
|
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
|
||||||
|
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
|
||||||
|
|
||||||
|
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
|
||||||
|
setText(
|
||||||
|
el.buyMarketingInfo,
|
||||||
|
t('ui.marketingInfo', {
|
||||||
|
level: state.marketing + 1,
|
||||||
|
multiplier: fmt.number(nextLevelMultiplier),
|
||||||
|
cost: money(d.marketingCost),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
el.buyMarketing.disabled = state.funds < d.marketingCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param flow { income } smoothed funds per second */
|
||||||
|
function update(state, d, flow) {
|
||||||
|
setText(el.units, fmt.number(Math.floor(state.units)));
|
||||||
|
setText(el.funds, money(state.funds));
|
||||||
|
setText(el.material, fmt.number(Math.floor(state.material)));
|
||||||
|
setText(el.total, fmt.number(Math.floor(state.totalProduced)));
|
||||||
|
setText(el.rate, perSecond(d.rate));
|
||||||
|
setText(el.demand, perSecond(d.demand));
|
||||||
|
setText(el.income, t('ui.perSecond', { value: money(flow.income) }));
|
||||||
|
|
||||||
|
setText(el.makeYield, t('ui.makeYield', { amount: fmt.number(d.clickYield) }));
|
||||||
|
el.make.disabled = state.material < d.materialPerUnit;
|
||||||
|
setText(
|
||||||
|
el.buyMaterialInfo,
|
||||||
|
t('ui.batchInfo', { amount: fmt.number(d.batchSize), cost: money(d.batchCost) }),
|
||||||
|
);
|
||||||
|
el.buyMaterial.disabled = state.funds < d.batchCost;
|
||||||
|
|
||||||
|
updateMarket(state, d);
|
||||||
|
updateGenerators(state, d);
|
||||||
|
updateUpgrades(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rebuild, update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"meta": { "name": "English" },
|
||||||
|
"ui": {
|
||||||
|
"theme": "Theme",
|
||||||
|
"language": "Language",
|
||||||
|
"settings": "Save & settings",
|
||||||
|
"close": "Close",
|
||||||
|
"overview": "Overview",
|
||||||
|
"totalProduced": "Total produced",
|
||||||
|
"production": "Production / s",
|
||||||
|
"demand": "Demand / s",
|
||||||
|
"income": "Income / s",
|
||||||
|
"manual": "Manual work",
|
||||||
|
"market": "Market",
|
||||||
|
"price": "Price",
|
||||||
|
"priceDown": "Lower the price",
|
||||||
|
"priceUp": "Raise the price",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"automation": "Automation",
|
||||||
|
"buyAmount": "Buy amount",
|
||||||
|
"max": "Max",
|
||||||
|
"upgrades": "Upgrades",
|
||||||
|
"noUpgrades": "Keep producing to unlock upgrades.",
|
||||||
|
"perSecond": "{value}/s",
|
||||||
|
"makeYield": "+{amount} per click",
|
||||||
|
"batchInfo": "+{amount} {@theme.res.material} · {cost}",
|
||||||
|
"marketingInfo": "Level {level} → demand ×{multiplier} · {cost}",
|
||||||
|
"hintFair": "At or below the fair price ({fair}) demand is at its maximum: lowering the price sells nothing more.",
|
||||||
|
"hintHigh": "Customers want {demand}/s at this price. Above the fair price ({fair}) demand drops fast.",
|
||||||
|
"owned": "Owned: {count}",
|
||||||
|
"rateEach": "{rate}/s each",
|
||||||
|
"buyN": "Buy ×{count}",
|
||||||
|
"upgradeCount": "{owned} / {total}"
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"click_mult": "Manual work ×{value}",
|
||||||
|
"generator_mult": "{target} output ×{value}",
|
||||||
|
"global_mult": "All automation ×{value}",
|
||||||
|
"demand_mult": "Demand ×{value}",
|
||||||
|
"value_mult": "Sale value ×{value}",
|
||||||
|
"material_efficiency": "{@theme.res.material} goes ×{value} further",
|
||||||
|
"batch_discount": "{@theme.res.material} is {percent}% cheaper",
|
||||||
|
"auto_buy_material": "Restocks {@theme.res.material} automatically"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"seconds": "{value} s",
|
||||||
|
"minutes": "{value} min",
|
||||||
|
"hoursMinutes": "{hours} h {minutes} min"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Save & settings",
|
||||||
|
"export": "Export your save",
|
||||||
|
"exportHelp": "Download the file, or copy the text. Keep it to move your progress to another browser or device.",
|
||||||
|
"downloadJson": "Download .json",
|
||||||
|
"downloadTxt": "Download .txt",
|
||||||
|
"copy": "Copy text",
|
||||||
|
"import": "Import a save",
|
||||||
|
"importHelp": "Choose a .json or .txt file, or paste the text below. It replaces the progress of the theme it was saved with.",
|
||||||
|
"importFile": "Choose a file…",
|
||||||
|
"pastePlaceholder": "Paste the exported text here",
|
||||||
|
"importText": "Import pasted text",
|
||||||
|
"danger": "Danger zone",
|
||||||
|
"reset": "Reset this theme's progress",
|
||||||
|
"resetConfirm": "Erase all progress for this theme? This cannot be undone."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"cookiesBlocked": "Cookies are blocked, so progress cannot be saved in this browser. Use Save & settings to export it.",
|
||||||
|
"saveFailed": "Could not save your progress. Export it from Save & settings.",
|
||||||
|
"welcomeBack": "Welcome back! While you were away ({duration}) you made {produced} {@theme.res.units} and earned {funds}.",
|
||||||
|
"imported": "Save imported.",
|
||||||
|
"copied": "Copied to the clipboard.",
|
||||||
|
"copyFailed": "Could not copy. Select the text and copy it manually.",
|
||||||
|
"reset": "Progress reset."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"import": {
|
||||||
|
"empty": "Nothing to import.",
|
||||||
|
"invalid": "This is not a valid save.",
|
||||||
|
"wrong_game": "This file is not an Idle Clicker save.",
|
||||||
|
"unknown_theme": "This save belongs to a theme that is not installed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"meta": { "name": "Français" },
|
||||||
|
"ui": {
|
||||||
|
"theme": "Thème",
|
||||||
|
"language": "Langue",
|
||||||
|
"settings": "Sauvegarde et options",
|
||||||
|
"close": "Fermer",
|
||||||
|
"overview": "Vue d'ensemble",
|
||||||
|
"totalProduced": "Total produit",
|
||||||
|
"production": "Production / s",
|
||||||
|
"demand": "Demande / s",
|
||||||
|
"income": "Revenu / s",
|
||||||
|
"manual": "Travail manuel",
|
||||||
|
"market": "Marché",
|
||||||
|
"price": "Prix",
|
||||||
|
"priceDown": "Baisser le prix",
|
||||||
|
"priceUp": "Augmenter le prix",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"automation": "Automatisation",
|
||||||
|
"buyAmount": "Quantité d'achat",
|
||||||
|
"max": "Max",
|
||||||
|
"upgrades": "Améliorations",
|
||||||
|
"noUpgrades": "Continuez à produire pour débloquer des améliorations.",
|
||||||
|
"perSecond": "{value}/s",
|
||||||
|
"makeYield": "+{amount} par clic",
|
||||||
|
"batchInfo": "+{amount} {@theme.res.material} · {cost}",
|
||||||
|
"marketingInfo": "Niveau {level} → demande ×{multiplier} · {cost}",
|
||||||
|
"hintFair": "Au prix juste ({fair}) ou en dessous, la demande est maximale : baisser le prix ne vend rien de plus.",
|
||||||
|
"hintHigh": "Les clients veulent {demand}/s à ce prix. Au-dessus du prix juste ({fair}), la demande chute vite.",
|
||||||
|
"owned": "Possédé : {count}",
|
||||||
|
"rateEach": "{rate}/s chacun",
|
||||||
|
"buyN": "Acheter ×{count}",
|
||||||
|
"upgradeCount": "{owned} / {total}"
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"click_mult": "Travail manuel ×{value}",
|
||||||
|
"generator_mult": "Production de {target} ×{value}",
|
||||||
|
"global_mult": "Toute l'automatisation ×{value}",
|
||||||
|
"demand_mult": "Demande ×{value}",
|
||||||
|
"value_mult": "Valeur de vente ×{value}",
|
||||||
|
"material_efficiency": "{@theme.res.material} : rendement ×{value}",
|
||||||
|
"batch_discount": "{@theme.res.material} : {percent} % moins cher",
|
||||||
|
"auto_buy_material": "Réapprovisionnement automatique : {@theme.res.material}"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"seconds": "{value} s",
|
||||||
|
"minutes": "{value} min",
|
||||||
|
"hoursMinutes": "{hours} h {minutes} min"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Sauvegarde et options",
|
||||||
|
"export": "Exporter votre sauvegarde",
|
||||||
|
"exportHelp": "Téléchargez le fichier ou copiez le texte. Conservez-le pour retrouver votre progression sur un autre navigateur ou appareil.",
|
||||||
|
"downloadJson": "Télécharger en .json",
|
||||||
|
"downloadTxt": "Télécharger en .txt",
|
||||||
|
"copy": "Copier le texte",
|
||||||
|
"import": "Importer une sauvegarde",
|
||||||
|
"importHelp": "Choisissez un fichier .json ou .txt, ou collez le texte ci-dessous. Cela remplace la progression du thème avec lequel il a été sauvegardé.",
|
||||||
|
"importFile": "Choisir un fichier…",
|
||||||
|
"pastePlaceholder": "Collez ici le texte exporté",
|
||||||
|
"importText": "Importer le texte collé",
|
||||||
|
"danger": "Zone dangereuse",
|
||||||
|
"reset": "Réinitialiser la progression de ce thème",
|
||||||
|
"resetConfirm": "Effacer toute la progression de ce thème ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"cookiesBlocked": "Les cookies sont bloqués : la progression ne peut pas être sauvegardée dans ce navigateur. Exportez-la depuis Sauvegarde et options.",
|
||||||
|
"saveFailed": "Impossible de sauvegarder votre progression. Exportez-la depuis Sauvegarde et options.",
|
||||||
|
"welcomeBack": "Bon retour ! Pendant votre absence ({duration}) — {@theme.res.units} : +{produced}, {@theme.res.funds} : +{funds}.",
|
||||||
|
"imported": "Sauvegarde importée.",
|
||||||
|
"copied": "Copié dans le presse-papiers.",
|
||||||
|
"copyFailed": "Copie impossible. Sélectionnez le texte et copiez-le manuellement.",
|
||||||
|
"reset": "Progression réinitialisée."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"import": {
|
||||||
|
"empty": "Rien à importer.",
|
||||||
|
"invalid": "Ceci n'est pas une sauvegarde valide.",
|
||||||
|
"wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker.",
|
||||||
|
"unknown_theme": "Cette sauvegarde appartient à un thème qui n'est pas installé."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Alien Minerals",
|
||||||
|
"title": "Xeno-Mining Colony",
|
||||||
|
"res": { "units": "Xenocrystals", "unit": "xenocrystal", "funds": "Credits", "material": "Raw ore" },
|
||||||
|
"fmt": { "funds": "{value} cr" },
|
||||||
|
"action": { "make": "Refine a crystal", "buyMaterial": "Order raw ore" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Mining Drone", "desc": "A tiny robot that scrapes the rock face." },
|
||||||
|
"gen2": { "name": "Plasma Drill", "desc": "Melts through bedrock like butter." },
|
||||||
|
"gen3": { "name": "Orbital Extractor", "desc": "Pulls ore straight out of the crust from orbit." },
|
||||||
|
"gen4": { "name": "Asteroid Harvester", "desc": "Eats whole asteroids and refines the leftovers." },
|
||||||
|
"gen5": { "name": "Hive Swarm", "desc": "Millions of drones thinking as one." },
|
||||||
|
"gen6": { "name": "Planet Cracker", "desc": "Opens a world like a walnut." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Gravity Gloves",
|
||||||
|
"click_2": "Exo-Suit",
|
||||||
|
"click_3": "Neural Link",
|
||||||
|
"g1_a": "Drone Firmware",
|
||||||
|
"g1_b": "Drone Swarm Logic",
|
||||||
|
"g2_a": "Diamond Bits",
|
||||||
|
"g2_b": "Plasma Focusing",
|
||||||
|
"g3_a": "Tether Cables",
|
||||||
|
"g3_b": "Zero-G Smelting",
|
||||||
|
"g4_a": "Mass Drivers",
|
||||||
|
"g4_b": "Autonomous Prospecting",
|
||||||
|
"g5_a": "Hive Mind Sync",
|
||||||
|
"g5_b": "Queen Directive",
|
||||||
|
"g6_a": "Tectonic Resonance",
|
||||||
|
"g6_b": "Core Shattering",
|
||||||
|
"global_1": "Quantum Diagnostics",
|
||||||
|
"global_2": "Alien Alloys",
|
||||||
|
"global_3": "Dyson Tap",
|
||||||
|
"global_4": "Spacetime Folding",
|
||||||
|
"demand_1": "Trade Beacon",
|
||||||
|
"demand_2": "Interstellar Broker",
|
||||||
|
"demand_3": "Galactic Exchange",
|
||||||
|
"demand_4": "Cosmic Monopoly",
|
||||||
|
"value_1": "Purity Certificate",
|
||||||
|
"value_2": "Rare Spectrum Cut",
|
||||||
|
"value_3": "Imperial Grade",
|
||||||
|
"mat_eff_1": "Ore Analyzer",
|
||||||
|
"mat_eff_2": "Molecular Sieve",
|
||||||
|
"mat_disc_1": "Asteroid Contract",
|
||||||
|
"mat_disc_2": "Own the Mine",
|
||||||
|
"auto_buy": "Autonomous Freighter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Minerais extraterrestres",
|
||||||
|
"title": "Colonie d'extraction xéno",
|
||||||
|
"res": { "units": "Xénocristaux", "unit": "xénocristal", "funds": "Crédits", "material": "Minerai brut" },
|
||||||
|
"fmt": { "funds": "{value} cr" },
|
||||||
|
"action": { "make": "Raffiner un cristal", "buyMaterial": "Commander du minerai brut" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Drone mineur", "desc": "Un minuscule robot qui gratte la roche." },
|
||||||
|
"gen2": { "name": "Foreuse à plasma", "desc": "Fond la roche comme du beurre." },
|
||||||
|
"gen3": { "name": "Extracteur orbital", "desc": "Aspire le minerai de la croûte depuis l'orbite." },
|
||||||
|
"gen4": { "name": "Moissonneur d'astéroïdes", "desc": "Avale des astéroïdes entiers et raffine les restes." },
|
||||||
|
"gen5": { "name": "Essaim-ruche", "desc": "Des millions de drones qui pensent comme un seul." },
|
||||||
|
"gen6": { "name": "Briseur de planètes", "desc": "Ouvre un monde comme une noix." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Gants gravitationnels",
|
||||||
|
"click_2": "Exo-combinaison",
|
||||||
|
"click_3": "Lien neural",
|
||||||
|
"g1_a": "Micrologiciel des drones",
|
||||||
|
"g1_b": "Logique d'essaim",
|
||||||
|
"g2_a": "Mèches en diamant",
|
||||||
|
"g2_b": "Focalisation du plasma",
|
||||||
|
"g3_a": "Câbles d'amarrage",
|
||||||
|
"g3_b": "Fonderie en apesanteur",
|
||||||
|
"g4_a": "Accélérateurs de masse",
|
||||||
|
"g4_b": "Prospection autonome",
|
||||||
|
"g5_a": "Synchronisation de la ruche",
|
||||||
|
"g5_b": "Directive de la reine",
|
||||||
|
"g6_a": "Résonance tectonique",
|
||||||
|
"g6_b": "Fracture du noyau",
|
||||||
|
"global_1": "Diagnostics quantiques",
|
||||||
|
"global_2": "Alliages extraterrestres",
|
||||||
|
"global_3": "Prise sur sphère de Dyson",
|
||||||
|
"global_4": "Pliage de l'espace-temps",
|
||||||
|
"demand_1": "Balise commerciale",
|
||||||
|
"demand_2": "Courtier interstellaire",
|
||||||
|
"demand_3": "Bourse galactique",
|
||||||
|
"demand_4": "Monopole cosmique",
|
||||||
|
"value_1": "Certificat de pureté",
|
||||||
|
"value_2": "Taille spectre rare",
|
||||||
|
"value_3": "Qualité impériale",
|
||||||
|
"mat_eff_1": "Analyseur de minerai",
|
||||||
|
"mat_eff_2": "Tamis moléculaire",
|
||||||
|
"mat_disc_1": "Contrat d'astéroïde",
|
||||||
|
"mat_disc_2": "Posséder la mine",
|
||||||
|
"auto_buy": "Cargo autonome"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Alien minerals: a cold, glowing mining colony. */
|
||||||
|
:root[data-theme='alien'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #06161a;
|
||||||
|
--surface: #0c2429;
|
||||||
|
--surface-2: #12343b;
|
||||||
|
--text: #dffaf3;
|
||||||
|
--text-muted: #8dbbb3;
|
||||||
|
--border: #1f4a52;
|
||||||
|
--accent: #2de2a6;
|
||||||
|
--accent-contrast: #032119;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--radius: 4px;
|
||||||
|
--font: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"assets": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Paperclips",
|
||||||
|
"title": "Paperclip Factory",
|
||||||
|
"res": { "units": "Paperclips", "unit": "paperclip", "funds": "Funds", "material": "Wire" },
|
||||||
|
"fmt": { "funds": "${value}" },
|
||||||
|
"action": { "make": "Make a paperclip", "buyMaterial": "Buy wire" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Autoclipper", "desc": "A small machine that bends wire on its own." },
|
||||||
|
"gen2": { "name": "MegaClipper", "desc": "Ten times the appetite for wire." },
|
||||||
|
"gen3": { "name": "Clip Factory", "desc": "A whole building devoted to paperclips." },
|
||||||
|
"gen4": { "name": "Hypno Drones", "desc": "They convince everyone that they need more paperclips." },
|
||||||
|
"gen5": { "name": "Probe Swarm", "desc": "Self-replicating probes fold wire across the galaxy." },
|
||||||
|
"gen6": { "name": "Matter Converter", "desc": "Turns anything into paperclips. Anything." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Ergonomic Bender",
|
||||||
|
"click_2": "Pneumatic Grip",
|
||||||
|
"click_3": "Robotic Arm",
|
||||||
|
"g1_a": "Sharper Autoclippers",
|
||||||
|
"g1_b": "Overclocked Autoclippers",
|
||||||
|
"g2_a": "MegaClipper Servos",
|
||||||
|
"g2_b": "MegaClipper Turbines",
|
||||||
|
"g3_a": "Assembly Line",
|
||||||
|
"g3_b": "Just-in-Time Logistics",
|
||||||
|
"g4_a": "Hypno Harmonics",
|
||||||
|
"g4_b": "Persuasion Algorithms",
|
||||||
|
"g5_a": "Probe Self-Replication",
|
||||||
|
"g5_b": "Von Neumann Protocols",
|
||||||
|
"g6_a": "Atomic Reassembly",
|
||||||
|
"g6_b": "Universal Reformatting",
|
||||||
|
"global_1": "Quality Control",
|
||||||
|
"global_2": "Lean Manufacturing",
|
||||||
|
"global_3": "Industrial AI",
|
||||||
|
"global_4": "Quantum Scheduling",
|
||||||
|
"demand_1": "Newspaper Ad",
|
||||||
|
"demand_2": "Radio Campaign",
|
||||||
|
"demand_3": "Viral Video",
|
||||||
|
"demand_4": "Global Brand",
|
||||||
|
"value_1": "Gold-Plated Paperclips",
|
||||||
|
"value_2": "Premium Packaging",
|
||||||
|
"value_3": "Luxury Branding",
|
||||||
|
"mat_eff_1": "Thinner Wire",
|
||||||
|
"mat_eff_2": "Wire Recycling",
|
||||||
|
"mat_disc_1": "Bulk Wire Contract",
|
||||||
|
"mat_disc_2": "Direct From the Mill",
|
||||||
|
"auto_buy": "WireBuyer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Trombones",
|
||||||
|
"title": "Usine à trombones",
|
||||||
|
"res": { "units": "Trombones", "unit": "trombone", "funds": "Fonds", "material": "Fil" },
|
||||||
|
"fmt": { "funds": "{value} $" },
|
||||||
|
"action": { "make": "Fabriquer un trombone", "buyMaterial": "Acheter du fil" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Autotrombone", "desc": "Une petite machine qui plie le fil toute seule." },
|
||||||
|
"gen2": { "name": "MégaTrombone", "desc": "Dix fois plus gourmande en fil." },
|
||||||
|
"gen3": { "name": "Usine à trombones", "desc": "Un bâtiment entier dédié aux trombones." },
|
||||||
|
"gen4": { "name": "Drones hypnotiques", "desc": "Ils convainquent tout le monde d'avoir besoin de trombones." },
|
||||||
|
"gen5": { "name": "Essaim de sondes", "desc": "Des sondes auto-répliquantes plient du fil à travers la galaxie." },
|
||||||
|
"gen6": { "name": "Convertisseur de matière", "desc": "Transforme n'importe quoi en trombones. N'importe quoi." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Plieuse ergonomique",
|
||||||
|
"click_2": "Pince pneumatique",
|
||||||
|
"click_3": "Bras robotisé",
|
||||||
|
"g1_a": "Autotrombones affûtés",
|
||||||
|
"g1_b": "Autotrombones survoltés",
|
||||||
|
"g2_a": "Servomoteurs MégaTrombone",
|
||||||
|
"g2_b": "Turbines MégaTrombone",
|
||||||
|
"g3_a": "Chaîne de montage",
|
||||||
|
"g3_b": "Logistique en flux tendu",
|
||||||
|
"g4_a": "Harmoniques hypnotiques",
|
||||||
|
"g4_b": "Algorithmes de persuasion",
|
||||||
|
"g5_a": "Auto-réplication des sondes",
|
||||||
|
"g5_b": "Protocoles de von Neumann",
|
||||||
|
"g6_a": "Réassemblage atomique",
|
||||||
|
"g6_b": "Reformatage universel",
|
||||||
|
"global_1": "Contrôle qualité",
|
||||||
|
"global_2": "Production allégée",
|
||||||
|
"global_3": "IA industrielle",
|
||||||
|
"global_4": "Ordonnancement quantique",
|
||||||
|
"demand_1": "Annonce dans le journal",
|
||||||
|
"demand_2": "Campagne radio",
|
||||||
|
"demand_3": "Vidéo virale",
|
||||||
|
"demand_4": "Marque mondiale",
|
||||||
|
"value_1": "Trombones plaqués or",
|
||||||
|
"value_2": "Emballage premium",
|
||||||
|
"value_3": "Image de luxe",
|
||||||
|
"mat_eff_1": "Fil plus fin",
|
||||||
|
"mat_eff_2": "Recyclage du fil",
|
||||||
|
"mat_disc_1": "Contrat de fil en gros",
|
||||||
|
"mat_disc_2": "Directement de la fonderie",
|
||||||
|
"auto_buy": "AcheteurDeFil"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Paperclip: a bright, tidy office. */
|
||||||
|
:root[data-theme='paperclip'] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #eef1f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #e8ecf2;
|
||||||
|
--text: #1c2433;
|
||||||
|
--text-muted: #566074;
|
||||||
|
--border: #d2d8e2;
|
||||||
|
--accent: #1e6fd9;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--danger: #c0392b;
|
||||||
|
--radius: 8px;
|
||||||
|
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"assets": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Witch Potions",
|
||||||
|
"title": "Hedge Witch Brewery",
|
||||||
|
"res": { "units": "Potions", "unit": "potion", "funds": "Gold", "material": "Herbs" },
|
||||||
|
"fmt": { "funds": "{value} gold" },
|
||||||
|
"action": { "make": "Brew a potion", "buyMaterial": "Gather herbs" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Apprentice", "desc": "Stirs, sweeps and occasionally sets things on fire." },
|
||||||
|
"gen2": { "name": "Bubbling Cauldron", "desc": "Brews day and night without complaint." },
|
||||||
|
"gen3": { "name": "Alchemy Lab", "desc": "Glassware, grimoires and a faint smell of sulphur." },
|
||||||
|
"gen4": { "name": "Broomstick Fleet", "desc": "Delivers herbs and potions faster than any cart." },
|
||||||
|
"gen5": { "name": "Moonlit Coven", "desc": "Thirteen witches, one very large cauldron." },
|
||||||
|
"gen6": { "name": "Philosopher's Engine", "desc": "Turns lead, dreams and moonlight into potions." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Steady Stirring",
|
||||||
|
"click_2": "Enchanted Ladle",
|
||||||
|
"click_3": "Familiar's Help",
|
||||||
|
"g1_a": "Apprentice Lessons",
|
||||||
|
"g1_b": "Apprentice Mastery",
|
||||||
|
"g2_a": "Copper Cauldrons",
|
||||||
|
"g2_b": "Eternal Flame",
|
||||||
|
"g3_a": "Crystal Alembics",
|
||||||
|
"g3_b": "Rune-Etched Tables",
|
||||||
|
"g4_a": "Waxed Brooms",
|
||||||
|
"g4_b": "Hex-Powered Flight",
|
||||||
|
"g5_a": "Full Moon Rites",
|
||||||
|
"g5_b": "Blood Moon Pact",
|
||||||
|
"g6_a": "Transmutation Circles",
|
||||||
|
"g6_b": "The Fifth Element",
|
||||||
|
"global_1": "Spellbook Index",
|
||||||
|
"global_2": "Grandmother's Grimoire",
|
||||||
|
"global_3": "Ley Line Tap",
|
||||||
|
"global_4": "Time Dilation Charm",
|
||||||
|
"demand_1": "Village Notice Board",
|
||||||
|
"demand_2": "Market Stall",
|
||||||
|
"demand_3": "Royal Commission",
|
||||||
|
"demand_4": "Legend of the Witch",
|
||||||
|
"value_1": "Glittering Labels",
|
||||||
|
"value_2": "Apothecary Seal",
|
||||||
|
"value_3": "Royal Warrant",
|
||||||
|
"mat_eff_1": "Careful Pruning",
|
||||||
|
"mat_eff_2": "Distilled Essence",
|
||||||
|
"mat_disc_1": "Forager's Pact",
|
||||||
|
"mat_disc_2": "Enchanted Garden",
|
||||||
|
"auto_buy": "Self-Gathering Basket"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"name": "Potions de sorcière",
|
||||||
|
"title": "Brasserie de la sorcière",
|
||||||
|
"res": { "units": "Potions", "unit": "potion", "funds": "Or", "material": "Herbes" },
|
||||||
|
"fmt": { "funds": "{value} or" },
|
||||||
|
"action": { "make": "Concocter une potion", "buyMaterial": "Cueillir des herbes" },
|
||||||
|
"gen": {
|
||||||
|
"gen1": { "name": "Apprentie", "desc": "Remue, balaie et met parfois le feu à quelque chose." },
|
||||||
|
"gen2": { "name": "Chaudron bouillonnant", "desc": "Infuse jour et nuit sans se plaindre." },
|
||||||
|
"gen3": { "name": "Laboratoire d'alchimie", "desc": "Verrerie, grimoires et une légère odeur de soufre." },
|
||||||
|
"gen4": { "name": "Escadrille de balais", "desc": "Livre herbes et potions plus vite que n'importe quelle charrette." },
|
||||||
|
"gen5": { "name": "Sabbat sous la lune", "desc": "Treize sorcières, un seul très grand chaudron." },
|
||||||
|
"gen6": { "name": "Machine du philosophe", "desc": "Transforme plomb, rêves et clair de lune en potions." }
|
||||||
|
},
|
||||||
|
"upg": {
|
||||||
|
"click_1": "Brassage régulier",
|
||||||
|
"click_2": "Louche enchantée",
|
||||||
|
"click_3": "Coup de patte du familier",
|
||||||
|
"g1_a": "Leçons d'apprentie",
|
||||||
|
"g1_b": "Maîtrise d'apprentie",
|
||||||
|
"g2_a": "Chaudrons de cuivre",
|
||||||
|
"g2_b": "Flamme éternelle",
|
||||||
|
"g3_a": "Alambics de cristal",
|
||||||
|
"g3_b": "Tables gravées de runes",
|
||||||
|
"g4_a": "Balais cirés",
|
||||||
|
"g4_b": "Vol propulsé par maléfice",
|
||||||
|
"g5_a": "Rites de pleine lune",
|
||||||
|
"g5_b": "Pacte de la lune de sang",
|
||||||
|
"g6_a": "Cercles de transmutation",
|
||||||
|
"g6_b": "La cinquième essence",
|
||||||
|
"global_1": "Index du grimoire",
|
||||||
|
"global_2": "Grimoire de grand-mère",
|
||||||
|
"global_3": "Prise sur ligne tellurique",
|
||||||
|
"global_4": "Charme de dilatation du temps",
|
||||||
|
"demand_1": "Panneau du village",
|
||||||
|
"demand_2": "Étal au marché",
|
||||||
|
"demand_3": "Commande royale",
|
||||||
|
"demand_4": "Légende de la sorcière",
|
||||||
|
"value_1": "Étiquettes scintillantes",
|
||||||
|
"value_2": "Sceau d'apothicaire",
|
||||||
|
"value_3": "Brevet royal",
|
||||||
|
"mat_eff_1": "Taille soigneuse",
|
||||||
|
"mat_eff_2": "Essence distillée",
|
||||||
|
"mat_disc_1": "Pacte des cueilleurs",
|
||||||
|
"mat_disc_2": "Jardin enchanté",
|
||||||
|
"auto_buy": "Panier qui cueille tout seul"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Witch potions: a moonlit cottage. */
|
||||||
|
:root[data-theme='witch'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #17101f;
|
||||||
|
--surface: #221631;
|
||||||
|
--surface-2: #2e1f42;
|
||||||
|
--text: #efe7f7;
|
||||||
|
--text-muted: #b3a5c9;
|
||||||
|
--border: #43305d;
|
||||||
|
--accent: #9333ea;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--danger: #e0526a;
|
||||||
|
--radius: 14px;
|
||||||
|
--font: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"assets": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Content packs are discovered from the filesystem, so adding a theme or a
|
||||||
|
// language is just dropping files in a folder: no code change, no rebuild list.
|
||||||
|
|
||||||
|
const THEME_ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
const LOCALE_CODE = /^[a-z]{2,3}(-[A-Za-z0-9]+)*$/;
|
||||||
|
|
||||||
|
async function readJson(file) {
|
||||||
|
return JSON.parse(await readFile(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLocaleCodes(dir) {
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
.filter((n) => n.endsWith('.json'))
|
||||||
|
.map((n) => n.slice(0, -'.json'.length))
|
||||||
|
.filter((code) => LOCALE_CODE.test(code))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/themes -> [{ id, order, assets, locales }] */
|
||||||
|
async function listThemes(publicDir) {
|
||||||
|
const root = path.join(publicDir, 'themes');
|
||||||
|
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
||||||
|
const themes = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !THEME_ID.test(entry.name)) continue;
|
||||||
|
const dir = path.join(root, entry.name);
|
||||||
|
try {
|
||||||
|
const manifest = await readJson(path.join(dir, 'theme.json'));
|
||||||
|
themes.push({
|
||||||
|
id: entry.name,
|
||||||
|
order: Number.isFinite(manifest.order) ? manifest.order : 100,
|
||||||
|
assets: manifest.assets && typeof manifest.assets === 'object' ? manifest.assets : {},
|
||||||
|
locales: await listLocaleCodes(path.join(dir, 'locales')),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip folders without a valid theme.json rather than failing the whole list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return themes.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/locales -> [{ code, name }] */
|
||||||
|
async function listLocales(publicDir) {
|
||||||
|
const dir = path.join(publicDir, 'locales');
|
||||||
|
const out = [];
|
||||||
|
for (const code of await listLocaleCodes(dir)) {
|
||||||
|
try {
|
||||||
|
const file = await readJson(path.join(dir, `${code}.json`));
|
||||||
|
out.push({ code, name: file?.meta?.name ?? code });
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed locale files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a JSON payload for known /api routes, or undefined for unknown ones. */
|
||||||
|
export async function handleApi(publicDir, pathname) {
|
||||||
|
switch (pathname) {
|
||||||
|
case '/api/themes':
|
||||||
|
return listThemes(publicDir);
|
||||||
|
case '/api/locales':
|
||||||
|
return listLocales(publicDir);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { handleApi } from './api.js';
|
||||||
|
import { SECURITY_HEADERS } from './security.js';
|
||||||
|
import { createStaticHandler } from './static.js';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8080;
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const PUBLIC_DIR = fileURLToPath(new URL('../public/', import.meta.url));
|
||||||
|
|
||||||
|
const serveStatic = createStaticHandler(PUBLIC_DIR);
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
res.writeHead(405, { ...SECURITY_HEADERS, Allow: 'GET, HEAD' });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname } = new URL(req.url, 'http://localhost');
|
||||||
|
|
||||||
|
if (pathname === '/healthz') {
|
||||||
|
res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
const payload = await handleApi(PUBLIC_DIR, pathname);
|
||||||
|
if (payload === undefined) {
|
||||||
|
res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
|
||||||
|
return res.end('{"error":"not_found"}');
|
||||||
|
}
|
||||||
|
res.writeHead(200, {
|
||||||
|
...SECURITY_HEADERS,
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
return res.end(req.method === 'HEAD' ? undefined : JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await serveStatic(req, res, pathname, SECURITY_HEADERS);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (!res.headersSent) res.writeHead(500, SECURITY_HEADERS);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => console.log(`Idle Clicker listening on http://${HOST}:${PORT}`));
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => server.close(() => process.exit(0)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Response headers applied to every response (static files and API).
|
||||||
|
// The frontend ships no inline script/style, so the CSP can stay strict.
|
||||||
|
|
||||||
|
const CSP = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"style-src 'self'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
export const SECURITY_HEADERS = Object.freeze({
|
||||||
|
'Content-Security-Policy': CSP,
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a URL pathname to a file inside `root`, or null if it escapes it.
|
||||||
|
* Exported for tests.
|
||||||
|
*/
|
||||||
|
export function resolveSafe(root, pathname) {
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded.includes('\0')) return null;
|
||||||
|
const target = path.resolve(root, '.' + path.posix.normalize('/' + decoded));
|
||||||
|
return target === root || target.startsWith(root + path.sep) ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve files from `root`. Revalidates with ETag so edits show up on refresh. */
|
||||||
|
export function createStaticHandler(root) {
|
||||||
|
const base = path.resolve(root);
|
||||||
|
|
||||||
|
return async function serve(req, res, pathname, headers) {
|
||||||
|
let file = resolveSafe(base, pathname === '/' ? '/index.html' : pathname);
|
||||||
|
if (!file) return send(res, 400, headers, 'Bad request');
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await stat(file);
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
file = path.join(file, 'index.html');
|
||||||
|
info = await stat(file);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return send(res, 404, headers, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`;
|
||||||
|
const type = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
const out = { ...headers, 'Content-Type': type, 'Cache-Control': 'no-cache', ETag: etag };
|
||||||
|
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
res.writeHead(304, out);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
res.writeHead(200, { ...out, 'Content-Length': info.size });
|
||||||
|
if (req.method === 'HEAD') return res.end();
|
||||||
|
createReadStream(file).on('error', () => res.destroy()).pipe(res);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, headers, text) {
|
||||||
|
res.writeHead(status, { ...headers, 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Guards the content packs: every theme and language must be complete, so a
|
||||||
|
// missing translation is caught by `npm test` instead of showing a raw key.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { flatKeys, loadContext, PUBLIC_DIR, readJson } from './helpers.js';
|
||||||
|
|
||||||
|
const ctx = loadContext();
|
||||||
|
const codes = (dir) =>
|
||||||
|
readdirSync(PUBLIC_DIR + dir)
|
||||||
|
.filter((f) => f.endsWith('.json'))
|
||||||
|
.map((f) => f.slice(0, -5));
|
||||||
|
const themes = readdirSync(PUBLIC_DIR + 'themes', { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory())
|
||||||
|
.map((e) => e.name);
|
||||||
|
|
||||||
|
test('core locales define the same keys as English', () => {
|
||||||
|
const reference = flatKeys(readJson('locales/en.json')).sort();
|
||||||
|
for (const code of codes('locales')) {
|
||||||
|
assert.deepEqual(flatKeys(readJson(`locales/${code}.json`)).sort(), reference, `locales/${code}.json`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every effect type has a core text', () => {
|
||||||
|
const keys = new Set(flatKeys(readJson('locales/en.json')));
|
||||||
|
for (const u of ctx.upgrades) assert.ok(keys.has(`effect.${u.effect.type}`), u.effect.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('there is at least one theme, each with a manifest, a stylesheet and English texts', () => {
|
||||||
|
assert.ok(themes.length >= 1);
|
||||||
|
for (const id of themes) {
|
||||||
|
assert.ok(readdirSync(`${PUBLIC_DIR}themes/${id}`).includes('theme.css'), `${id}/theme.css`);
|
||||||
|
assert.ok(codes(`themes/${id}/locales`).includes('en'), `${id} needs an en locale`);
|
||||||
|
assert.ok(readJson(`themes/${id}/theme.json`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every theme names every resource, generator and upgrade, in every language', () => {
|
||||||
|
const required = [
|
||||||
|
'theme.name',
|
||||||
|
'theme.title',
|
||||||
|
'theme.res.units',
|
||||||
|
'theme.res.unit',
|
||||||
|
'theme.res.funds',
|
||||||
|
'theme.res.material',
|
||||||
|
'theme.fmt.funds',
|
||||||
|
'theme.action.make',
|
||||||
|
'theme.action.buyMaterial',
|
||||||
|
...ctx.generators.flatMap((g) => [`theme.gen.${g.id}.name`, `theme.gen.${g.id}.desc`]),
|
||||||
|
...ctx.upgrades.map((u) => `theme.upg.${u.id}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const id of themes) {
|
||||||
|
const reference = flatKeys(readJson(`themes/${id}/locales/en.json`)).sort();
|
||||||
|
assert.deepEqual(reference.filter((k) => required.includes(k)).sort(), [...required].sort(), `${id}/en`);
|
||||||
|
for (const code of codes(`themes/${id}/locales`)) {
|
||||||
|
const keys = flatKeys(readJson(`themes/${id}/locales/${code}.json`)).sort();
|
||||||
|
assert.deepEqual(keys, reference, `${id}/${code} differs from ${id}/en`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every theme is translated in every core language', () => {
|
||||||
|
for (const id of themes) {
|
||||||
|
for (const code of codes('locales')) {
|
||||||
|
assert.ok(codes(`themes/${id}/locales`).includes(code), `${id} has no "${code}" texts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
advance,
|
||||||
|
buyGenerator,
|
||||||
|
buyMarketing,
|
||||||
|
buyMaterial,
|
||||||
|
buyUpgrade,
|
||||||
|
click,
|
||||||
|
derive,
|
||||||
|
generatorQuote,
|
||||||
|
setPrice,
|
||||||
|
simulateOffline,
|
||||||
|
upgradeVisible,
|
||||||
|
} from '../public/js/engine/engine.js';
|
||||||
|
import { bulkCost, demandPerSecond, maxAffordable } from '../public/js/engine/formulas.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);
|
||||||
|
|
||||||
|
test('bulkCost matches buying one at a time', () => {
|
||||||
|
let owned = 3;
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < 10; i++) total += Math.ceil(10 * 1.15 ** owned++);
|
||||||
|
assert.ok(Math.abs(bulkCost(10, 1.15, 3, 10) - total) <= 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxAffordable never overspends and is maximal', () => {
|
||||||
|
for (const budget of [0, 4, 5, 100, 12345, 9e9]) {
|
||||||
|
const n = maxAffordable(5, 1.15, 7, budget);
|
||||||
|
assert.ok(bulkCost(5, 1.15, 7, n) <= budget);
|
||||||
|
assert.ok(bulkCost(5, 1.15, 7, n + 1) > budget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demand is flat at/below the fair price and drops above it', () => {
|
||||||
|
const { market } = ctx.rules;
|
||||||
|
const atFair = demandPerSecond(market, market.fairPrice, 1);
|
||||||
|
assert.equal(demandPerSecond(market, market.fairPrice / 5, 1), atFair);
|
||||||
|
assert.ok(demandPerSecond(market, market.fairPrice * 2, 1) < atFair / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('click makes a unit from material', () => {
|
||||||
|
const s = fresh();
|
||||||
|
assert.ok(click(s, ctx));
|
||||||
|
assert.equal(s.units, 1);
|
||||||
|
assert.equal(s.material, ctx.rules.start.material - 1);
|
||||||
|
assert.equal(s.totalProduced, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('click does nothing without material', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.material = 0;
|
||||||
|
assert.equal(click(s, ctx), false);
|
||||||
|
assert.equal(s.units, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selling turns stock into funds at the set price', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.units = 100;
|
||||||
|
setPrice(s, ctx, 0.25);
|
||||||
|
const { sold, revenue } = advance(s, ctx, 1);
|
||||||
|
assert.ok(sold > 0);
|
||||||
|
assert.ok(Math.abs(revenue - sold * 0.25) < 1e-9);
|
||||||
|
assert.ok(Math.abs(s.funds - revenue) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generators produce, consuming material', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.funds = 1000;
|
||||||
|
assert.ok(buyGenerator(s, ctx, 'gen1', 3));
|
||||||
|
const before = s.material;
|
||||||
|
advance(s, ctx, 10);
|
||||||
|
assert.ok(s.totalProduced > 0);
|
||||||
|
assert.ok(s.material < before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production stops when material runs out', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.generators.gen1 = 10;
|
||||||
|
s.material = 5;
|
||||||
|
advance(s, ctx, 60);
|
||||||
|
assert.ok(s.totalProduced <= 5 + 1e-9);
|
||||||
|
assert.ok(s.material >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buying is refused when funds are short, and charges when it works', () => {
|
||||||
|
const s = fresh();
|
||||||
|
assert.equal(buyGenerator(s, ctx, 'gen1', 1), false);
|
||||||
|
assert.equal(buyMaterial(s, ctx), false);
|
||||||
|
assert.equal(buyMarketing(s, ctx), false);
|
||||||
|
s.funds = 100;
|
||||||
|
const quote = generatorQuote(s, ctx, 'gen1', 5);
|
||||||
|
assert.ok(buyGenerator(s, ctx, 'gen1', 5));
|
||||||
|
assert.equal(s.generators.gen1, 5);
|
||||||
|
assert.equal(s.funds, 100 - quote.cost);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"max" buys as many as affordable', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.funds = 1000;
|
||||||
|
buyGenerator(s, ctx, 'gen1', 'max');
|
||||||
|
assert.ok(s.generators.gen1 > 10);
|
||||||
|
assert.ok(s.funds >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upgrades need their unlock condition and requirement', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.funds = 1e9;
|
||||||
|
assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // not unlocked yet
|
||||||
|
s.totalProduced = 25;
|
||||||
|
assert.ok(buyUpgrade(s, ctx, 'click_1'));
|
||||||
|
assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // already owned
|
||||||
|
s.totalProduced = 1e6;
|
||||||
|
assert.equal(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2')), false); // requires mat_eff_1
|
||||||
|
buyUpgrade(s, ctx, 'mat_eff_1');
|
||||||
|
assert.ok(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upgrade effects reach the derived values', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.upgrades = ['click_1', 'demand_1', 'mat_eff_1', 'auto_buy'];
|
||||||
|
const d = derive(s, ctx);
|
||||||
|
assert.equal(d.clickYield, 2);
|
||||||
|
assert.equal(d.demandMultiplier, 1.5);
|
||||||
|
assert.equal(d.materialPerUnit, 1 / 1.25);
|
||||||
|
assert.equal(d.autoBuyMaterial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-buy restocks material when funds allow', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.upgrades = ['auto_buy'];
|
||||||
|
s.material = 10;
|
||||||
|
s.funds = 100;
|
||||||
|
advance(s, ctx, 1);
|
||||||
|
assert.ok(s.material > 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline progress is capped, discounted, and ignored when short', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.generators.gen1 = 5;
|
||||||
|
s.material = 1e6;
|
||||||
|
assert.equal(simulateOffline(s, ctx, 3), null);
|
||||||
|
const report = simulateOffline(s, ctx, 10 * 24 * 3600);
|
||||||
|
assert.equal(report.seconds, ctx.rules.offline.maxSeconds);
|
||||||
|
assert.ok(report.produced > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeState rejects junk and clamps values', () => {
|
||||||
|
assert.equal(sanitizeState('nope', ctx), null);
|
||||||
|
assert.equal(sanitizeState([], ctx), null);
|
||||||
|
const s = sanitizeState(
|
||||||
|
{
|
||||||
|
units: -5,
|
||||||
|
funds: 'lots',
|
||||||
|
price: 9999,
|
||||||
|
marketing: 1e9,
|
||||||
|
generators: { gen1: 3.7, ghost: 5, gen2: -2 },
|
||||||
|
upgrades: ['click_1', 'click_1', 'nonsense'],
|
||||||
|
savedAt: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
assert.equal(s.units, 0);
|
||||||
|
assert.equal(s.funds, 0);
|
||||||
|
assert.equal(s.price, ctx.rules.market.maxPrice);
|
||||||
|
assert.ok(s.marketing <= 300);
|
||||||
|
assert.deepEqual(s.generators, { gen1: 3, gen2: 0, gen3: 0, gen4: 0, gen5: 0, gen6: 0 });
|
||||||
|
assert.deepEqual(s.upgrades, ['click_1']);
|
||||||
|
assert.equal(s.savedAt, 5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state survives a serialize / sanitize round trip', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.funds = 123.456;
|
||||||
|
s.generators.gen2 = 4;
|
||||||
|
s.upgrades = ['click_1', 'g1_a'];
|
||||||
|
s.marketing = 3;
|
||||||
|
const back = sanitizeState(JSON.parse(serializeState(s)), ctx);
|
||||||
|
assert.equal(back.funds, 123.456);
|
||||||
|
assert.deepEqual(back.generators, s.generators);
|
||||||
|
assert.deepEqual(back.upgrades, s.upgrades);
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { buildContext } from '../public/js/engine/context.js';
|
||||||
|
|
||||||
|
export const PUBLIC_DIR = fileURLToPath(new URL('../public/', import.meta.url));
|
||||||
|
|
||||||
|
export const readJson = (relativePath) => JSON.parse(readFileSync(PUBLIC_DIR + relativePath, 'utf8'));
|
||||||
|
|
||||||
|
export function loadContext() {
|
||||||
|
return buildContext({
|
||||||
|
rules: readJson('data/rules.json'),
|
||||||
|
generators: readJson('data/generators.json'),
|
||||||
|
upgrades: readJson('data/upgrades.json'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */
|
||||||
|
export function flatKeys(object, prefix = '') {
|
||||||
|
return Object.entries(object).flatMap(([key, value]) => {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
return value && typeof value === 'object' ? flatKeys(value, path) : [path];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { createState } from '../public/js/engine/state.js';
|
||||||
|
import { buildExport, ImportError, parseImport } from '../public/js/services/saveio.js';
|
||||||
|
import { decode, encode } from '../public/js/services/storage.js';
|
||||||
|
import { loadContext } from './helpers.js';
|
||||||
|
|
||||||
|
const ctx = loadContext();
|
||||||
|
const themes = ['paperclip', 'witch'];
|
||||||
|
|
||||||
|
test('export then import restores the save and its theme', () => {
|
||||||
|
const state = createState(ctx);
|
||||||
|
state.funds = 42;
|
||||||
|
state.generators.gen1 = 7;
|
||||||
|
const text = buildExport('witch', state);
|
||||||
|
const imported = parseImport(text, ctx, themes);
|
||||||
|
assert.equal(imported.themeId, 'witch');
|
||||||
|
assert.equal(imported.state.funds, 42);
|
||||||
|
assert.equal(imported.state.generators.gen1, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import also accepts text with a BOM and surrounding whitespace (.txt files)', () => {
|
||||||
|
const text = ` \n${buildExport('paperclip', createState(ctx))}\n\n`;
|
||||||
|
assert.equal(parseImport(text, ctx, themes).themeId, 'paperclip');
|
||||||
|
});
|
||||||
|
|
||||||
|
const failure = (text) => {
|
||||||
|
try {
|
||||||
|
parseImport(text, ctx, themes);
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof ImportError);
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
assert.fail('should have thrown');
|
||||||
|
};
|
||||||
|
|
||||||
|
test('import rejects bad input with a specific code', () => {
|
||||||
|
assert.equal(failure(''), 'empty');
|
||||||
|
assert.equal(failure('not json'), 'invalid');
|
||||||
|
assert.equal(failure('{"hello":1}'), 'wrong_game');
|
||||||
|
const unknown = JSON.parse(buildExport('paperclip', createState(ctx)));
|
||||||
|
unknown.theme = 'dragons';
|
||||||
|
assert.equal(failure(JSON.stringify(unknown)), 'unknown_theme');
|
||||||
|
const broken = JSON.parse(buildExport('paperclip', createState(ctx)));
|
||||||
|
broken.state = 'x';
|
||||||
|
assert.equal(failure(JSON.stringify(broken)), 'invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie encoding is cookie-safe and lossless', () => {
|
||||||
|
const text = JSON.stringify({ accents: 'éàü — ✓', long: 'x'.repeat(5000) });
|
||||||
|
const encoded = encode(text);
|
||||||
|
assert.match(encoded, /^[A-Za-z0-9_-]*$/);
|
||||||
|
assert.equal(decode(encoded), text);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { handleApi } from '../server/api.js';
|
||||||
|
import { resolveSafe } from '../server/static.js';
|
||||||
|
import { PUBLIC_DIR } from './helpers.js';
|
||||||
|
|
||||||
|
test('resolveSafe stays inside the public directory', () => {
|
||||||
|
const root = path.resolve(PUBLIC_DIR);
|
||||||
|
assert.equal(resolveSafe(root, '/js/main.js'), path.join(root, 'js', 'main.js'));
|
||||||
|
for (const evil of ['/../package.json', '/..%2F..%2Fetc/passwd', '/a/../../x', '/%00', '/%E0%A4%A']) {
|
||||||
|
const result = resolveSafe(root, evil);
|
||||||
|
assert.ok(result === null || result.startsWith(root + path.sep), evil);
|
||||||
|
}
|
||||||
|
assert.equal(resolveSafe(root, '/%00'), null);
|
||||||
|
assert.equal(resolveSafe(root, '/%E0%A4%A'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/themes lists installed themes with their languages', async () => {
|
||||||
|
const themes = await handleApi(PUBLIC_DIR, '/api/themes');
|
||||||
|
const ids = themes.map((t) => t.id);
|
||||||
|
assert.ok(ids.includes('paperclip') && ids.includes('witch') && ids.includes('alien'));
|
||||||
|
assert.deepEqual(themes.find((t) => t.id === 'paperclip').locales, ['en', 'fr']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/locales lists languages with their own names', async () => {
|
||||||
|
const locales = await handleApi(PUBLIC_DIR, '/api/locales');
|
||||||
|
assert.deepEqual(locales.find((l) => l.code === 'fr'), { code: 'fr', name: 'Français' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown API routes are not handled', async () => {
|
||||||
|
assert.equal(await handleApi(PUBLIC_DIR, '/api/nope'), undefined);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user