fix: Changing way to sell with demand

This commit is contained in:
gauvainboiche
2026-10-08 12:34:33 +02:00
parent 1734487f1e
commit c98995f310
7 changed files with 103 additions and 38 deletions
+2 -2
View File
@@ -73,5 +73,5 @@ 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:** 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.
- **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.
+9 -9
View File
@@ -3,9 +3,9 @@
"click": { "yield": 1 },
"unit": { "materialPerUnit": 1 },
"market": {
"fairPrice": 0.25,
"referencePrice": 0.25,
"baseDemand": 2.5,
"elasticity": 2,
"elasticity": 3,
"minPrice": 0.01,
"maxPrice": 50,
"priceStep": 0.01
@@ -17,13 +17,13 @@
"startUnitPrice": 0.08,
"floorUnitPrice": 0.01,
"scale": 3000,
"minFactor": 0.4,
"maxFactor": 3,
"volatility": 0.3,
"reversion": 0.08,
"spikeChance": 0.06,
"spikeSize": 1.9,
"updateEverySeconds": 1.5,
"minFactor": 0.3,
"maxFactor": 4,
"volatility": 0.5,
"reversion": 0.12,
"spikeChance": 0.2,
"spikeSize": 2,
"updateEverySeconds": 10,
"autoBuyBelowFactor": 1.2
},
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
+17 -8
View File
@@ -45,17 +45,26 @@ 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 };
const cents = size * materialUnitPrice(mm, totalProduced) * factor * discount;
return { size, cost: Math.max(0.01, Math.round(cents * 100) / 100) };
}
/**
* Units customers want to buy per second at `price`.
* Demand is maxed at or below the fair price and drops steeply above it, so
* extra production is only worth selling at a higher price while supply is
* short; to sell more at the fair price, the player needs more demand.
* Units customers want to buy per second at `price`: a smooth curve, with more
* demand the lower the price and less the higher it is (`referencePrice` is
* just where demand equals baseDemand x multiplier).
*/
export function demandPerSecond(market, price, multiplier) {
const overpriced = Math.max(1, price / market.fairPrice);
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
return market.baseDemand * multiplier * (market.referencePrice / price) ** market.elasticity;
}
/**
* The price at which customers buy exactly what is produced. Selling for less
* gains no volume (everything is already sold); selling for more leaves stock
* unsold. It moves with output and every demand bonus, and the UI deliberately
* does not reveal it: the player finds it from the market feedback.
*/
export function balancedPrice(market, supplyPerSecond, multiplier) {
const demandAtReference = market.baseDemand * multiplier;
return market.referencePrice * (demandAtReference / supplyPerSecond) ** (1 / market.elasticity);
}
+19 -4
View File
@@ -6,7 +6,8 @@ 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
const MARKET_TOLERANCE = 1.15; // demand within ±15 % of output counts as balanced
const TREND ={ '-1': '▼', 1: '▲' }; // last move of the material price
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
@@ -184,14 +185,28 @@ export function createView(actions) {
);
}
/**
* Feedback on the price: what customers want versus what is produced, and
* which side of the balance the player is on. It never gives the balanced
* price itself: it depends on output and every bonus, so it must be found.
*/
function marketHint(d) {
const demand = fmt.number(d.demand);
if (d.rate <= 0) return t('ui.hintDemandOnly', { demand });
const ratio = d.demand / d.rate;
let verdict = 'ui.marketBalanced';
if (ratio > MARKET_TOLERANCE) verdict = 'ui.marketShort';
else if (ratio < 1 / MARKET_TOLERANCE) verdict = 'ui.marketLong';
return `${t('ui.hintDemand', { demand, production: fmt.number(d.rate) })} ${t(verdict)}`;
}
function updateMarket(state, d) {
const { fairPrice, minPrice } = ctx.rules.market;
const { minPrice } = ctx.rules.market;
if (document.activeElement !== el.priceInput) el.priceInput.value = state.price.toFixed(2);
if (document.activeElement !== el.priceSlider) {
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
}
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
setText(el.priceHint, marketHint(d));
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
setText(
+9 -4
View File
@@ -1,5 +1,7 @@
{
"meta": { "name": "English" },
"meta": {
"name": "English"
},
"ui": {
"theme": "Theme",
"language": "Language",
@@ -25,12 +27,15 @@
"makeYield": "+{amount} per click",
"batchInfo": "+{amount} {@theme.res.material} · {trend} {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.",
"hintHigh": "Customers want {demand}/s at this price. Above the fair price ({fair}) demand drops fast.",
"owned": "Owned: {count}",
"rateEach": "{rate}/s each",
"buyN": "Buy ×{count}",
"upgradeCount": "{owned} / {total}"
"upgradeCount": "{owned} / {total}",
"hintDemand": "At this price customers want {demand}/s; you make {production}/s.",
"hintDemandOnly": "At this price customers want {demand}/s.",
"marketShort": "Demand outpaces your output: you are selling too cheap.",
"marketLong": "You make more than customers want: stock piles up. Try a lower price.",
"marketBalanced": "Supply and demand are about even."
},
"effect": {
"click_mult": "Manual work ×{value}",
+9 -4
View File
@@ -1,5 +1,7 @@
{
"meta": { "name": "Français" },
"meta": {
"name": "Français"
},
"ui": {
"theme": "Thème",
"language": "Langue",
@@ -25,12 +27,15 @@
"makeYield": "+{amount} par clic",
"batchInfo": "+{amount} {@theme.res.material} · {trend} {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.",
"hintHigh": "Les clients veulent {demand}/s à ce prix. Au-dessus du prix juste ({fair}), la demande chute vite.",
"owned": "Possédé : {count}",
"rateEach": "{rate}/s chacun",
"buyN": "Acheter ×{count}",
"upgradeCount": "{owned} / {total}"
"upgradeCount": "{owned} / {total}",
"hintDemand": "À ce prix, les clients veulent {demand}/s ; vous produisez {production}/s.",
"hintDemandOnly": "À ce prix, les clients veulent {demand}/s.",
"marketShort": "La demande dépasse votre production : vous vendez trop bon marché.",
"marketLong": "Vous produisez plus que la demande : le stock s'accumule. Essayez un prix plus bas.",
"marketBalanced": "L'offre et la demande sont à peu près égales."
},
"effect": {
"click_mult": "Travail manuel ×{value}",
+38 -7
View File
@@ -13,7 +13,7 @@ import {
simulateOffline,
upgradeVisible,
} from '../public/js/engine/engine.js';
import { bulkCost, demandPerSecond, maxAffordable } from '../public/js/engine/formulas.js';
import { balancedPrice, bulkCost, demandPerSecond, maxAffordable } from '../public/js/engine/formulas.js';
import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
import { loadContext } from './helpers.js';
@@ -35,11 +35,24 @@ test('maxAffordable never overspends and is maximal', () => {
}
});
test('demand is flat at/below the fair price and drops above it', () => {
test('lower prices sell more, higher prices sell less, with no ceiling at the reference price', () => {
const { market } = ctx.rules;
const atFair = demandPerSecond(market, market.fairPrice, 1);
assert.equal(demandPerSecond(market, market.fairPrice / 5, 1), atFair);
assert.ok(demandPerSecond(market, market.fairPrice * 2, 1) < atFair / 3);
const at = (price, mult = 1) => demandPerSecond(market, price, mult);
const ref = at(market.referencePrice);
assert.equal(ref, market.baseDemand);
assert.ok(at(market.referencePrice / 2) > ref * 2, 'half price sells more than double');
assert.ok(at(market.minPrice) > at(market.referencePrice / 2));
assert.ok(at(market.referencePrice * 2) < ref / 2);
assert.equal(at(market.referencePrice, 3), ref * 3, 'bonuses scale demand');
});
test('the balanced price moves with output and demand bonuses', () => {
const { market } = ctx.rules;
const balanced = (supply, mult) => balancedPrice(market, supply, mult);
const p = balanced(10, 1);
assert.ok(Math.abs(demandPerSecond(market, p, 1) - 10) < 1e-9, 'demand equals supply there');
assert.ok(balanced(100, 1) < p, 'more output -> lower balanced price');
assert.ok(balanced(10, 4) > p, 'more demand -> higher balanced price');
});
test('click makes a unit from material', () => {
@@ -139,12 +152,12 @@ test('auto-buy restocks material when funds allow', () => {
});
test('material is costly at first and cheaper relative to the sale price as production grows', () => {
const { fairPrice } = ctx.rules.market;
const { referencePrice } = ctx.rules.market;
const costShare = (totalProduced) => {
const s = fresh();
s.totalProduced = totalProduced;
const d = derive(s, ctx);
return d.batchCost / d.batchSize / fairPrice;
return d.batchCost / d.batchSize / referencePrice;
};
assert.ok(costShare(0) > 0.25, 'expensive at the start');
assert.ok(costShare(1e4) < costShare(0));
@@ -178,6 +191,24 @@ test('the material market is erratic but stays within its bounds', () => {
assert.ok(new Set(seen.map((f) => f.toFixed(2))).size > 100, 'does not sit still');
});
test('material prices have cents, not just whole numbers', () => {
const s = fresh();
s.materialFactor = 0.8137;
const { batchCost } = derive(s, ctx);
assert.notEqual(batchCost, Math.round(batchCost));
assert.equal(batchCost, Math.round(batchCost * 100) / 100);
});
test('the material price changes every 10 seconds, not before', () => {
const m = ctx.rules.materialMarket;
assert.equal(m.updateEverySeconds, 10);
const s = fresh();
advance(s, ctx, 9.5, () => 0.9);
assert.equal(s.materialFactor, 1);
advance(s, ctx, 1, () => 0.9);
assert.notEqual(s.materialFactor, 1);
});
test('auto-buy waits for a fair price unless stock is nearly gone', () => {
const s = fresh();
s.upgrades = ['auto_buy'];