From aa9721ac2a53dbde52aeaf0362e4bc3ab676828d Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Thu, 8 Oct 2026 11:58:18 +0200 Subject: [PATCH] feat: First iteration, multi themes, I18N --- .dockerignore | 11 + .gitignore | 6 + Dockerfile | 23 ++ README.md | 74 ++++- docker-compose.yml | 18 + package.json | 15 + public/css/base.css | 89 +++++ public/css/components.css | 425 ++++++++++++++++++++++++ public/css/layout.css | 67 ++++ public/data/generators.json | 8 + public/data/rules.json | 25 ++ public/data/upgrades.json | 38 +++ public/index.html | 162 +++++++++ public/js/engine/context.js | 27 ++ public/js/engine/effects.js | 38 +++ public/js/engine/engine.js | 179 ++++++++++ public/js/engine/formulas.js | 37 +++ public/js/engine/state.js | 70 ++++ public/js/main.js | 237 +++++++++++++ public/js/services/cookies.js | 21 ++ public/js/services/data.js | 24 ++ public/js/services/format.js | 25 ++ public/js/services/i18n.js | 81 +++++ public/js/services/saveio.js | 64 ++++ public/js/services/storage.js | 106 ++++++ public/js/services/themes.js | 18 + public/js/ui/dom.js | 44 +++ public/js/ui/settings.js | 71 ++++ public/js/ui/toast.js | 9 + public/js/ui/view.js | 231 +++++++++++++ public/locales/en.json | 83 +++++ public/locales/fr.json | 83 +++++ public/themes/alien/locales/en.json | 50 +++ public/themes/alien/locales/fr.json | 50 +++ public/themes/alien/theme.css | 15 + public/themes/alien/theme.json | 4 + public/themes/paperclip/locales/en.json | 50 +++ public/themes/paperclip/locales/fr.json | 50 +++ public/themes/paperclip/theme.css | 15 + public/themes/paperclip/theme.json | 4 + public/themes/witch/locales/en.json | 50 +++ public/themes/witch/locales/fr.json | 50 +++ public/themes/witch/theme.css | 15 + public/themes/witch/theme.json | 4 + server/api.js | 76 +++++ server/index.js | 53 +++ server/security.js | 23 ++ server/static.js | 71 ++++ tests/content.test.js | 70 ++++ tests/engine.test.js | 195 +++++++++++ tests/helpers.js | 23 ++ tests/saveio.test.js | 54 +++ tests/server.test.js | 33 ++ 53 files changed, 3363 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/css/base.css create mode 100644 public/css/components.css create mode 100644 public/css/layout.css create mode 100644 public/data/generators.json create mode 100644 public/data/rules.json create mode 100644 public/data/upgrades.json create mode 100644 public/index.html create mode 100644 public/js/engine/context.js create mode 100644 public/js/engine/effects.js create mode 100644 public/js/engine/engine.js create mode 100644 public/js/engine/formulas.js create mode 100644 public/js/engine/state.js create mode 100644 public/js/main.js create mode 100644 public/js/services/cookies.js create mode 100644 public/js/services/data.js create mode 100644 public/js/services/format.js create mode 100644 public/js/services/i18n.js create mode 100644 public/js/services/saveio.js create mode 100644 public/js/services/storage.js create mode 100644 public/js/services/themes.js create mode 100644 public/js/ui/dom.js create mode 100644 public/js/ui/settings.js create mode 100644 public/js/ui/toast.js create mode 100644 public/js/ui/view.js create mode 100644 public/locales/en.json create mode 100644 public/locales/fr.json create mode 100644 public/themes/alien/locales/en.json create mode 100644 public/themes/alien/locales/fr.json create mode 100644 public/themes/alien/theme.css create mode 100644 public/themes/alien/theme.json create mode 100644 public/themes/paperclip/locales/en.json create mode 100644 public/themes/paperclip/locales/fr.json create mode 100644 public/themes/paperclip/theme.css create mode 100644 public/themes/paperclip/theme.json create mode 100644 public/themes/witch/locales/en.json create mode 100644 public/themes/witch/locales/fr.json create mode 100644 public/themes/witch/theme.css create mode 100644 public/themes/witch/theme.json create mode 100644 server/api.js create mode 100644 server/index.js create mode 100644 server/security.js create mode 100644 server/static.js create mode 100644 tests/content.test.js create mode 100644 tests/engine.test.js create mode 100644 tests/helpers.js create mode 100644 tests/saveio.test.js create mode 100644 tests/server.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..00e1b35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.dockerignore +.claude +node_modules +npm-debug.log +tests +Dockerfile +docker-compose.yml +README.md +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..019dcb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +npm-debug.log* +.env +.DS_Store +Thumbs.db +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f72238d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 4b1d6be..880d889 100644 --- a/README.md +++ b/README.md @@ -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// ONE FOLDER PER THEME + theme.json order + (later) image paths + theme.css colours/fonts, as CSS variables + locales/.json the theme's vocabulary: names of resources, generators, upgrades + js/ + engine/ game rules, pure JS, no DOM (also used by the tests) + 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//` (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/.json` and translate it (set `meta.name` to the language's own name). +2. Add `public/themes//locales/.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.`, `upg.`. Anything not listed keeps the placeholder. + +## Saves + +- Each theme has its own save slot, stored in cookies (`ic_save__*`, 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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6baccda --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..e603dc8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/css/base.css b/public/css/base.css new file mode 100644 index 0000000..d444c37 --- /dev/null +++ b/public/css/base.css @@ -0,0 +1,89 @@ +/* + * Base: reset + the design tokens every theme can override. + * A theme (themes//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; +} diff --git a/public/css/components.css b/public/css/components.css new file mode 100644 index 0000000..d6ffd18 --- /dev/null +++ b/public/css/components.css @@ -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; +} diff --git a/public/css/layout.css b/public/css/layout.css new file mode 100644 index 0000000..3de50ef --- /dev/null +++ b/public/css/layout.css @@ -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'; + } +} diff --git a/public/data/generators.json b/public/data/generators.json new file mode 100644 index 0000000..ad3f8e9 --- /dev/null +++ b/public/data/generators.json @@ -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 } } +] diff --git a/public/data/rules.json b/public/data/rules.json new file mode 100644 index 0000000..2c207b8 --- /dev/null +++ b/public/data/rules.json @@ -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 +} diff --git a/public/data/upgrades.json b/public/data/upgrades.json new file mode 100644 index 0000000..ced3165 --- /dev/null +++ b/public/data/upgrades.json @@ -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" } } +] diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..37acb5f --- /dev/null +++ b/public/index.html @@ -0,0 +1,162 @@ + + + + + + + Idle Clicker + + + + + + + +
+

Idle Clicker

+
+ + + +
+
+ + + +
+ +
+

Overview

+
+
+ +
+ + 0 +
+
+
+ +
+ + 0 +
+
+
+ +
+ + 0 +
+
+
+
+
0
+
0
+
0
+
0
+
+
+ + +
+

Manual work

+ + +
+ + +
+

Market

+
+ +
+ + + +
+ +

+
+ +
+ + +
+
+

Automation

+
+ + + + + +
+
+
    +
    + + +
    +
    +

    Upgrades

    + +
    +

    +
      +
      +
      + + + +
      +
      +

      + +
      + +

      +

      + +
      + + + +
      + +

      +

      +
      + + +
      + +
      + +
      + +

      +
      + +
      +
      +
      + +
      + + diff --git a/public/js/engine/context.js b/public/js/engine/context.js new file mode 100644 index 0000000..44a6681 --- /dev/null +++ b/public/js/engine/context.js @@ -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 }; +} diff --git a/public/js/engine/effects.js b/public/js/engine/effects.js new file mode 100644 index 0000000..5cac5ed --- /dev/null +++ b/public/js/engine/effects.js @@ -0,0 +1,38 @@ +// Upgrade effects. To add a new effect type: register it here, add its +// `effect.` string to the core locale files, and use it in upgrades.json. + +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; +} diff --git a/public/js/engine/engine.js b/public/js/engine/engine.js new file mode 100644 index 0000000..4141ee4 --- /dev/null +++ b/public/js/engine/engine.js @@ -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) }; +} diff --git a/public/js/engine/formulas.js b/public/js/engine/formulas.js new file mode 100644 index 0000000..77377e4 --- /dev/null +++ b/public/js/engine/formulas.js @@ -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; +} diff --git a/public/js/engine/state.js b/public/js/engine/state.js new file mode 100644 index 0000000..38ea7df --- /dev/null +++ b/public/js/engine/state.js @@ -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, + ); +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..0d8f6cc --- /dev/null +++ b/public/js/main.js @@ -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); diff --git a/public/js/services/cookies.js b/public/js/services/cookies.js new file mode 100644 index 0000000..6d0981c --- /dev/null +++ b/public/js/services/cookies.js @@ -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`; +} diff --git a/public/js/services/data.js b/public/js/services/data.js new file mode 100644 index 0000000..30968e6 --- /dev/null +++ b/public/js/services/data.js @@ -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'); diff --git a/public/js/services/format.js b/public/js/services/format.js new file mode 100644 index 0000000..01c2948 --- /dev/null +++ b/public/js/services/format.js @@ -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) }; +} diff --git a/public/js/services/i18n.js b/public/js/services/i18n.js new file mode 100644 index 0000000..6d436b5 --- /dev/null +++ b/public/js/services/i18n.js @@ -0,0 +1,81 @@ +// Internationalisation. +// +// Two kinds of locale files are merged into one flat dictionary: +// locales/.json core UI texts (shared by all themes) +// themes//locales/.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; +} diff --git a/public/js/services/saveio.js b/public/js/services/saveio.js new file mode 100644 index 0000000..e55a85a --- /dev/null +++ b/public/js/services/saveio.js @@ -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(); +} diff --git a/public/js/services/storage.js b/public/js/services/storage.js new file mode 100644 index 0000000..3dadbca --- /dev/null +++ b/public/js/services/storage.js @@ -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__n -> number of chunks +// ic_save__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 {}; + } +} diff --git a/public/js/services/themes.js b/public/js/services/themes.js new file mode 100644 index 0000000..2bade55 --- /dev/null +++ b/public/js/services/themes.js @@ -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`; + }); +} diff --git a/public/js/ui/dom.js b/public/js/ui/dom.js new file mode 100644 index 0000000..e197fea --- /dev/null +++ b/public/js/ui/dom.js @@ -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)); + } +} diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js new file mode 100644 index 0000000..8fd0635 --- /dev/null +++ b/public/js/ui/settings.js @@ -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 }; +} diff --git a/public/js/ui/toast.js b/public/js/ui/toast.js new file mode 100644 index 0000000..15c27c0 --- /dev/null +++ b/public/js/ui/toast.js @@ -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); +} diff --git a/public/js/ui/view.js b/public/js/ui/view.js new file mode 100644 index 0000000..e26a48e --- /dev/null +++ b/public/js/ui/view.js @@ -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 }; +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..8fadd3b --- /dev/null +++ b/public/locales/en.json @@ -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." + } + } +} diff --git a/public/locales/fr.json b/public/locales/fr.json new file mode 100644 index 0000000..705372e --- /dev/null +++ b/public/locales/fr.json @@ -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é." + } + } +} diff --git a/public/themes/alien/locales/en.json b/public/themes/alien/locales/en.json new file mode 100644 index 0000000..d20e9d7 --- /dev/null +++ b/public/themes/alien/locales/en.json @@ -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" + } + } +} diff --git a/public/themes/alien/locales/fr.json b/public/themes/alien/locales/fr.json new file mode 100644 index 0000000..484a32b --- /dev/null +++ b/public/themes/alien/locales/fr.json @@ -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" + } + } +} diff --git a/public/themes/alien/theme.css b/public/themes/alien/theme.css new file mode 100644 index 0000000..f11c7a6 --- /dev/null +++ b/public/themes/alien/theme.css @@ -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; +} diff --git a/public/themes/alien/theme.json b/public/themes/alien/theme.json new file mode 100644 index 0000000..aaa9542 --- /dev/null +++ b/public/themes/alien/theme.json @@ -0,0 +1,4 @@ +{ + "order": 3, + "assets": {} +} diff --git a/public/themes/paperclip/locales/en.json b/public/themes/paperclip/locales/en.json new file mode 100644 index 0000000..bcc1e36 --- /dev/null +++ b/public/themes/paperclip/locales/en.json @@ -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" + } + } +} diff --git a/public/themes/paperclip/locales/fr.json b/public/themes/paperclip/locales/fr.json new file mode 100644 index 0000000..015eb22 --- /dev/null +++ b/public/themes/paperclip/locales/fr.json @@ -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" + } + } +} diff --git a/public/themes/paperclip/theme.css b/public/themes/paperclip/theme.css new file mode 100644 index 0000000..72e3e1a --- /dev/null +++ b/public/themes/paperclip/theme.css @@ -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; +} diff --git a/public/themes/paperclip/theme.json b/public/themes/paperclip/theme.json new file mode 100644 index 0000000..a329f8f --- /dev/null +++ b/public/themes/paperclip/theme.json @@ -0,0 +1,4 @@ +{ + "order": 1, + "assets": {} +} diff --git a/public/themes/witch/locales/en.json b/public/themes/witch/locales/en.json new file mode 100644 index 0000000..46c1bc8 --- /dev/null +++ b/public/themes/witch/locales/en.json @@ -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" + } + } +} diff --git a/public/themes/witch/locales/fr.json b/public/themes/witch/locales/fr.json new file mode 100644 index 0000000..8387c84 --- /dev/null +++ b/public/themes/witch/locales/fr.json @@ -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" + } + } +} diff --git a/public/themes/witch/theme.css b/public/themes/witch/theme.css new file mode 100644 index 0000000..6ddeb7e --- /dev/null +++ b/public/themes/witch/theme.css @@ -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; +} diff --git a/public/themes/witch/theme.json b/public/themes/witch/theme.json new file mode 100644 index 0000000..cc4b494 --- /dev/null +++ b/public/themes/witch/theme.json @@ -0,0 +1,4 @@ +{ + "order": 2, + "assets": {} +} diff --git a/server/api.js b/server/api.js new file mode 100644 index 0000000..c86c2ee --- /dev/null +++ b/server/api.js @@ -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; + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..172c2fd --- /dev/null +++ b/server/index.js @@ -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))); +} diff --git a/server/security.js b/server/security.js new file mode 100644 index 0000000..5afbe6e --- /dev/null +++ b/server/security.js @@ -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=()', +}); diff --git a/server/static.js b/server/static.js new file mode 100644 index 0000000..28c3540 --- /dev/null +++ b/server/static.js @@ -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); +} diff --git a/tests/content.test.js b/tests/content.test.js new file mode 100644 index 0000000..0ccb750 --- /dev/null +++ b/tests/content.test.js @@ -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`); + } + } +}); diff --git a/tests/engine.test.js b/tests/engine.test.js new file mode 100644 index 0000000..857e2d8 --- /dev/null +++ b/tests/engine.test.js @@ -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); +}); diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..287f9a6 --- /dev/null +++ b/tests/helpers.js @@ -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]; + }); +} diff --git a/tests/saveio.test.js b/tests/saveio.test.js new file mode 100644 index 0000000..4266f17 --- /dev/null +++ b/tests/saveio.test.js @@ -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); +}); diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..9e882e6 --- /dev/null +++ b/tests/server.test.js @@ -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); +});