Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight.
228 lines
9.2 KiB
JavaScript
228 lines
9.2 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// How much food a city wants in store before the coming month: a month at the
|
|
// current appetite, with a 130% cushion.
|
|
export function foodMonthlyTarget(population) {
|
|
return foodNeedPerDay(population) *
|
|
RESOURCE_RULES.daysPerMonth *
|
|
RESOURCE_RULES.foodMonthlyBuffer;
|
|
}
|
|
|
|
// 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 must 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.
|
|
export function synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable = Infinity) {
|
|
const gap = Math.max(0, shortfall);
|
|
if (!(gap > 0)) return 0;
|
|
const forced = Math.max(0, gap - 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 --
|
|
|
|
// Moves a price toward what the day's world-wide supply and demand imply. A
|
|
// glut pushes it down, a shortage up, and the clamp keeps the market sane. A
|
|
// zero-supply day is treated as the sharpest shortage.
|
|
export function nextMarketPrice(basePrice, price, supply, demand) {
|
|
if (!(demand > 0)) return basePrice;
|
|
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxMultiplier;
|
|
const pressure = (ratio - 1) * RESOURCE_MARKET.adjustment;
|
|
const moved = price * (1 + pressure);
|
|
const min = basePrice * RESOURCE_MARKET.minMultiplier;
|
|
const max = basePrice * RESOURCE_MARKET.maxMultiplier;
|
|
return Math.max(min, Math.min(max, moved));
|
|
}
|