From 1734487f1e87ffadfeada0c3e33347a0f29ca103 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Thu, 8 Oct 2026 12:25:45 +0200 Subject: [PATCH] feat: New theme + wire price adjustement --- README.md | 13 +++--- public/css/layout.css | 1 + public/data/rules.json | 21 ++++++---- public/js/engine/engine.js | 28 +++++++++---- public/js/engine/formulas.js | 24 +++++++++++ public/js/engine/state.js | 3 +- public/js/main.js | 60 ++++++++++++++++++--------- public/js/services/saveio.js | 15 ++++--- public/js/services/storage.js | 37 ++++++++++++----- public/js/ui/view.js | 7 +++- public/locales/en.json | 11 +++-- public/locales/fr.json | 11 +++-- public/themes/insects/locales/en.json | 50 ++++++++++++++++++++++ public/themes/insects/locales/fr.json | 50 ++++++++++++++++++++++ public/themes/insects/theme.css | 15 +++++++ public/themes/insects/theme.json | 4 ++ tests/engine.test.js | 53 +++++++++++++++++++++++ tests/saveio.test.js | 31 +++++++------- tests/server.test.js | 2 +- 19 files changed, 348 insertions(+), 88 deletions(-) create mode 100644 public/themes/insects/locales/en.json create mode 100644 public/themes/insects/locales/fr.json create mode 100644 public/themes/insects/theme.css create mode 100644 public/themes/insects/theme.json diff --git a/README.md b/README.md index 880d889..c642a5b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Idle Clicker -A themeable idle game inspired by *Universal Paperclips*. Make units by hand, sell them, automate production, buy upgrades. Runs in any browser (phone, tablet, desktop); progress is saved in cookies and can be exported/imported as a JSON or TXT file. +A themeable idle game inspired by *Universal Paperclips*. Make units by hand, sell them, automate production, buy upgrades. Runs in any browser (phone, tablet, desktop); progress is saved in cookies and can be exported/imported as a JSON or TXT file. There is **one game**: themes (paperclips, witch potions, alien minerals, leaf-piling insects) are only skins, so switching theme keeps every amount. Zero runtime dependencies: a small Node server (`http` module only) plus a vanilla JS frontend (ES modules). @@ -64,11 +64,14 @@ Keys: `res.units|funds|material`, `gen.`, `upg.`. Anything not listed ke ## 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. +- One shared game for all themes, stored in cookies (`ic_game_*`, chunked and base64-encoded because a cookie holds ~4 KB), plus one preferences cookie (theme + language). Saves from the first version (one per theme) are merged automatically: the most advanced one is kept. +- **Save & settings** exports the save as `.json` or `.txt` (download or copy/paste) and imports either (file picker or pasted text). The file does not depend on the theme. Imports are validated and sanitised: unknown ids are dropped, numbers clamped. - Progress continues while the game is closed: up to 8 h, at 50 % efficiency (`public/data/rules.json`). -- Cookies are sent with every request to the server. A save is under 2 KB and there is one per theme, so this stays far below header limits, but it is the reason to keep saves compact. +- Cookies are sent with every request to the server. A save is under 2 KB, far below header limits, but it is the reason to keep saves compact. ## 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. +Everything numeric is in `public/data/`: `rules.json` (market, raw material, marketing, offline, autosave), `generators.json`, `upgrades.json`. + +- **Selling:** customers want `baseDemand × demandMultiplier` units/s at the *fair price* and demand collapses above it. Raise the price when supply is short; buy marketing/demand upgrades to sell more. +- **Raw material** (wire, herbs…): the price per unit starts high (`startUnitPrice`, about a third of the sale price) and falls towards `floorUnitPrice` as total production grows (economies of scale). Lots grow with the operation, in doublings. The market is erratic: random swings every `updateEverySeconds` plus occasional shortages and gluts (`spikeChance`, `spikeSize`), with ▲/▼ next to the price showing its last move. The auto-buy upgrade is patient: it waits for a fair price unless stock is nearly gone. diff --git a/public/css/layout.css b/public/css/layout.css index 3de50ef..5398433 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -59,6 +59,7 @@ @media (min-width: 1200px) { .game { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr); + grid-template-rows: auto auto 1fr; /* spare height goes below the left column, not between its panels */ grid-template-areas: 'stats stats stats' 'production generators upgrades' diff --git a/public/data/rules.json b/public/data/rules.json index 2c207b8..383dc80 100644 --- a/public/data/rules.json +++ b/public/data/rules.json @@ -11,13 +11,20 @@ "priceStep": 0.01 }, "materialMarket": { - "batchSize": 100, - "basePrice": 2, - "minFactor": 0.5, - "maxFactor": 2, - "volatility": 0.12, - "reversion": 0.05, - "updateEverySeconds": 2 + "minBatch": 100, + "batchShareOfTotal": 0.1, + "batchSecondsOfProduction": 60, + "startUnitPrice": 0.08, + "floorUnitPrice": 0.01, + "scale": 3000, + "minFactor": 0.4, + "maxFactor": 3, + "volatility": 0.3, + "reversion": 0.08, + "spikeChance": 0.06, + "spikeSize": 1.9, + "updateEverySeconds": 1.5, + "autoBuyBelowFactor": 1.2 }, "marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 }, "offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 }, diff --git a/public/js/engine/engine.js b/public/js/engine/engine.js index 4141ee4..992f453 100644 --- a/public/js/engine/engine.js +++ b/public/js/engine/engine.js @@ -3,7 +3,7 @@ // browser and in Node (tests, balance simulations). import { computeModifiers } from './effects.js'; -import { bulkCost, clamp, demandPerSecond, maxAffordable } from './formulas.js'; +import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.js'; const MAX_STEP_SECONDS = 1; @@ -38,6 +38,13 @@ export function derive(state, ctx) { } const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing; + const batch = materialBatch( + rules.materialMarket, + state.totalProduced, + rate, + state.materialFactor, + mods.batchDiscount, + ); return { clickYield: rules.click.yield * mods.click, perGenerator, @@ -46,8 +53,8 @@ export function derive(state, ctx) { 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), + batchSize: batch.size, + batchCost: batch.cost, autoBuyMaterial: mods.autoBuyMaterial, marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing), }; @@ -121,9 +128,12 @@ function stepMaterialMarket(state, ctx, dt, rng) { state.materialTimer += dt; while (state.materialTimer >= m.updateEverySeconds) { state.materialTimer -= m.updateEverySeconds; - const shock = 1 + (rng() - 0.5) * 2 * m.volatility; + let shock = 1 + (rng() - 0.5) * 2 * m.volatility; + if (rng() < m.spikeChance) shock *= rng() < 0.5 ? m.spikeSize : 1 / m.spikeSize; // shortage or glut const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion; - state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor); + const next = clamp(pulled * shock, m.minFactor, m.maxFactor); + state.materialTrend = Math.sign(next - state.materialFactor); + state.materialFactor = next; } } @@ -137,9 +147,11 @@ function step(state, ctx, dt, rng, totals) { 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); + // Automatic restock: patient while the price is high, urgent when nearly out. + if (d.autoBuyMaterial && state.funds >= d.batchCost) { + const { autoBuyBelowFactor } = ctx.rules.materialMarket; + const low = state.material < d.batchSize / 2 && state.materialFactor <= autoBuyBelowFactor; + if (low || state.material < d.batchSize / 10) buyMaterial(state, ctx); } // Customers buy what they want, up to what is in stock. diff --git a/public/js/engine/formulas.js b/public/js/engine/formulas.js index 77377e4..f996dcc 100644 --- a/public/js/engine/formulas.js +++ b/public/js/engine/formulas.js @@ -25,6 +25,30 @@ export function maxAffordable(base, growth, owned, budget) { return Math.max(n, 0); } +/** + * Raw-material price per unit of material, before market noise. Expensive at + * first, then cheaper relative to revenue as the operation scales + * (economies of scale): it falls from `startUnitPrice` towards `floorUnitPrice`. + */ +export function materialUnitPrice(mm, totalProduced) { + const scale = 1 / Math.sqrt(1 + totalProduced / mm.scale); + return mm.floorUnitPrice + (mm.startUnitPrice - mm.floorUnitPrice) * scale; +} + +/** + * What one purchase of material looks like. The lot grows with the operation + * (a share of everything produced so far, or `batchSecondsOfProduction` of + * current output), in doubling steps so the price stays readable. + * `factor` is the market's current noise, `discount` comes from upgrades. + */ +export function materialBatch(mm, totalProduced, rate, factor, discount) { + const basis = Math.max(totalProduced * mm.batchShareOfTotal, rate * mm.batchSecondsOfProduction); + const doublings = Math.max(0, Math.floor(Math.log2(Math.max(1, basis / mm.minBatch)))); + const size = mm.minBatch * 2 ** doublings; + const cost = Math.max(1, Math.ceil(size * materialUnitPrice(mm, totalProduced) * factor * discount)); + return { size, cost }; +} + /** * Units customers want to buy per second at `price`. * Demand is maxed at or below the fair price and drops steeply above it, so diff --git a/public/js/engine/state.js b/public/js/engine/state.js index 38ea7df..b885310 100644 --- a/public/js/engine/state.js +++ b/public/js/engine/state.js @@ -22,6 +22,7 @@ export function createState(ctx, now = Date.now()) { upgrades: [], savedAt: now, materialTimer: 0, // transient, never saved + materialTrend: 0, // transient: -1 falling, 0 steady, 1 rising }; } @@ -63,7 +64,7 @@ export function sanitizeState(raw, ctx, now = Date.now()) { /** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */ export function serializeState(state) { - const { materialTimer, ...persisted } = state; + const { materialTimer, materialTrend, ...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 index 0d8f6cc..2ea1ca7 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -16,7 +16,15 @@ 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 { + cookiesAvailable, + deleteGame, + loadGameText, + loadPrefs, + saveGameText, + savePrefs, + takeLegacySaves, +} from './services/storage.js'; import { applyThemeStyles } from './services/themes.js'; import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js'; import { createSettings } from './ui/settings.js'; @@ -44,7 +52,7 @@ function persist() { if (!state) return; state.savedAt = Date.now(); try { - saveGameText(themeId, serializeState(state)); + saveGameText(serializeState(state)); } catch { if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) { lastSaveError = Date.now(); @@ -53,9 +61,7 @@ function persist() { } } -function loadSavedState() { - const text = loadGameText(themeId); - if (!text) return null; +function parseSaved(text) { try { return sanitizeState(JSON.parse(text), ctx); } catch { @@ -63,6 +69,19 @@ function loadSavedState() { } } +/** + * The shared game from cookies. Falls back to the per-theme saves of the first + * version: the most advanced one becomes the shared game, the others are dropped. + */ +function loadSavedState() { + const text = loadGameText(); + if (text) return parseSaved(text); + + const legacy = takeLegacySaves(themes.map((th) => th.id)).map(parseSaved).filter(Boolean); + legacy.sort((a, b) => b.totalProduced - a.totalProduced); + return legacy[0] ?? null; +} + function persistPrefs() { savePrefs({ theme: themeId, lang: locale }); } @@ -107,16 +126,13 @@ async function refreshTexts() { await fillSelects(); } -async function enterTheme(id) { - persist(); // keep the theme we are leaving +/** Change the skin only: the game state is shared by every theme. */ +async function setTheme(id) { themeId = id; await applyThemeStyles(id); await refreshTexts(); - state = loadSavedState() ?? createState(ctx); - income = 0; - catchUp((Date.now() - state.savedAt) / 1000); persistPrefs(); - render(); + if (state) render(); } // ------------------------------------------------------------------ the loop @@ -181,18 +197,17 @@ const view = createView({ createSettings({ getExport: () => ({ - text: buildExport(themeId, state), - name: `idle-clicker-${themeId}-${new Date().toISOString().slice(0, 10)}`, + text: buildExport(state), + name: `idle-clicker-${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; + importText(text) { + state = parseImport(text, ctx); // throws before anything changes + income = 0; persist(); render(); }, reset() { - deleteGame(themeId); + deleteGame(); state = createState(ctx); income = 0; persist(); @@ -216,7 +231,7 @@ async function boot() { const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0]; $('#cookie-warning').hidden = cookiesAvailable(); - $('#theme-select').addEventListener('change', (e) => enterTheme(e.target.value)); + $('#theme-select').addEventListener('change', (e) => setTheme(e.target.value)); $('#locale-select').addEventListener('change', async (e) => { locale = e.target.value; persistPrefs(); @@ -224,9 +239,14 @@ async function boot() { render(); }); - await enterTheme(initialTheme.id); + await setTheme(initialTheme.id); refreshStaticIcons(); + state = loadSavedState() ?? createState(ctx); + catchUp((Date.now() - state.savedAt) / 1000); + persist(); + render(); + lastFrame = Date.now(); setInterval(frame, TICK_MS); setInterval(persist, ctx.rules.autosaveSeconds * 1000); diff --git a/public/js/services/saveio.js b/public/js/services/saveio.js index e55a85a..4985904 100644 --- a/public/js/services/saveio.js +++ b/public/js/services/saveio.js @@ -1,5 +1,6 @@ -// Export / import of a save as a JSON or TXT file (or pasted text). +// Export / import of the 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. +// The save is theme-independent: any export can be imported under any theme. import { sanitizeState, serializeState } from '../engine/state.js'; @@ -7,7 +8,7 @@ const GAME_ID = 'idle-clicker'; const FORMAT = 1; export class ImportError extends Error { - /** @param {'empty'|'invalid'|'unknown_theme'|'wrong_game'} code */ + /** @param {'empty'|'invalid'|'wrong_game'} code */ constructor(code) { super(code); this.code = code; @@ -15,12 +16,11 @@ export class ImportError extends Error { } /** The text a player downloads, copies or pastes back. */ -export function buildExport(themeId, state, now = Date.now()) { +export function buildExport(state, now = Date.now()) { return JSON.stringify( { game: GAME_ID, format: FORMAT, - theme: themeId, exportedAt: new Date(now).toISOString(), state: JSON.parse(serializeState(state)), }, @@ -29,8 +29,8 @@ export function buildExport(themeId, state, now = Date.now()) { ); } -/** Validate imported text. Throws ImportError; never trusts the content. */ -export function parseImport(text, ctx, knownThemeIds) { +/** Validate imported text and return a clean state. Throws ImportError; never trusts the content. */ +export function parseImport(text, ctx) { const trimmed = String(text ?? '').trim().replace(/^/, ''); if (!trimmed) throw new ImportError('empty'); @@ -41,11 +41,10 @@ export function parseImport(text, ctx, knownThemeIds) { 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 }; + return state; } export function downloadText(filename, text, mime = 'application/json') { diff --git a/public/js/services/storage.js b/public/js/services/storage.js index 3dadbca..5aff89e 100644 --- a/public/js/services/storage.js +++ b/public/js/services/storage.js @@ -1,17 +1,18 @@ // 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. +// ic_game_n -> number of chunks +// ic_game_0.. -> the chunks +// There is ONE game, shared by every theme (a theme is only a skin). +// 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}`; +const GAME_KEY = 'ic_game'; +const legacyKey = (themeId) => `ic_save_${themeId}`; // v1: one save per theme // ------------------------------------------------------------------ encoding @@ -79,16 +80,30 @@ export function cookiesAvailable() { } /** Throws if the save does not fit in cookies. */ -export function saveGameText(themeId, text) { - writeChunked(saveKey(themeId), text); +export function saveGameText(text) { + writeChunked(GAME_KEY, text); } -export function loadGameText(themeId) { - return readChunked(saveKey(themeId)); +export function loadGameText() { + return readChunked(GAME_KEY); } -export function deleteGame(themeId) { - removeChunked(saveKey(themeId)); +export function deleteGame() { + removeChunked(GAME_KEY); +} + +/** + * Saves from the first version (one per theme). Returns their texts and + * removes those cookies, so the caller can merge them into the shared game. + */ +export function takeLegacySaves(themeIds) { + const texts = []; + for (const id of themeIds) { + const text = readChunked(legacyKey(id)); + if (text) texts.push(text); + removeChunked(legacyKey(id)); + } + return texts; } export function savePrefs(prefs) { diff --git a/public/js/ui/view.js b/public/js/ui/view.js index e26a48e..00fa72a 100644 --- a/public/js/ui/view.js +++ b/public/js/ui/view.js @@ -6,6 +6,7 @@ import { t } from '../services/i18n.js'; import { $, h, icon, refreshStaticIcons } from './dom.js'; const SLIDER_STEPS = 1000; +const TREND = { '-1': '▼', 1: '▲' }; // last move of the material price function setText(node, text) { if (node.textContent !== text) node.textContent = text; @@ -218,7 +219,11 @@ export function createView(actions) { el.make.disabled = state.material < d.materialPerUnit; setText( el.buyMaterialInfo, - t('ui.batchInfo', { amount: fmt.number(d.batchSize), cost: money(d.batchCost) }), + t('ui.batchInfo', { + amount: fmt.number(d.batchSize), + cost: money(d.batchCost), + trend: TREND[state.materialTrend] ?? '', + }), ); el.buyMaterial.disabled = state.funds < d.batchCost; diff --git a/public/locales/en.json b/public/locales/en.json index 8fadd3b..3e1431d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -23,7 +23,7 @@ "noUpgrades": "Keep producing to unlock upgrades.", "perSecond": "{value}/s", "makeYield": "+{amount} per click", - "batchInfo": "+{amount} {@theme.res.material} · {cost}", + "batchInfo": "+{amount} {@theme.res.material} · {trend} {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.", @@ -55,13 +55,13 @@ "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.", + "importHelp": "Choose a .json or .txt file, or paste the text below. It replaces your current progress, whatever the theme.", "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." + "reset": "Reset all progress", + "resetConfirm": "Erase all progress? This cannot be undone." }, "toast": { "cookiesBlocked": "Cookies are blocked, so progress cannot be saved in this browser. Use Save & settings to export it.", @@ -76,8 +76,7 @@ "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." + "wrong_game": "This file is not an Idle Clicker save." } } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 705372e..ebd30ec 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -23,7 +23,7 @@ "noUpgrades": "Continuez à produire pour débloquer des améliorations.", "perSecond": "{value}/s", "makeYield": "+{amount} par clic", - "batchInfo": "+{amount} {@theme.res.material} · {cost}", + "batchInfo": "+{amount} {@theme.res.material} · {trend} {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.", @@ -55,13 +55,13 @@ "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é.", + "importHelp": "Choisissez un fichier .json ou .txt, ou collez le texte ci-dessous. Cela remplace votre progression actuelle, quel que soit le thème.", "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." + "reset": "Réinitialiser toute la progression", + "resetConfirm": "Effacer toute la progression ? 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.", @@ -76,8 +76,7 @@ "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é." + "wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker." } } } diff --git a/public/themes/insects/locales/en.json b/public/themes/insects/locales/en.json new file mode 100644 index 0000000..79fa48f --- /dev/null +++ b/public/themes/insects/locales/en.json @@ -0,0 +1,50 @@ +{ + "theme": { + "name": "Leaf-Piling Insects", + "title": "Leaf Pile Colony", + "res": { "units": "Leaves", "unit": "leaf", "funds": "Honeydew", "material": "Silk" }, + "fmt": { "funds": "{value} drops" }, + "action": { "make": "Cut a leaf", "buyMaterial": "Trade for silk" }, + "gen": { + "gen1": { "name": "Worker Ant", "desc": "Carries a leaf many times its own size." }, + "gen2": { "name": "Leafcutter Squad", "desc": "Slices, hauls and stacks in perfect formation." }, + "gen3": { "name": "Beetle Hauler", "desc": "Armoured carrier that shifts whole piles." }, + "gen4": { "name": "Termite Mound", "desc": "A towering colony that never stops building." }, + "gen5": { "name": "Locust Swarm", "desc": "Strips whole trees in an afternoon." }, + "gen6": { "name": "Hive Queen", "desc": "Her pheromones coordinate millions of workers." } + }, + "upg": { + "click_1": "Sharper Mandibles", + "click_2": "Pheromone Trail", + "click_3": "Double-Grip Legs", + "g1_a": "Ant Drill Sergeant", + "g1_b": "Marching Songs", + "g2_a": "Wider Leaf Slices", + "g2_b": "Leafcutter Guild", + "g3_a": "Reinforced Shells", + "g3_b": "Dung Beetle Logistics", + "g4_a": "Air-Conditioned Chambers", + "g4_b": "Deep Tunnel Network", + "g5_a": "Riding the Wind", + "g5_b": "Migration Instinct", + "g6_a": "Royal Jelly", + "g6_b": "Queen's Decree", + "global_1": "Dewdrop Hydration", + "global_2": "Seasonal Rhythm", + "global_3": "Hive Mind", + "global_4": "Perfect Spring", + "demand_1": "Scent Markers", + "demand_2": "Cricket Courier", + "demand_3": "Forest Market", + "demand_4": "Famous Pile", + "value_1": "Dewy Gloss", + "value_2": "Autumn Colours", + "value_3": "Golden Leaf Grade", + "mat_eff_1": "Tighter Weaving", + "mat_eff_2": "Silk Recycling", + "mat_disc_1": "Spider Contract", + "mat_disc_2": "Silkworm Farm", + "auto_buy": "Spider Delivery Service" + } + } +} diff --git a/public/themes/insects/locales/fr.json b/public/themes/insects/locales/fr.json new file mode 100644 index 0000000..b014092 --- /dev/null +++ b/public/themes/insects/locales/fr.json @@ -0,0 +1,50 @@ +{ + "theme": { + "name": "Insectes et tas de feuilles", + "title": "Colonie des tas de feuilles", + "res": { "units": "Feuilles", "unit": "feuille", "funds": "Miellat", "material": "Soie" }, + "fmt": { "funds": "{value} gouttes" }, + "action": { "make": "Couper une feuille", "buyMaterial": "Échanger contre de la soie" }, + "gen": { + "gen1": { "name": "Fourmi ouvrière", "desc": "Porte une feuille bien plus grosse qu'elle." }, + "gen2": { "name": "Escouade coupeuse", "desc": "Coupe, transporte et empile en formation parfaite." }, + "gen3": { "name": "Scarabée porteur", "desc": "Porteur blindé qui déplace des tas entiers." }, + "gen4": { "name": "Termitière", "desc": "Une colonie immense qui ne cesse de bâtir." }, + "gen5": { "name": "Nuée de criquets", "desc": "Dépouille des arbres entiers en un après-midi." }, + "gen6": { "name": "Reine de la ruche", "desc": "Ses phéromones coordonnent des millions d'ouvrières." } + }, + "upg": { + "click_1": "Mandibules aiguisées", + "click_2": "Piste de phéromones", + "click_3": "Pattes à double prise", + "g1_a": "Fourmi sergent-instructeur", + "g1_b": "Chants de marche", + "g2_a": "Tranches de feuille plus larges", + "g2_b": "Guilde des coupeuses", + "g3_a": "Carapaces renforcées", + "g3_b": "Logistique des bousiers", + "g4_a": "Chambres climatisées", + "g4_b": "Réseau de galeries profondes", + "g5_a": "Chevaucher le vent", + "g5_b": "Instinct de migration", + "g6_a": "Gelée royale", + "g6_b": "Décret de la reine", + "global_1": "Hydratation à la rosée", + "global_2": "Rythme des saisons", + "global_3": "Esprit de ruche", + "global_4": "Printemps parfait", + "demand_1": "Marqueurs odorants", + "demand_2": "Courrier-criquet", + "demand_3": "Marché de la forêt", + "demand_4": "Tas célèbre", + "value_1": "Lustre de rosée", + "value_2": "Couleurs d'automne", + "value_3": "Qualité feuille d'or", + "mat_eff_1": "Tissage plus serré", + "mat_eff_2": "Recyclage de la soie", + "mat_disc_1": "Contrat avec l'araignée", + "mat_disc_2": "Ferme de vers à soie", + "auto_buy": "Livraison par araignée" + } + } +} diff --git a/public/themes/insects/theme.css b/public/themes/insects/theme.css new file mode 100644 index 0000000..6a91385 --- /dev/null +++ b/public/themes/insects/theme.css @@ -0,0 +1,15 @@ +/* Insects piling up leaves: a sunlit forest floor. */ +:root[data-theme='insects'] { + color-scheme: light; + --bg: #e9efd8; + --surface: #f9fbf1; + --surface-2: #e2ebcb; + --text: #2a3321; + --text-muted: #5d6b49; + --border: #c4d3a2; + --accent: #2f7d32; + --accent-contrast: #ffffff; + --danger: #b3412a; + --radius: 12px; + --font: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif; +} diff --git a/public/themes/insects/theme.json b/public/themes/insects/theme.json new file mode 100644 index 0000000..5f8b0c3 --- /dev/null +++ b/public/themes/insects/theme.json @@ -0,0 +1,4 @@ +{ + "order": 4, + "assets": {} +} diff --git a/tests/engine.test.js b/tests/engine.test.js index 857e2d8..e153af1 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -138,6 +138,59 @@ test('auto-buy restocks material when funds allow', () => { assert.ok(s.material > 50); }); +test('material is costly at first and cheaper relative to the sale price as production grows', () => { + const { fairPrice } = ctx.rules.market; + const costShare = (totalProduced) => { + const s = fresh(); + s.totalProduced = totalProduced; + const d = derive(s, ctx); + return d.batchCost / d.batchSize / fairPrice; + }; + assert.ok(costShare(0) > 0.25, 'expensive at the start'); + assert.ok(costShare(1e4) < costShare(0)); + assert.ok(costShare(1e6) < costShare(1e4)); + assert.ok(costShare(1e9) < 0.1, 'a small share of revenue late in the game'); +}); + +test('material lots grow with the operation, in doublings', () => { + const sizeAt = (total) => { + const s = fresh(); + s.totalProduced = total; + return derive(s, ctx).batchSize; + }; + assert.equal(sizeAt(0), ctx.rules.materialMarket.minBatch); + assert.ok(sizeAt(1e6) > sizeAt(1e4)); + assert.equal(Math.log2(sizeAt(1e6) / ctx.rules.materialMarket.minBatch) % 1, 0); +}); + +test('the material market is erratic but stays within its bounds', () => { + const m = ctx.rules.materialMarket; + const s = fresh(); + let seed = 7; + const rng = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const seen = []; + for (let i = 0; i < 2000; i++) { + advance(s, ctx, m.updateEverySeconds, rng); + seen.push(s.materialFactor); + } + assert.ok(Math.min(...seen) >= m.minFactor && Math.max(...seen) <= m.maxFactor); + assert.ok(Math.max(...seen) > 1.6 && Math.min(...seen) < 0.6, 'swings widely'); + assert.ok(new Set(seen.map((f) => f.toFixed(2))).size > 100, 'does not sit still'); +}); + +test('auto-buy waits for a fair price unless stock is nearly gone', () => { + const s = fresh(); + s.upgrades = ['auto_buy']; + s.funds = 1e6; + s.materialFactor = 2.5; // expensive + s.material = 40; // low but not desperate (< lot/2, > lot/10) + advance(s, ctx, 0.1, () => 0.5); + assert.equal(s.material, 40, 'did not buy at a high price'); + s.material = 5; // desperate + advance(s, ctx, 0.1, () => 0.5); + assert.ok(s.material > 50, 'buys when nearly out'); +}); + test('offline progress is capped, discounted, and ignored when short', () => { const s = fresh(); s.generators.gen1 = 5; diff --git a/tests/saveio.test.js b/tests/saveio.test.js index 4266f17..142d889 100644 --- a/tests/saveio.test.js +++ b/tests/saveio.test.js @@ -6,27 +6,33 @@ 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', () => { +test('export then import restores the save', () => { 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); + const imported = parseImport(buildExport(state), ctx); + assert.equal(imported.funds, 42); + assert.equal(imported.generators.gen1, 7); +}); + +test('the export does not depend on the theme', () => { + assert.equal('theme' in JSON.parse(buildExport(createState(ctx))), false); +}); + +test('first-version exports (with a theme field) still import', () => { + const legacy = { ...JSON.parse(buildExport(createState(ctx))), theme: 'witch' }; + assert.equal(parseImport(JSON.stringify(legacy), ctx).units, 0); }); 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 text = ` \n${buildExport(createState(ctx))}\n\n`; + assert.ok(parseImport(text, ctx)); }); const failure = (text) => { try { - parseImport(text, ctx, themes); + parseImport(text, ctx); } catch (error) { assert.ok(error instanceof ImportError); return error.code; @@ -38,10 +44,7 @@ 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))); + const broken = JSON.parse(buildExport(createState(ctx))); broken.state = 'x'; assert.equal(failure(JSON.stringify(broken)), 'invalid'); }); diff --git a/tests/server.test.js b/tests/server.test.js index 9e882e6..546186a 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -19,7 +19,7 @@ test('resolveSafe stays inside the public directory', () => { 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')); + for (const id of ['paperclip', 'witch', 'alien', 'insects']) assert.ok(ids.includes(id), id); assert.deepEqual(themes.find((t) => t.id === 'paperclip').locales, ['en', 'fr']); });