fix: Changing way to sell with demand
This commit is contained in:
@@ -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`.
|
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.
|
- **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: 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.
|
- **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.
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"click": { "yield": 1 },
|
"click": { "yield": 1 },
|
||||||
"unit": { "materialPerUnit": 1 },
|
"unit": { "materialPerUnit": 1 },
|
||||||
"market": {
|
"market": {
|
||||||
"fairPrice": 0.25,
|
"referencePrice": 0.25,
|
||||||
"baseDemand": 2.5,
|
"baseDemand": 2.5,
|
||||||
"elasticity": 2,
|
"elasticity": 3,
|
||||||
"minPrice": 0.01,
|
"minPrice": 0.01,
|
||||||
"maxPrice": 50,
|
"maxPrice": 50,
|
||||||
"priceStep": 0.01
|
"priceStep": 0.01
|
||||||
@@ -17,13 +17,13 @@
|
|||||||
"startUnitPrice": 0.08,
|
"startUnitPrice": 0.08,
|
||||||
"floorUnitPrice": 0.01,
|
"floorUnitPrice": 0.01,
|
||||||
"scale": 3000,
|
"scale": 3000,
|
||||||
"minFactor": 0.4,
|
"minFactor": 0.3,
|
||||||
"maxFactor": 3,
|
"maxFactor": 4,
|
||||||
"volatility": 0.3,
|
"volatility": 0.5,
|
||||||
"reversion": 0.08,
|
"reversion": 0.12,
|
||||||
"spikeChance": 0.06,
|
"spikeChance": 0.2,
|
||||||
"spikeSize": 1.9,
|
"spikeSize": 2,
|
||||||
"updateEverySeconds": 1.5,
|
"updateEverySeconds": 10,
|
||||||
"autoBuyBelowFactor": 1.2
|
"autoBuyBelowFactor": 1.2
|
||||||
},
|
},
|
||||||
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
|
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
|
||||||
|
|||||||
@@ -45,17 +45,26 @@ export function materialBatch(mm, totalProduced, rate, factor, discount) {
|
|||||||
const basis = Math.max(totalProduced * mm.batchShareOfTotal, rate * mm.batchSecondsOfProduction);
|
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 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;
|
||||||
const cost = Math.max(1, Math.ceil(size * materialUnitPrice(mm, totalProduced) * factor * discount));
|
const cents = size * materialUnitPrice(mm, totalProduced) * factor * discount;
|
||||||
return { size, cost };
|
return { size, cost: Math.max(0.01, Math.round(cents * 100) / 100) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Units customers want to buy per second at `price`.
|
* Units customers want to buy per second at `price`: a smooth curve, with more
|
||||||
* Demand is maxed at or below the fair price and drops steeply above it, so
|
* demand the lower the price and less the higher it is (`referencePrice` is
|
||||||
* extra production is only worth selling at a higher price while supply is
|
* just where demand equals baseDemand x multiplier).
|
||||||
* short; to sell more at the fair price, the player needs more demand.
|
|
||||||
*/
|
*/
|
||||||
export function demandPerSecond(market, price, multiplier) {
|
export function demandPerSecond(market, price, multiplier) {
|
||||||
const overpriced = Math.max(1, price / market.fairPrice);
|
return market.baseDemand * multiplier * (market.referencePrice / price) ** market.elasticity;
|
||||||
return (market.baseDemand * multiplier) / overpriced ** 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -6,6 +6,7 @@ import { t } from '../services/i18n.js';
|
|||||||
import { $, h, icon, refreshStaticIcons } from './dom.js';
|
import { $, h, icon, refreshStaticIcons } from './dom.js';
|
||||||
|
|
||||||
const SLIDER_STEPS = 1000;
|
const SLIDER_STEPS = 1000;
|
||||||
|
const MARKET_TOLERANCE = 1.15; // demand within ±15 % of output counts as balanced
|
||||||
const TREND ={ '-1': '▼', 1: '▲' }; // last move of the material price
|
const TREND ={ '-1': '▼', 1: '▲' }; // last move of the material price
|
||||||
|
|
||||||
function setText(node, text) {
|
function setText(node, 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) {
|
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.priceInput) el.priceInput.value = state.price.toFixed(2);
|
||||||
if (document.activeElement !== el.priceSlider) {
|
if (document.activeElement !== el.priceSlider) {
|
||||||
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
|
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
|
||||||
}
|
}
|
||||||
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
|
setText(el.priceHint, marketHint(d));
|
||||||
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
|
|
||||||
|
|
||||||
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
|
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
|
||||||
setText(
|
setText(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"meta": { "name": "English" },
|
"meta": {
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -25,12 +27,15 @@
|
|||||||
"makeYield": "+{amount} per click",
|
"makeYield": "+{amount} per click",
|
||||||
"batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
|
"batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
|
||||||
"marketingInfo": "Level {level} → demand ×{multiplier} · {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}",
|
"owned": "Owned: {count}",
|
||||||
"rateEach": "{rate}/s each",
|
"rateEach": "{rate}/s each",
|
||||||
"buyN": "Buy ×{count}",
|
"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": {
|
"effect": {
|
||||||
"click_mult": "Manual work ×{value}",
|
"click_mult": "Manual work ×{value}",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"meta": { "name": "Français" },
|
"meta": {
|
||||||
|
"name": "Français"
|
||||||
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"theme": "Thème",
|
"theme": "Thème",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
@@ -25,12 +27,15 @@
|
|||||||
"makeYield": "+{amount} par clic",
|
"makeYield": "+{amount} par clic",
|
||||||
"batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
|
"batchInfo": "+{amount} {@theme.res.material} · {trend} {cost}",
|
||||||
"marketingInfo": "Niveau {level} → demande ×{multiplier} · {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}",
|
"owned": "Possédé : {count}",
|
||||||
"rateEach": "{rate}/s chacun",
|
"rateEach": "{rate}/s chacun",
|
||||||
"buyN": "Acheter ×{count}",
|
"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": {
|
"effect": {
|
||||||
"click_mult": "Travail manuel ×{value}",
|
"click_mult": "Travail manuel ×{value}",
|
||||||
|
|||||||
+38
-7
@@ -13,7 +13,7 @@ import {
|
|||||||
simulateOffline,
|
simulateOffline,
|
||||||
upgradeVisible,
|
upgradeVisible,
|
||||||
} from '../public/js/engine/engine.js';
|
} 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 { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
|
||||||
import { loadContext } from './helpers.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 { market } = ctx.rules;
|
||||||
const atFair = demandPerSecond(market, market.fairPrice, 1);
|
const at = (price, mult = 1) => demandPerSecond(market, price, mult);
|
||||||
assert.equal(demandPerSecond(market, market.fairPrice / 5, 1), atFair);
|
const ref = at(market.referencePrice);
|
||||||
assert.ok(demandPerSecond(market, market.fairPrice * 2, 1) < atFair / 3);
|
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', () => {
|
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', () => {
|
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 costShare = (totalProduced) => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.totalProduced = totalProduced;
|
s.totalProduced = totalProduced;
|
||||||
const d = derive(s, ctx);
|
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(0) > 0.25, 'expensive at the start');
|
||||||
assert.ok(costShare(1e4) < costShare(0));
|
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');
|
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', () => {
|
test('auto-buy waits for a fair price unless stock is nearly gone', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
s.upgrades = ['auto_buy'];
|
s.upgrades = ['auto_buy'];
|
||||||
|
|||||||
Reference in New Issue
Block a user