fix: Changing prices and dynamics in revenus

This commit is contained in:
gauvainboiche
2026-10-08 13:21:32 +02:00
parent c98995f310
commit cd6d38899c
17 changed files with 112 additions and 18 deletions
+1 -1
View File
@@ -74,4 +74,4 @@ Keys: `res.units|funds|material`, `gen.<id>`, `upg.<id>`. Anything not listed ke
Everything numeric is in `public/data/`: `rules.json` (market, raw material, marketing, offline, autosave), `generators.json`, `upgrades.json`. 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. - **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.
+1 -2
View File
@@ -12,8 +12,7 @@
}, },
"materialMarket": { "materialMarket": {
"minBatch": 100, "minBatch": 100,
"batchShareOfTotal": 0.1, "batchSecondsOfProduction": 10,
"batchSecondsOfProduction": 60,
"startUnitPrice": 0.08, "startUnitPrice": 0.08,
"floorUnitPrice": 0.01, "floorUnitPrice": 0.01,
"scale": 3000, "scale": 3000,
+5
View File
@@ -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_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_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": "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" } } { "id": "auto_buy", "cost": 800, "unlock": { "totalProduced": 800 }, "effect": { "type": "auto_buy_material" } }
] ]
+2
View File
@@ -10,6 +10,7 @@ export function newModifiers() {
value: 1, value: 1,
materialEfficiency: 1, materialEfficiency: 1,
batchDiscount: 1, batchDiscount: 1,
batchSize: 1,
autoBuyMaterial: false, autoBuyMaterial: false,
}; };
} }
@@ -22,6 +23,7 @@ const APPLY = {
value_mult: (m, e) => void (m.value *= e.value), value_mult: (m, e) => void (m.value *= e.value),
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value), material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
batch_discount: (m, e) => void (m.batchDiscount *= 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), auto_buy_material: (m) => void (m.autoBuyMaterial = true),
}; };
+1
View File
@@ -44,6 +44,7 @@ export function derive(state, ctx) {
rate, rate,
state.materialFactor, state.materialFactor,
mods.batchDiscount, mods.batchDiscount,
mods.batchSize,
); );
return { return {
clickYield: rules.click.yield * mods.click, clickYield: rules.click.yield * mods.click,
+11 -7
View File
@@ -36,15 +36,19 @@ export function materialUnitPrice(mm, totalProduced) {
} }
/** /**
* What one purchase of material looks like. The lot grows with the operation * What one purchase of material looks like.
* (a share of everything produced so far, or `batchSecondsOfProduction` of *
* current output), in doubling steps so the price stays readable. * The lot covers `batchSecondsOfProduction` of current output (in doubling
* `factor` is the market's current noise, `discount` comes from upgrades. * 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) { export function materialBatch(mm, totalProduced, rate, factor, discount, sizeMultiplier = 1) {
const basis = Math.max(totalProduced * mm.batchShareOfTotal, rate * mm.batchSecondsOfProduction); const basis = rate * mm.batchSecondsOfProduction;
const doublings = Math.max(0, Math.floor(Math.log2(Math.max(1, basis / mm.minBatch)))); 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; const cents = size * materialUnitPrice(mm, totalProduced) * factor * discount;
return { size, cost: Math.max(0.01, Math.round(cents * 100) / 100) }; return { size, cost: Math.max(0.01, Math.round(cents * 100) / 100) };
} }
+2 -1
View File
@@ -45,7 +45,8 @@
"value_mult": "Sale value ×{value}", "value_mult": "Sale value ×{value}",
"material_efficiency": "{@theme.res.material} goes ×{value} further", "material_efficiency": "{@theme.res.material} goes ×{value} further",
"batch_discount": "{@theme.res.material} is {percent}% cheaper", "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": { "time": {
"seconds": "{value} s", "seconds": "{value} s",
+2 -1
View File
@@ -45,7 +45,8 @@
"value_mult": "Valeur de vente ×{value}", "value_mult": "Valeur de vente ×{value}",
"material_efficiency": "{@theme.res.material} : rendement ×{value}", "material_efficiency": "{@theme.res.material} : rendement ×{value}",
"batch_discount": "{@theme.res.material} : {percent} % moins cher", "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": { "time": {
"seconds": "{value} s", "seconds": "{value} s",
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Molecular Sieve", "mat_eff_2": "Molecular Sieve",
"mat_disc_1": "Asteroid Contract", "mat_disc_1": "Asteroid Contract",
"mat_disc_2": "Own the Mine", "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" "auto_buy": "Autonomous Freighter"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Tamis moléculaire", "mat_eff_2": "Tamis moléculaire",
"mat_disc_1": "Contrat d'astéroïde", "mat_disc_1": "Contrat d'astéroïde",
"mat_disc_2": "Posséder la mine", "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" "auto_buy": "Cargo autonome"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Silk Recycling", "mat_eff_2": "Silk Recycling",
"mat_disc_1": "Spider Contract", "mat_disc_1": "Spider Contract",
"mat_disc_2": "Silkworm Farm", "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" "auto_buy": "Spider Delivery Service"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Recyclage de la soie", "mat_eff_2": "Recyclage de la soie",
"mat_disc_1": "Contrat avec l'araignée", "mat_disc_1": "Contrat avec l'araignée",
"mat_disc_2": "Ferme de vers à soie", "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" "auto_buy": "Livraison par araignée"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Wire Recycling", "mat_eff_2": "Wire Recycling",
"mat_disc_1": "Bulk Wire Contract", "mat_disc_1": "Bulk Wire Contract",
"mat_disc_2": "Direct From the Mill", "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" "auto_buy": "WireBuyer"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Recyclage du fil", "mat_eff_2": "Recyclage du fil",
"mat_disc_1": "Contrat de fil en gros", "mat_disc_1": "Contrat de fil en gros",
"mat_disc_2": "Directement de la fonderie", "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" "auto_buy": "AcheteurDeFil"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Distilled Essence", "mat_eff_2": "Distilled Essence",
"mat_disc_1": "Forager's Pact", "mat_disc_1": "Forager's Pact",
"mat_disc_2": "Enchanted Garden", "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" "auto_buy": "Self-Gathering Basket"
} }
} }
+5
View File
@@ -44,6 +44,11 @@
"mat_eff_2": "Essence distillée", "mat_eff_2": "Essence distillée",
"mat_disc_1": "Pacte des cueilleurs", "mat_disc_1": "Pacte des cueilleurs",
"mat_disc_2": "Jardin enchanté", "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" "auto_buy": "Panier qui cueille tout seul"
} }
} }
+47 -6
View File
@@ -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'); assert.ok(costShare(1e9) < 0.1, 'a small share of revenue late in the game');
}); });
test('material lots grow with the operation, in doublings', () => { test('material lots follow current output, in doublings', () => {
const sizeAt = (total) => { const sizeWith = (gen1) => {
const s = fresh(); const s = fresh();
s.totalProduced = total; s.generators.gen1 = gen1;
return derive(s, ctx).batchSize; return derive(s, ctx).batchSize;
}; };
assert.equal(sizeAt(0), ctx.rules.materialMarket.minBatch); const { minBatch } = ctx.rules.materialMarket;
assert.ok(sizeAt(1e6) > sizeAt(1e4)); assert.equal(sizeWith(0), minBatch);
assert.equal(Math.log2(sizeAt(1e6) / ctx.rules.materialMarket.minBatch) % 1, 0); 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', () => { test('the material market is erratic but stays within its bounds', () => {