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`.
- **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": {
"minBatch": 100,
"batchShareOfTotal": 0.1,
"batchSecondsOfProduction": 60,
"batchSecondsOfProduction": 10,
"startUnitPrice": 0.08,
"floorUnitPrice": 0.01,
"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_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" } }
]
+2
View File
@@ -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),
};
+1
View File
@@ -44,6 +44,7 @@ export function derive(state, ctx) {
rate,
state.materialFactor,
mods.batchDiscount,
mods.batchSize,
);
return {
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
* (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) };
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+5
View File
@@ -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"
}
}
+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');
});
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', () => {