diff --git a/README.md b/README.md index c642a5b..ba5c68c 100644 --- a/README.md +++ b/README.md @@ -73,5 +73,5 @@ 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:** 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. diff --git a/public/data/rules.json b/public/data/rules.json index 383dc80..8dbdbc5 100644 --- a/public/data/rules.json +++ b/public/data/rules.json @@ -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 }, diff --git a/public/js/engine/formulas.js b/public/js/engine/formulas.js index f996dcc..467c384 100644 --- a/public/js/engine/formulas.js +++ b/public/js/engine/formulas.js @@ -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); } diff --git a/public/js/ui/view.js b/public/js/ui/view.js index 00fa72a..65e0b0e 100644 --- a/public/js/ui/view.js +++ b/public/js/ui/view.js @@ -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( diff --git a/public/locales/en.json b/public/locales/en.json index 3e1431d..3b2256f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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}", diff --git a/public/locales/fr.json b/public/locales/fr.json index ebd30ec..a9131d1 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -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}", diff --git a/tests/engine.test.js b/tests/engine.test.js index e153af1..7eb7b2b 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -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'];