feat: New theme + wire price adjustement

This commit is contained in:
gauvainboiche
2026-10-08 12:25:45 +02:00
parent aa9721ac2a
commit 1734487f1e
19 changed files with 348 additions and 88 deletions
+8 -5
View File
@@ -1,6 +1,6 @@
# Idle Clicker # Idle Clicker
A themeable idle game inspired by *Universal Paperclips*. Make units by hand, sell them, automate production, buy upgrades. Runs in any browser (phone, tablet, desktop); progress is saved in cookies and can be exported/imported as a JSON or TXT file. 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). 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.<id>`, `upg.<id>`. Anything not listed ke
## Saves ## Saves
- Each theme has its own save slot, stored in cookies (`ic_save_<theme>_*`, chunked and base64-encoded because a cookie holds ~4 KB), plus one preferences cookie (theme + language). - 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 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. - **Save & settings** exports the save as `.json` or `.txt` (download or copy/paste) and imports either (file picker or pasted text). The file does not depend on the theme. Imports are validated and sanitised: unknown ids are dropped, numbers clamped.
- Progress continues while the game is closed: up to 8 h, at 50 % efficiency (`public/data/rules.json`). - Progress continues while the game is closed: up to 8 h, at 50 % efficiency (`public/data/rules.json`).
- Cookies are sent with every request to the server. A save is under 2 KB 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 ## 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.
+1
View File
@@ -59,6 +59,7 @@
@media (min-width: 1200px) { @media (min-width: 1200px) {
.game { .game {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr); 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: grid-template-areas:
'stats stats stats' 'stats stats stats'
'production generators upgrades' 'production generators upgrades'
+14 -7
View File
@@ -11,13 +11,20 @@
"priceStep": 0.01 "priceStep": 0.01
}, },
"materialMarket": { "materialMarket": {
"batchSize": 100, "minBatch": 100,
"basePrice": 2, "batchShareOfTotal": 0.1,
"minFactor": 0.5, "batchSecondsOfProduction": 60,
"maxFactor": 2, "startUnitPrice": 0.08,
"volatility": 0.12, "floorUnitPrice": 0.01,
"reversion": 0.05, "scale": 3000,
"updateEverySeconds": 2 "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 }, "marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
"offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 }, "offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 },
+20 -8
View File
@@ -3,7 +3,7 @@
// browser and in Node (tests, balance simulations). // browser and in Node (tests, balance simulations).
import { computeModifiers } from './effects.js'; 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; const MAX_STEP_SECONDS = 1;
@@ -38,6 +38,13 @@ export function derive(state, ctx) {
} }
const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing; const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing;
const batch = materialBatch(
rules.materialMarket,
state.totalProduced,
rate,
state.materialFactor,
mods.batchDiscount,
);
return { return {
clickYield: rules.click.yield * mods.click, clickYield: rules.click.yield * mods.click,
perGenerator, perGenerator,
@@ -46,8 +53,8 @@ export function derive(state, ctx) {
demand: demandPerSecond(rules.market, state.price, demandMultiplier), demand: demandPerSecond(rules.market, state.price, demandMultiplier),
valueMultiplier: mods.value, valueMultiplier: mods.value,
materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency, materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency,
batchSize: rules.materialMarket.batchSize, batchSize: batch.size,
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount), batchCost: batch.cost,
autoBuyMaterial: mods.autoBuyMaterial, autoBuyMaterial: mods.autoBuyMaterial,
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing), marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
}; };
@@ -121,9 +128,12 @@ function stepMaterialMarket(state, ctx, dt, rng) {
state.materialTimer += dt; state.materialTimer += dt;
while (state.materialTimer >= m.updateEverySeconds) { while (state.materialTimer >= m.updateEverySeconds) {
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; 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.units += made;
state.totalProduced += made; state.totalProduced += made;
// Automatic restock when the stock runs low. // Automatic restock: patient while the price is high, urgent when nearly out.
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) { if (d.autoBuyMaterial && state.funds >= d.batchCost) {
buyMaterial(state, ctx); 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. // Customers buy what they want, up to what is in stock.
+24
View File
@@ -25,6 +25,30 @@ export function maxAffordable(base, growth, owned, budget) {
return Math.max(n, 0); 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`. * Units customers want to buy per second at `price`.
* Demand is maxed at or below the fair price and drops steeply above it, so * Demand is maxed at or below the fair price and drops steeply above it, so
+2 -1
View File
@@ -22,6 +22,7 @@ export function createState(ctx, now = Date.now()) {
upgrades: [], upgrades: [],
savedAt: now, savedAt: now,
materialTimer: 0, // transient, never saved 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. */ /** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) { export function serializeState(state) {
const { materialTimer, ...persisted } = state; const { materialTimer, materialTrend, ...persisted } = state;
return JSON.stringify(persisted, (_key, value) => return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value, typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
); );
+40 -20
View File
@@ -16,7 +16,15 @@ import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
import { createFormatter } from './services/format.js'; import { createFormatter } from './services/format.js';
import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js'; import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js';
import { buildExport, parseImport } from './services/saveio.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 { applyThemeStyles } from './services/themes.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js'; import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createSettings } from './ui/settings.js'; import { createSettings } from './ui/settings.js';
@@ -44,7 +52,7 @@ function persist() {
if (!state) return; if (!state) return;
state.savedAt = Date.now(); state.savedAt = Date.now();
try { try {
saveGameText(themeId, serializeState(state)); saveGameText(serializeState(state));
} catch { } catch {
if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) { if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) {
lastSaveError = Date.now(); lastSaveError = Date.now();
@@ -53,9 +61,7 @@ function persist() {
} }
} }
function loadSavedState() { function parseSaved(text) {
const text = loadGameText(themeId);
if (!text) return null;
try { try {
return sanitizeState(JSON.parse(text), ctx); return sanitizeState(JSON.parse(text), ctx);
} catch { } 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() { function persistPrefs() {
savePrefs({ theme: themeId, lang: locale }); savePrefs({ theme: themeId, lang: locale });
} }
@@ -107,16 +126,13 @@ async function refreshTexts() {
await fillSelects(); await fillSelects();
} }
async function enterTheme(id) { /** Change the skin only: the game state is shared by every theme. */
persist(); // keep the theme we are leaving async function setTheme(id) {
themeId = id; themeId = id;
await applyThemeStyles(id); await applyThemeStyles(id);
await refreshTexts(); await refreshTexts();
state = loadSavedState() ?? createState(ctx);
income = 0;
catchUp((Date.now() - state.savedAt) / 1000);
persistPrefs(); persistPrefs();
render(); if (state) render();
} }
// ------------------------------------------------------------------ the loop // ------------------------------------------------------------------ the loop
@@ -181,18 +197,17 @@ const view = createView({
createSettings({ createSettings({
getExport: () => ({ getExport: () => ({
text: buildExport(themeId, state), text: buildExport(state),
name: `idle-clicker-${themeId}-${new Date().toISOString().slice(0, 10)}`, name: `idle-clicker-${new Date().toISOString().slice(0, 10)}`,
}), }),
async importText(text) { importText(text) {
const imported = parseImport(text, ctx, themes.map((th) => th.id)); // throws before any change state = parseImport(text, ctx); // throws before anything changes
if (imported.themeId !== themeId) await enterTheme(imported.themeId); income = 0;
state = imported.state;
persist(); persist();
render(); render();
}, },
reset() { reset() {
deleteGame(themeId); deleteGame();
state = createState(ctx); state = createState(ctx);
income = 0; income = 0;
persist(); persist();
@@ -216,7 +231,7 @@ async function boot() {
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0]; const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
$('#cookie-warning').hidden = cookiesAvailable(); $('#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-select').addEventListener('change', async (e) => {
locale = e.target.value; locale = e.target.value;
persistPrefs(); persistPrefs();
@@ -224,9 +239,14 @@ async function boot() {
render(); render();
}); });
await enterTheme(initialTheme.id); await setTheme(initialTheme.id);
refreshStaticIcons(); refreshStaticIcons();
state = loadSavedState() ?? createState(ctx);
catchUp((Date.now() - state.savedAt) / 1000);
persist();
render();
lastFrame = Date.now(); lastFrame = Date.now();
setInterval(frame, TICK_MS); setInterval(frame, TICK_MS);
setInterval(persist, ctx.rules.autosaveSeconds * 1000); setInterval(persist, ctx.rules.autosaveSeconds * 1000);
+7 -8
View File
@@ -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. // 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'; import { sanitizeState, serializeState } from '../engine/state.js';
@@ -7,7 +8,7 @@ const GAME_ID = 'idle-clicker';
const FORMAT = 1; const FORMAT = 1;
export class ImportError extends Error { export class ImportError extends Error {
/** @param {'empty'|'invalid'|'unknown_theme'|'wrong_game'} code */ /** @param {'empty'|'invalid'|'wrong_game'} code */
constructor(code) { constructor(code) {
super(code); super(code);
this.code = code; this.code = code;
@@ -15,12 +16,11 @@ export class ImportError extends Error {
} }
/** The text a player downloads, copies or pastes back. */ /** 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( return JSON.stringify(
{ {
game: GAME_ID, game: GAME_ID,
format: FORMAT, format: FORMAT,
theme: themeId,
exportedAt: new Date(now).toISOString(), exportedAt: new Date(now).toISOString(),
state: JSON.parse(serializeState(state)), 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. */ /** Validate imported text and return a clean state. Throws ImportError; never trusts the content. */
export function parseImport(text, ctx, knownThemeIds) { export function parseImport(text, ctx) {
const trimmed = String(text ?? '').trim().replace(/^/, ''); const trimmed = String(text ?? '').trim().replace(/^/, '');
if (!trimmed) throw new ImportError('empty'); if (!trimmed) throw new ImportError('empty');
@@ -41,11 +41,10 @@ export function parseImport(text, ctx, knownThemeIds) {
throw new ImportError('invalid'); throw new ImportError('invalid');
} }
if (data?.game !== GAME_ID || data.format !== FORMAT) throw new ImportError('wrong_game'); 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); const state = sanitizeState(data.state, ctx);
if (!state) throw new ImportError('invalid'); if (!state) throw new ImportError('invalid');
return { themeId: data.theme, state }; return state;
} }
export function downloadText(filename, text, mime = 'application/json') { export function downloadText(filename, text, mime = 'application/json') {
+26 -11
View File
@@ -1,17 +1,18 @@
// Persistence in browser cookies. // Persistence in browser cookies.
// //
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks: // A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
// ic_save_<theme>_n -> number of chunks // ic_game_n -> number of chunks
// ic_save_<theme>_0.. -> the chunks // ic_game_0.. -> the chunks
// Each theme has its own save slot. Preferences (theme, language) are stored // There is ONE game, shared by every theme (a theme is only a skin).
// in a single small cookie. // Preferences (theme, language) are stored in a single small cookie.
import { readCookie, removeCookie, writeCookie } from './cookies.js'; import { readCookie, removeCookie, writeCookie } from './cookies.js';
const CHUNK_SIZE = 3000; const CHUNK_SIZE = 3000;
const MAX_CHUNKS = 6; const MAX_CHUNKS = 6;
const PREFS_KEY = 'ic_prefs'; 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 // ------------------------------------------------------------------ encoding
@@ -79,16 +80,30 @@ export function cookiesAvailable() {
} }
/** Throws if the save does not fit in cookies. */ /** Throws if the save does not fit in cookies. */
export function saveGameText(themeId, text) { export function saveGameText(text) {
writeChunked(saveKey(themeId), text); writeChunked(GAME_KEY, text);
} }
export function loadGameText(themeId) { export function loadGameText() {
return readChunked(saveKey(themeId)); return readChunked(GAME_KEY);
} }
export function deleteGame(themeId) { export function deleteGame() {
removeChunked(saveKey(themeId)); 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) { export function savePrefs(prefs) {
+6 -1
View File
@@ -6,6 +6,7 @@ import { t } from '../services/i18n.js';
import { $, h, icon, refreshStaticIcons } from './dom.js'; import { $, h, icon, refreshStaticIcons } from './dom.js';
const SLIDER_STEPS = 1000; const SLIDER_STEPS = 1000;
const TREND = { '-1': '▼', 1: '▲' }; // last move of the material price
function setText(node, text) { function setText(node, text) {
if (node.textContent !== text) node.textContent = text; if (node.textContent !== text) node.textContent = text;
@@ -218,7 +219,11 @@ export function createView(actions) {
el.make.disabled = state.material < d.materialPerUnit; el.make.disabled = state.material < d.materialPerUnit;
setText( setText(
el.buyMaterialInfo, 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; el.buyMaterial.disabled = state.funds < d.batchCost;
+5 -6
View File
@@ -23,7 +23,7 @@
"noUpgrades": "Keep producing to unlock upgrades.", "noUpgrades": "Keep producing to unlock upgrades.",
"perSecond": "{value}/s", "perSecond": "{value}/s",
"makeYield": "+{amount} per click", "makeYield": "+{amount} per click",
"batchInfo": "+{amount} {@theme.res.material} · {cost}", "batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
"marketingInfo": "Level {level} → demand ×{multiplier} · {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.", "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.", "hintHigh": "Customers want {demand}/s at this price. Above the fair price ({fair}) demand drops fast.",
@@ -55,13 +55,13 @@
"downloadTxt": "Download .txt", "downloadTxt": "Download .txt",
"copy": "Copy text", "copy": "Copy text",
"import": "Import a save", "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…", "importFile": "Choose a file…",
"pastePlaceholder": "Paste the exported text here", "pastePlaceholder": "Paste the exported text here",
"importText": "Import pasted text", "importText": "Import pasted text",
"danger": "Danger zone", "danger": "Danger zone",
"reset": "Reset this theme's progress", "reset": "Reset all progress",
"resetConfirm": "Erase all progress for this theme? This cannot be undone." "resetConfirm": "Erase all progress? This cannot be undone."
}, },
"toast": { "toast": {
"cookiesBlocked": "Cookies are blocked, so progress cannot be saved in this browser. Use Save & settings to export it.", "cookiesBlocked": "Cookies are blocked, so progress cannot be saved in this browser. Use Save & settings to export it.",
@@ -76,8 +76,7 @@
"import": { "import": {
"empty": "Nothing to import.", "empty": "Nothing to import.",
"invalid": "This is not a valid save.", "invalid": "This is not a valid save.",
"wrong_game": "This file is not an Idle Clicker save.", "wrong_game": "This file is not an Idle Clicker save."
"unknown_theme": "This save belongs to a theme that is not installed."
} }
} }
} }
+5 -6
View File
@@ -23,7 +23,7 @@
"noUpgrades": "Continuez à produire pour débloquer des améliorations.", "noUpgrades": "Continuez à produire pour débloquer des améliorations.",
"perSecond": "{value}/s", "perSecond": "{value}/s",
"makeYield": "+{amount} par clic", "makeYield": "+{amount} par clic",
"batchInfo": "+{amount} {@theme.res.material} · {cost}", "batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
"marketingInfo": "Niveau {level} → demande ×{multiplier} · {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.", "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.", "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", "downloadTxt": "Télécharger en .txt",
"copy": "Copier le texte", "copy": "Copier le texte",
"import": "Importer une sauvegarde", "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…", "importFile": "Choisir un fichier…",
"pastePlaceholder": "Collez ici le texte exporté", "pastePlaceholder": "Collez ici le texte exporté",
"importText": "Importer le texte collé", "importText": "Importer le texte collé",
"danger": "Zone dangereuse", "danger": "Zone dangereuse",
"reset": "Réinitialiser la progression de ce thème", "reset": "Réinitialiser toute la progression",
"resetConfirm": "Effacer toute la progression de ce thème ? Cette action est irréversible." "resetConfirm": "Effacer toute la progression ? Cette action est irréversible."
}, },
"toast": { "toast": {
"cookiesBlocked": "Les cookies sont bloqués : la progression ne peut pas être sauvegardée dans ce navigateur. Exportez-la depuis Sauvegarde et options.", "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": { "import": {
"empty": "Rien à importer.", "empty": "Rien à importer.",
"invalid": "Ceci n'est pas une sauvegarde valide.", "invalid": "Ceci n'est pas une sauvegarde valide.",
"wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker.", "wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker."
"unknown_theme": "Cette sauvegarde appartient à un thème qui n'est pas installé."
} }
} }
} }
+50
View File
@@ -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"
}
}
}
+50
View File
@@ -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"
}
}
}
+15
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
{
"order": 4,
"assets": {}
}
+53
View File
@@ -138,6 +138,59 @@ test('auto-buy restocks material when funds allow', () => {
assert.ok(s.material > 50); 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', () => { test('offline progress is capped, discounted, and ignored when short', () => {
const s = fresh(); const s = fresh();
s.generators.gen1 = 5; s.generators.gen1 = 5;
+17 -14
View File
@@ -6,27 +6,33 @@ import { decode, encode } from '../public/js/services/storage.js';
import { loadContext } from './helpers.js'; import { loadContext } from './helpers.js';
const ctx = loadContext(); 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); const state = createState(ctx);
state.funds = 42; state.funds = 42;
state.generators.gen1 = 7; state.generators.gen1 = 7;
const text = buildExport('witch', state); const imported = parseImport(buildExport(state), ctx);
const imported = parseImport(text, ctx, themes); assert.equal(imported.funds, 42);
assert.equal(imported.themeId, 'witch'); assert.equal(imported.generators.gen1, 7);
assert.equal(imported.state.funds, 42); });
assert.equal(imported.state.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)', () => { test('import also accepts text with a BOM and surrounding whitespace (.txt files)', () => {
const text = ` \n${buildExport('paperclip', createState(ctx))}\n\n`; const text = ` \n${buildExport(createState(ctx))}\n\n`;
assert.equal(parseImport(text, ctx, themes).themeId, 'paperclip'); assert.ok(parseImport(text, ctx));
}); });
const failure = (text) => { const failure = (text) => {
try { try {
parseImport(text, ctx, themes); parseImport(text, ctx);
} catch (error) { } catch (error) {
assert.ok(error instanceof ImportError); assert.ok(error instanceof ImportError);
return error.code; return error.code;
@@ -38,10 +44,7 @@ test('import rejects bad input with a specific code', () => {
assert.equal(failure(''), 'empty'); assert.equal(failure(''), 'empty');
assert.equal(failure('not json'), 'invalid'); assert.equal(failure('not json'), 'invalid');
assert.equal(failure('{"hello":1}'), 'wrong_game'); assert.equal(failure('{"hello":1}'), 'wrong_game');
const unknown = JSON.parse(buildExport('paperclip', createState(ctx))); const broken = JSON.parse(buildExport(createState(ctx)));
unknown.theme = 'dragons';
assert.equal(failure(JSON.stringify(unknown)), 'unknown_theme');
const broken = JSON.parse(buildExport('paperclip', createState(ctx)));
broken.state = 'x'; broken.state = 'x';
assert.equal(failure(JSON.stringify(broken)), 'invalid'); assert.equal(failure(JSON.stringify(broken)), 'invalid');
}); });
+1 -1
View File
@@ -19,7 +19,7 @@ test('resolveSafe stays inside the public directory', () => {
test('/api/themes lists installed themes with their languages', async () => { test('/api/themes lists installed themes with their languages', async () => {
const themes = await handleApi(PUBLIC_DIR, '/api/themes'); const themes = await handleApi(PUBLIC_DIR, '/api/themes');
const ids = themes.map((t) => t.id); 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']); assert.deepEqual(themes.find((t) => t.id === 'paperclip').locales, ['en', 'fr']);
}); });