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
+20 -8
View File
@@ -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.
+24
View File
@@ -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
+2 -1
View File
@@ -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,
);
+40 -20
View File
@@ -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);
+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.
// 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') {
+26 -11
View File
@@ -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_<theme>_n -> number of chunks
// ic_save_<theme>_0.. -> the chunks
// Each theme has its own save slot. Preferences (theme, language) are stored
// in a single small cookie.
// 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) {
+6 -1
View File
@@ -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;