From cd6d38899c7e4d94b92640f3e73a62516e9fd635 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Thu, 8 Oct 2026 13:21:32 +0200 Subject: [PATCH] fix: Changing prices and dynamics in revenus --- README.md | 2 +- public/data/rules.json | 3 +- public/data/upgrades.json | 5 +++ public/js/engine/effects.js | 2 + public/js/engine/engine.js | 1 + public/js/engine/formulas.js | 18 +++++---- public/locales/en.json | 3 +- public/locales/fr.json | 3 +- public/themes/alien/locales/en.json | 5 +++ public/themes/alien/locales/fr.json | 5 +++ public/themes/insects/locales/en.json | 5 +++ public/themes/insects/locales/fr.json | 5 +++ public/themes/paperclip/locales/en.json | 5 +++ public/themes/paperclip/locales/fr.json | 5 +++ public/themes/witch/locales/en.json | 5 +++ public/themes/witch/locales/fr.json | 5 +++ tests/engine.test.js | 53 ++++++++++++++++++++++--- 17 files changed, 112 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ba5c68c..e4063fa 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,4 @@ Keys: `res.units|funds|material`, `gen.`, `upg.`. Anything not listed ke Everything numeric is in `public/data/`: `rules.json` (market, raw material, marketing, offline, autosave), `generators.json`, `upgrades.json`. - **Selling:** demand is a smooth curve, `baseDemand × demandMultiplier × (referencePrice / price) ^ elasticity` units/s: lower the price and more people buy, with no ceiling. The best price is where demand just matches your output (selling for less gains no volume, selling for more leaves stock unsold). That point moves with every generator, upgrade and marketing level, and the game does not display it: the market hint only says whether you are selling too cheap, too dear, or about right. -- **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: the price is re-drawn every 10 s (`updateEverySeconds`) with wide random swings plus frequent shortages and gluts (`spikeChance`, `spikeSize`), and is shown to the cent, with ▲/▼ for its last move. The auto-buy upgrade is patient: it waits for a fair price unless stock is nearly gone. +- **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). A lot covers about 10 s of current output (in doublings), multiplied by the "stack" upgrades (`batch_size_mult`, a chain of five, ×180 in total). A lot costs in proportion to its size, so bigger stacks cost more per purchase while the price per unit still falls with scale. The market is erratic: the price is re-drawn every 10 s (`updateEverySeconds`) with wide random swings plus frequent shortages and gluts (`spikeChance`, `spikeSize`), and is shown to the cent, with ▲/▼ for its last move. The auto-buy upgrade is patient: it waits for a fair price unless stock is nearly gone. diff --git a/public/data/rules.json b/public/data/rules.json index 8dbdbc5..4ed49c0 100644 --- a/public/data/rules.json +++ b/public/data/rules.json @@ -12,8 +12,7 @@ }, "materialMarket": { "minBatch": 100, - "batchShareOfTotal": 0.1, - "batchSecondsOfProduction": 60, + "batchSecondsOfProduction": 10, "startUnitPrice": 0.08, "floorUnitPrice": 0.01, "scale": 3000, diff --git a/public/data/upgrades.json b/public/data/upgrades.json index ced3165..8dc5508 100644 --- a/public/data/upgrades.json +++ b/public/data/upgrades.json @@ -34,5 +34,10 @@ { "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": "stack_1", "cost": 120, "unlock": { "totalProduced": 400 }, "effect": { "type": "batch_size_mult", "value": 2 } }, + { "id": "stack_2", "cost": 2500, "unlock": { "totalProduced": 4000 }, "requires": "stack_1", "effect": { "type": "batch_size_mult", "value": 2 } }, + { "id": "stack_3", "cost": 40000, "unlock": { "totalProduced": 40000 }, "requires": "stack_2", "effect": { "type": "batch_size_mult", "value": 3 } }, + { "id": "stack_4", "cost": 600000, "unlock": { "totalProduced": 500000 }, "requires": "stack_3", "effect": { "type": "batch_size_mult", "value": 3 } }, + { "id": "stack_5", "cost": 10000000, "unlock": { "totalProduced": 5000000 }, "requires": "stack_4", "effect": { "type": "batch_size_mult", "value": 5 } }, { "id": "auto_buy", "cost": 800, "unlock": { "totalProduced": 800 }, "effect": { "type": "auto_buy_material" } } ] diff --git a/public/js/engine/effects.js b/public/js/engine/effects.js index 5cac5ed..065ba18 100644 --- a/public/js/engine/effects.js +++ b/public/js/engine/effects.js @@ -10,6 +10,7 @@ export function newModifiers() { value: 1, materialEfficiency: 1, batchDiscount: 1, + batchSize: 1, autoBuyMaterial: false, }; } @@ -22,6 +23,7 @@ const APPLY = { 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), + batch_size_mult: (m, e) => void (m.batchSize *= e.value), auto_buy_material: (m) => void (m.autoBuyMaterial = true), }; diff --git a/public/js/engine/engine.js b/public/js/engine/engine.js index 992f453..f129d7e 100644 --- a/public/js/engine/engine.js +++ b/public/js/engine/engine.js @@ -44,6 +44,7 @@ export function derive(state, ctx) { rate, state.materialFactor, mods.batchDiscount, + mods.batchSize, ); return { clickYield: rules.click.yield * mods.click, diff --git a/public/js/engine/formulas.js b/public/js/engine/formulas.js index 467c384..4fe513b 100644 --- a/public/js/engine/formulas.js +++ b/public/js/engine/formulas.js @@ -36,15 +36,19 @@ export function materialUnitPrice(mm, totalProduced) { } /** - * 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. + * What one purchase of material looks like. + * + * The lot covers `batchSecondsOfProduction` of current output (in doubling + * steps, never below `minBatch`), times `sizeMultiplier` from the "bigger + * stacks" upgrades. The price of a lot is proportional to its size: a bigger + * stack costs more in total, while the price PER UNIT keeps falling with scale + * (see materialUnitPrice). `factor` is the market's current noise and + * `discount` comes from upgrades. Cost is exact to the cent. */ -export function materialBatch(mm, totalProduced, rate, factor, discount) { - const basis = Math.max(totalProduced * mm.batchShareOfTotal, rate * mm.batchSecondsOfProduction); +export function materialBatch(mm, totalProduced, rate, factor, discount, sizeMultiplier = 1) { + const basis = 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 size = mm.minBatch * 2 ** doublings * sizeMultiplier; const cents = size * materialUnitPrice(mm, totalProduced) * factor * discount; return { size, cost: Math.max(0.01, Math.round(cents * 100) / 100) }; } diff --git a/public/locales/en.json b/public/locales/en.json index 3b2256f..9390d40 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -45,7 +45,8 @@ "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" + "auto_buy_material": "Restocks {@theme.res.material} automatically", + "batch_size_mult": "{@theme.res.material}: ×{value} per purchase" }, "time": { "seconds": "{value} s", diff --git a/public/locales/fr.json b/public/locales/fr.json index a9131d1..2944320 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -45,7 +45,8 @@ "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}" + "auto_buy_material": "Réapprovisionnement automatique : {@theme.res.material}", + "batch_size_mult": "{@theme.res.material} : ×{value} par achat" }, "time": { "seconds": "{value} s", diff --git a/public/themes/alien/locales/en.json b/public/themes/alien/locales/en.json index d20e9d7..7547cfa 100644 --- a/public/themes/alien/locales/en.json +++ b/public/themes/alien/locales/en.json @@ -44,6 +44,11 @@ "mat_eff_2": "Molecular Sieve", "mat_disc_1": "Asteroid Contract", "mat_disc_2": "Own the Mine", + "stack_1": "Bigger Cargo Pods", + "stack_2": "Container Drones", + "stack_3": "Freighter Holds", + "stack_4": "Convoy Fleets", + "stack_5": "Orbital Stockpile", "auto_buy": "Autonomous Freighter" } } diff --git a/public/themes/alien/locales/fr.json b/public/themes/alien/locales/fr.json index 484a32b..98d9a0d 100644 --- a/public/themes/alien/locales/fr.json +++ b/public/themes/alien/locales/fr.json @@ -44,6 +44,11 @@ "mat_eff_2": "Tamis moléculaire", "mat_disc_1": "Contrat d'astéroïde", "mat_disc_2": "Posséder la mine", + "stack_1": "Nacelles de fret plus grandes", + "stack_2": "Drones-conteneurs", + "stack_3": "Soutes de cargo", + "stack_4": "Flottes de convoi", + "stack_5": "Stock orbital", "auto_buy": "Cargo autonome" } } diff --git a/public/themes/insects/locales/en.json b/public/themes/insects/locales/en.json index 79fa48f..972ab33 100644 --- a/public/themes/insects/locales/en.json +++ b/public/themes/insects/locales/en.json @@ -44,6 +44,11 @@ "mat_eff_2": "Silk Recycling", "mat_disc_1": "Spider Contract", "mat_disc_2": "Silkworm Farm", + "stack_1": "Bigger Silk Bundles", + "stack_2": "Cocoon Sacks", + "stack_3": "Web Warehouses", + "stack_4": "Spider Caravans", + "stack_5": "Great Silk Hoard", "auto_buy": "Spider Delivery Service" } } diff --git a/public/themes/insects/locales/fr.json b/public/themes/insects/locales/fr.json index b014092..3f2dc6b 100644 --- a/public/themes/insects/locales/fr.json +++ b/public/themes/insects/locales/fr.json @@ -44,6 +44,11 @@ "mat_eff_2": "Recyclage de la soie", "mat_disc_1": "Contrat avec l'araignée", "mat_disc_2": "Ferme de vers à soie", + "stack_1": "Écheveaux de soie plus gros", + "stack_2": "Sacs de cocons", + "stack_3": "Entrepôts de toile", + "stack_4": "Caravanes d'araignées", + "stack_5": "Grand trésor de soie", "auto_buy": "Livraison par araignée" } } diff --git a/public/themes/paperclip/locales/en.json b/public/themes/paperclip/locales/en.json index bcc1e36..ddd68ab 100644 --- a/public/themes/paperclip/locales/en.json +++ b/public/themes/paperclip/locales/en.json @@ -44,6 +44,11 @@ "mat_eff_2": "Wire Recycling", "mat_disc_1": "Bulk Wire Contract", "mat_disc_2": "Direct From the Mill", + "stack_1": "Bigger Spools", + "stack_2": "Pallet Orders", + "stack_3": "Container Loads", + "stack_4": "Freight Trains", + "stack_5": "Wire Mills", "auto_buy": "WireBuyer" } } diff --git a/public/themes/paperclip/locales/fr.json b/public/themes/paperclip/locales/fr.json index 015eb22..f97307d 100644 --- a/public/themes/paperclip/locales/fr.json +++ b/public/themes/paperclip/locales/fr.json @@ -44,6 +44,11 @@ "mat_eff_2": "Recyclage du fil", "mat_disc_1": "Contrat de fil en gros", "mat_disc_2": "Directement de la fonderie", + "stack_1": "Bobines plus grosses", + "stack_2": "Commandes par palette", + "stack_3": "Chargements en conteneur", + "stack_4": "Trains de marchandises", + "stack_5": "Tréfileries", "auto_buy": "AcheteurDeFil" } } diff --git a/public/themes/witch/locales/en.json b/public/themes/witch/locales/en.json index 46c1bc8..20981de 100644 --- a/public/themes/witch/locales/en.json +++ b/public/themes/witch/locales/en.json @@ -44,6 +44,11 @@ "mat_eff_2": "Distilled Essence", "mat_disc_1": "Forager's Pact", "mat_disc_2": "Enchanted Garden", + "stack_1": "Larger Baskets", + "stack_2": "Herb Sacks", + "stack_3": "Cartloads", + "stack_4": "Greenhouse Harvest", + "stack_5": "Whole-Forest Rights", "auto_buy": "Self-Gathering Basket" } } diff --git a/public/themes/witch/locales/fr.json b/public/themes/witch/locales/fr.json index 8387c84..11ff17c 100644 --- a/public/themes/witch/locales/fr.json +++ b/public/themes/witch/locales/fr.json @@ -44,6 +44,11 @@ "mat_eff_2": "Essence distillée", "mat_disc_1": "Pacte des cueilleurs", "mat_disc_2": "Jardin enchanté", + "stack_1": "Paniers plus grands", + "stack_2": "Sacs d'herbes", + "stack_3": "Charrettes pleines", + "stack_4": "Récolte en serre", + "stack_5": "Droits sur toute la forêt", "auto_buy": "Panier qui cueille tout seul" } } diff --git a/tests/engine.test.js b/tests/engine.test.js index 7eb7b2b..f6f273a 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -165,15 +165,56 @@ test('material is costly at first and cheaper relative to the sale price as prod 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) => { +test('material lots follow current output, in doublings', () => { + const sizeWith = (gen1) => { const s = fresh(); - s.totalProduced = total; + s.generators.gen1 = gen1; 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); + const { minBatch } = ctx.rules.materialMarket; + assert.equal(sizeWith(0), minBatch); + assert.ok(sizeWith(500) > sizeWith(5)); + assert.equal(Math.log2(sizeWith(500) / minBatch) % 1, 0); +}); + +test('stack upgrades multiply the lot, and its price grows in proportion', () => { + const lot = (upgrades) => { + const s = fresh(); + s.upgrades = upgrades; + s.materialFactor = 1; + return derive(s, ctx); + }; + const base = lot([]); + const doubled = lot(['stack_1']); + const x12 = lot(['stack_1', 'stack_2', 'stack_3']); + assert.equal(doubled.batchSize, base.batchSize * 2); + assert.equal(x12.batchSize, base.batchSize * 12); + assert.ok(Math.abs(doubled.batchCost - base.batchCost * 2) <= 0.01, 'twice the wire costs twice as much'); + assert.ok(Math.abs(x12.batchCost - base.batchCost * 12) <= 0.06); +}); + +test('the price PER UNIT of material is unchanged by stack size and keeps falling with scale', () => { + const unitPrice = (totalProduced, upgrades) => { + const s = fresh(); + s.totalProduced = totalProduced; + s.upgrades = upgrades; + s.materialFactor = 1; + const d = derive(s, ctx); + return d.batchCost / d.batchSize; + }; + const early = unitPrice(0, []); + assert.ok(Math.abs(unitPrice(0, ['stack_1', 'stack_2']) - early) < early * 0.01); + assert.ok(unitPrice(1e6, ['stack_1', 'stack_2']) < unitPrice(1e4, ['stack_1', 'stack_2'])); + assert.ok(unitPrice(1e9, []) < early / 5, 'much cheaper per unit late in the game'); +}); + +test('stack upgrades form a chain', () => { + const s = fresh(); + s.totalProduced = 1e9; + assert.ok(upgradeVisible(s, ctx.upgradesById.get('stack_1'))); + assert.equal(upgradeVisible(s, ctx.upgradesById.get('stack_2')), false); + s.upgrades = ['stack_1']; + assert.ok(upgradeVisible(s, ctx.upgradesById.get('stack_2'))); }); test('the material market is erratic but stays within its bounds', () => {