The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put.
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
// Pure resource maths shared by the server (which simulates the economy) and
|
|
// the browser (which explains it). Everything here is framework-free and takes
|
|
// plain numbers, so both sides derive identical figures from the same rules in
|
|
// shared/data/resources.js.
|
|
|
|
import {
|
|
RESOURCE_IDS,
|
|
RESOURCE_RULES,
|
|
RESOURCE_MARKET,
|
|
RESOURCE_MARKET_BASE,
|
|
resourceById,
|
|
} from "./data/resources.js";
|
|
import { EFFECT_RESEARCH } from "./data/effects.js";
|
|
import { groupDigits } from "./text_format.js";
|
|
|
|
const DAYS_PER_YEAR = 365;
|
|
|
|
// --------------------------------------------------------------- formatting --
|
|
|
|
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
|
|
export function formatEnergy(kwh) {
|
|
const value = Math.max(0, kwh);
|
|
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
|
|
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
|
|
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
|
|
return `${trim(value)} kWh`;
|
|
}
|
|
|
|
// A material in its own friendly unit: tonnes step up through kt and Mt,
|
|
// carats through kct, high-tech through ku.
|
|
export function formatResourceAmount(id, value) {
|
|
if (id === "energy") return formatEnergy(value);
|
|
const amount = Math.max(0, value);
|
|
let unit = resourceById(id) ? resourceById(id).unit : "";
|
|
let scaled = amount;
|
|
let prefix = "";
|
|
if (amount >= 1e9) {
|
|
scaled = amount / 1e9;
|
|
prefix = "M";
|
|
} else if (amount >= 1e6) {
|
|
scaled = amount / 1e6;
|
|
prefix = "k";
|
|
}
|
|
return `${trim(scaled)} ${prefix}${unit}`;
|
|
}
|
|
|
|
function trim(value) {
|
|
if (value >= 100) return groupDigits(Math.round(value));
|
|
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
|
|
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
// --------------------------------------------------------------- per-person --
|
|
|
|
export function foodNeedPerDay(population) {
|
|
return Math.max(0, population) * RESOURCE_RULES.foodPerPersonPerDay;
|
|
}
|
|
|
|
// The food a unit eats in a day from its carried stores. Aircraft and ships do
|
|
// not: they live on endurance and range instead. Every land unit fields a
|
|
// company of `population` (or the rule's default), fed like the civilians of
|
|
// that size. Mirrors GameState._tickUnitSupply so the card and the tick agree.
|
|
export function unitFoodPerDay(proto) {
|
|
if (!proto || proto.air || proto.naval) return 0;
|
|
if (!(proto.traversableTerrains || []).includes("Land")) return 0;
|
|
return foodNeedPerDay(proto.population || RESOURCE_RULES.unitPopulation);
|
|
}
|
|
|
|
export function steelNeedPerDay(population) {
|
|
return Math.max(0, population) * RESOURCE_RULES.steelPerPersonPerDay;
|
|
}
|
|
|
|
export function luxuryNeedPerDay(population) {
|
|
return Math.max(0, population) * RESOURCE_RULES.luxuryPerPersonPerDay;
|
|
}
|
|
|
|
// What one worked land tile grows in a day. Food comes from the land, not from
|
|
// how many people live on it: a farm feeds whoever works it, so moving people
|
|
// between regions changes only the income per head, never the harvest. A tile
|
|
// feeds roughly `ECONOMY.basePopulation` people at factor 1.
|
|
export function tileFoodOutput(tile) {
|
|
const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
|
|
return RESOURCE_RULES.foodPerTilePerDay * factor;
|
|
}
|
|
|
|
// The reserve a region wants to hold of a material it consumes: a month at the
|
|
// current appetite. Every storable good is buffered to the same horizon, so the
|
|
// same figure sizes food, steel, luxury and high-tech reserves alike.
|
|
export function stockpileTarget(needPerDay) {
|
|
return Math.max(0, needPerDay) * RESOURCE_RULES.stockpileDays;
|
|
}
|
|
|
|
// How much food a city wants in store before the coming month: a month at the
|
|
// current appetite.
|
|
export function foodMonthlyTarget(population) {
|
|
return stockpileTarget(foodNeedPerDay(population));
|
|
}
|
|
|
|
// The energy a region spends to force `extraFood` tonnes a day from the land
|
|
// beyond its harvest. Quadratic in the extra food, so the marginal tonne gets
|
|
// dearer the harder the land is pushed.
|
|
export function foodSynthesisEnergy(extraFood) {
|
|
const x = Math.max(0, extraFood);
|
|
return RESOURCE_RULES.foodSynthesisQuadratic * x * x;
|
|
}
|
|
|
|
// The marginal energy for one more tonne of synthesised food, the slope of
|
|
// `foodSynthesisEnergy`.
|
|
export function foodSynthesisMarginalEnergy(extraFood) {
|
|
return 2 * RESOURCE_RULES.foodSynthesisQuadratic * Math.max(0, extraFood);
|
|
}
|
|
|
|
// How much extra food a region grows for a `shortfall` it can cover. Growing is
|
|
// always cheapest for the first tonnes (its marginal energy starts near zero)
|
|
// and buying is a flat price, so the region grows while the marginal energy is
|
|
// cheaper than the market, up to the break-even. Then it completes with the
|
|
// dearer option: what the reachable market cannot actually supply -- `buyable`
|
|
// tonnes -- it must grow itself, even past the break-even. With an unreachable
|
|
// or bottomless market the default `buyable = Infinity` gives the plain
|
|
// break-even result.
|
|
//
|
|
// `shortfall` is how much the region would *like* to grow -- the day's meal
|
|
// plus whatever it must add to refill an empty reserve. `mustGrow` is the part
|
|
// it cannot avoid: the ration it must force because it cannot buy that much. It
|
|
// defaults to the whole shortfall, which is the old single-target behaviour.
|
|
export function synthesisedFoodFor(
|
|
foodPrice, energyPrice, shortfall, buyable = Infinity, mustGrow = shortfall
|
|
) {
|
|
const gap = Math.max(0, shortfall);
|
|
if (!(gap > 0)) return 0;
|
|
const forced = Math.max(0, Math.max(0, mustGrow) - Math.max(0, buyable));
|
|
const quadratic = RESOURCE_RULES.foodSynthesisQuadratic;
|
|
if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) {
|
|
return Math.min(gap, forced);
|
|
}
|
|
const breakEven = foodPrice / (2 * quadratic * energyPrice);
|
|
if (!(breakEven > 0)) return Math.min(gap, forced);
|
|
return Math.min(gap, Math.max(breakEven, forced));
|
|
}
|
|
|
|
// A city's baseline electric appetite, kWh a day, from its yearly GDP.
|
|
export function cityEnergyPerDay(gdp) {
|
|
return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
|
|
}
|
|
|
|
// What one building level, unit or tile work wears out in a day, from its money
|
|
// value. Advanced things also burn high-tech. Buildings pass their level's cost
|
|
// (so upkeep grows with the level), units their training cost.
|
|
export function upkeepResources(value, advanced = false) {
|
|
const amount = Math.max(0, value);
|
|
return {
|
|
steel: amount * RESOURCE_RULES.upkeepSteelPerEuro,
|
|
energy: amount * RESOURCE_RULES.upkeepEnergyPerEuro,
|
|
hightech: advanced ? amount * RESOURCE_RULES.upkeepHighTechPerEuro : 0,
|
|
};
|
|
}
|
|
|
|
// The daily upkeep of a catalogue entry. An explicit `materialUpkeep` override
|
|
// wins -- guns and aircraft are cheap parked and costly when fired, so their
|
|
// standing bill is set by hand -- otherwise it scales with the entry's value.
|
|
export function protoUpkeep(proto, value) {
|
|
if (proto && proto.materialUpkeep) {
|
|
return {
|
|
steel: proto.materialUpkeep.steel || 0,
|
|
energy: proto.materialUpkeep.energy || 0,
|
|
hightech: proto.materialUpkeep.hightech || 0,
|
|
};
|
|
}
|
|
return upkeepResources(value, !!(proto && proto.advanced));
|
|
}
|
|
|
|
// The materials and power a tile improvement spends to be built: steel in
|
|
// proportion to its money cost, the electricity that steel embodies, and
|
|
// high-tech for an advanced work. Shared so the server's bill and the browser's
|
|
// cost buttons agree on the same figures.
|
|
export function tileImprovementResourceCost(proto, buildCost = null) {
|
|
const cost = Math.max(0, buildCost !== null ? buildCost : ((proto && proto.buildCost) || 0));
|
|
return {
|
|
steel: cost * RESOURCE_RULES.steelPerBudgetEuro,
|
|
energy: cost * RESOURCE_RULES.constructionEnergyPerBudgetEuro,
|
|
hightech: proto && proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel : 0,
|
|
};
|
|
}
|
|
|
|
// The steel and high-tech a building or unit spends to be built: steel in
|
|
// proportion to its money cost, high-tech for an advanced item (more with every
|
|
// level). Mirrors the server's construction bill so the browser can quote it at
|
|
// today's prices.
|
|
export function constructionResourceCost(proto, level = 0, buildCost = null) {
|
|
const cost = buildCost !== null ? buildCost : ((proto && (proto.buildCost || proto.baseCost)) || 0);
|
|
const advanced = !!(proto && (proto.advanced ||
|
|
(proto.effects || []).some((e) => e.stat === EFFECT_RESEARCH)));
|
|
return {
|
|
steel: Math.max(0, cost) * RESOURCE_RULES.steelPerBudgetEuro,
|
|
hightech: advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
|
|
};
|
|
}
|
|
|
|
// Today's global price index: the average ratio of each resource's current
|
|
// price to its base. One figure lets a money cost with no material bill of its
|
|
// own be quoted at the market.
|
|
export function marketPriceIndex(prices) {
|
|
let total = 0;
|
|
let count = 0;
|
|
for (const id of RESOURCE_IDS) {
|
|
const base = RESOURCE_MARKET_BASE[id];
|
|
if (!(base > 0)) continue;
|
|
const price = prices && prices[id] !== undefined ? prices[id] : base;
|
|
total += price / base;
|
|
count += 1;
|
|
}
|
|
return count > 0 ? total / count : 1;
|
|
}
|
|
|
|
// The market value of a resource bill at the given prices, a resource at a time.
|
|
export function resourceMarketValue(resources, prices) {
|
|
let total = 0;
|
|
for (const id of RESOURCE_IDS) {
|
|
const amount = (resources && resources[id]) || 0;
|
|
if (!(amount > 0)) continue;
|
|
const price = prices && prices[id] !== undefined ? prices[id] : RESOURCE_MARKET_BASE[id];
|
|
total += amount * (price || 0);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
// The energy a material producer burns to make its output: the energy-per-unit
|
|
// ratio from the rules, whichever resource it makes.
|
|
export function producerEnergyPerDay(id, outputPerDay) {
|
|
if (id === "steel") return outputPerDay * RESOURCE_RULES.energyPerSteelTonne;
|
|
if (id === "food") return outputPerDay * RESOURCE_RULES.energyPerFoodTonne;
|
|
if (id === "hightech") return outputPerDay * RESOURCE_RULES.energyPerHighTechUnit;
|
|
return 0;
|
|
}
|
|
|
|
// ----------------------------------------------------------------- market --
|
|
|
|
// Eases a price toward the equilibrium the day's world-wide supply and demand
|
|
// imply: the price that clears supply against demand is `base * demand/supply`,
|
|
// and the price travels a fixed share of the way there each day. A sustained
|
|
// shortage therefore lifts a good above its base and a glut cheapens it, but the
|
|
// price settles at the equilibrium instead of compounding without bound, so no
|
|
// price band is needed to keep it sane. A day with no supply at all is treated as
|
|
// the sharpest shortage the ratio may read, and the ratio is capped so a
|
|
// rounding-error supply cannot run the equilibrium away.
|
|
export function nextMarketPrice(basePrice, price, supply, demand) {
|
|
if (!(demand > 0)) return basePrice;
|
|
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxRatio;
|
|
const equilibrium = basePrice * Math.min(ratio, RESOURCE_MARKET.maxRatio);
|
|
return price + (equilibrium - price) * RESOURCE_MARKET.adjustment;
|
|
}
|