Moved the economy onto resources and surfaced each building's material bill

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.
This commit is contained in:
2026-09-22 23:31:36 +02:00
parent 1324128245
commit 7ea5373b60
21 changed files with 411 additions and 202 deletions
-5
View File
@@ -97,11 +97,6 @@ export const MAP_CONFIG = {
// leaves exactly this many landmasses.
minContinents: 3,
connectOceans: true,
// How many nations open with a chip foundry, the only high-tech works the
// opening economy ever gets. They are spread across distinct nations, drawn
// from the world seed, and never the same nation twice. A nation with no land
// to host one simply goes without.
startingChipFabs: 2,
citiesPerCiv: 10,
// Minimum distance between any two cities during generation (never below 5).
citySpacing: 5,
+10 -7
View File
@@ -162,7 +162,7 @@ export const RESOURCE_RULES = {
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
diamond_mine: 5 * KWH_PER_MWH, // 5 MWh per carat
},
energyPerHighTechUnit: 100 * KWH_PER_MWH, // 100 MWh per unit
energyPerHighTechUnit: 10 * KWH_PER_MWH, // 10 MWh per unit
// Science buildings spend one high-tech unit for every research point.
highTechPerResearchPoint: 1,
@@ -176,12 +176,15 @@ export const RESOURCE_RULES = {
ship: 50, // 50 kWh per tonne per tile
},
// Steel every construction spends per euro of its money cost. At 1/10000 a
// 100 M building needs 10,000 t of steel.
steelPerBudgetEuro: 1 / 10_000,
// Steel every construction spends per euro of its money cost. At 1/1000 a
// 100 M building needs 100,000 t of steel. The steel bill is deliberately an
// order of magnitude larger than the cash price, so nothing is bought with
// money alone: the state pays with materials it has, and the currency is only
// the settlement it falls back on.
steelPerBudgetEuro: 1 / 1_000,
// The electricity a tile work draws as it is built, per euro of its money
// cost. It is roughly the power embodied in the steel it spends: steel costs
// 20 MWh a tonne and 1 t per 10,000 euro, so building is powered as well as
// 20 MWh a tonne and 1 t per 1,000 euro, so building is powered as well as
// supplied. Tile improvements pay this up front.
constructionEnergyPerBudgetEuro: 2, // kWh per euro
// High-tech a science or advanced building needs to be raised, per level.
@@ -382,9 +385,9 @@ export const RESOURCE_BUILDINGS = [
id: "steel_mill",
name: "Steel mill",
description:
"Smelts iron into 2000 t of steel a day, spending 20 MWh of energy per tonne.",
"Smelts iron into 20,000 t of steel a day, spending 20 MWh of energy per tonne.",
resource: "steel",
outputPerDay: 2_000,
outputPerDay: 20_000,
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
buildCost: 800_000_000,
maintenance: 10_000,
+76
View File
@@ -0,0 +1,76 @@
// The economic graph toolkit: a framework-free traversal over an abstract
// transport graph, and the transport-energy costs that weight its edges. It
// knows nothing about tiles, roads, ports or nations -- the caller supplies the
// adjacency and the per-step cost -- so the same engine serves the trade
// network, the delivery graph and any other economic spread over the map.
//
// Keeping this apart from `game_state/resources.js` means the simulation
// describes *how* its world is wired, while this module owns *how* to walk a
// graph and price a leg.
import { RESOURCE_RULES, resourceById } from "./data/resources.js";
// --------------------------------------------------------------- traversal --
// Breadth-first spread from `start` over an abstract graph, in rings of
// increasing distance. `neighbours(node)` returns the outward edges of a node,
// each `{ node, cost }`; a directed graph is just a `neighbours` that omits the
// reverse edge. The traversal stops at `maxDistance` steps.
//
// `accept(node, { distance, cost })` is called for every newly reached node
// (never the start) and decides whether it joins the result; returning false
// still lets the search continue through it. The result is the accepted nodes
// with their accumulated `distance` (steps) and `cost` (sum of edge costs),
// sorted by cost.
export function traverse(start, { neighbours, id = (node) => node, accept = null, maxDistance = Infinity }) {
const results = [];
const visited = new Set([id(start)]);
let frontier = [{ node: start, distance: 0, cost: 0 }];
while (frontier.length > 0) {
const next = [];
for (const current of frontier) {
if (current.distance >= maxDistance) continue;
for (const edge of neighbours(current.node)) {
const key = id(edge.node);
if (visited.has(key)) continue;
visited.add(key);
const reached = {
node: edge.node,
distance: current.distance + 1,
cost: current.cost + (edge.cost || 0),
};
if (!accept || accept(edge.node, reached)) results.push(reached);
next.push(reached);
}
}
frontier = next;
}
results.sort((a, b) => a.cost - b.cost);
return results;
}
// ---------------------------------------------------------------- delivery --
// The kWh of transport energy one material tonne costs over `tiles` tiles in
// the given mode. A material tonne and an energy tonne (10 GWh) share the
// formula, so material deliveries and energy deliveries are priced alike.
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
const d = RESOURCE_RULES.deliveryEnergy;
const perTile = mode === "train"
? d.train
: mode === "ship"
? d.ship
: mode === "road"
? d.truckRoad
: d.truckOffRoad * Math.max(tileMovementCost, 0);
return tonnes * Math.max(tiles, 0) * perTile;
}
// The energy cost of delivering `amount` of a resource over `tiles`. Every
// resource carries a mass per canonical unit, so a carat or a chip is charged
// for the tonnes it actually weighs rather than as if it were a tonne.
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
const proto = resourceById(id);
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
}
+143 -86
View File
@@ -33,11 +33,11 @@ import {
producerEnergyPerDay,
foodSynthesisEnergy,
synthesisedFoodFor,
deliveryEnergyForResource,
nextMarketPrice,
protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js";
import { deliveryEnergyForResource, traverse } from "../economy_graph.js";
import { buildingBuildCost } from "../rules.js";
import { Random } from "../rng.js";
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
@@ -50,13 +50,14 @@ const SUPPLIER_RESERVE_DAYS = 2;
// The producers a fresh nation may be seeded with. Energy uses the large
// fossil plants so a nation needs few works to light its industry; the luxury
// pick is randomised between the two mines. High-tech is deliberately absent:
// a chip foundry is not covered by these passes but placed by hand for the two
// nations that open with one (see `_seedResourceBuildings`). Fusion, locked
// behind research, never appears either.
// pick is randomised between the two mines. High-tech is an industry like any
// other: a nation short of it places chip foundries just as it places mills and
// mines, and the power pass then builds the grid to run them. Fusion, locked
// behind research, never appears.
const SEED_PRODUCERS = {
steel: ["steel_mill"],
luxury: ["diamond_mine", "gold_mine"],
hightech: ["chip_foundry"],
energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
};
// How many top-up passes the start-of-game seeding makes before it gives up on
@@ -68,6 +69,11 @@ const SEED_MAX_PASSES = 10;
// because it scales with GDP, which the market moves.
const SEED_MATERIAL_MARGIN = 1.05;
const SEED_ENERGY_MARGIN = 1.15;
// The steel and high-tech a city opens holding, on top of its people's own
// staples. Construction is settled in steel, so the opening store is sized to
// let a fresh region raise its first works before any producer comes online.
const OPENING_STEEL_STORE = 200_000;
const OPENING_HIGHTECH_STORE = 500;
function emptyStock() {
return { steel: 0, food: 0, luxury: 0, hightech: 0 };
@@ -207,15 +213,17 @@ export const resourceMethods = {
return record;
},
// Every city opens with most of a month of food, a little steel and luxury,
// and some high-tech, so an early game with no producer yet can still build.
// Every city opens with most of a month of food, a working store of steel and
// luxury, and some high-tech, so an early game with no producer yet can still
// build. Steel is stocked deeply because construction is paid in steel, not
// cash: the opening store is sized to cover a first building's material bill.
_seedCityResources(city) {
const k = key(city.coords.x, city.coords.y);
const stock = emptyStock();
stock.food = foodMonthlyTarget(city.population) * 0.75;
stock.steel = steelNeedPerDay(city.population) * 45;
stock.steel = steelNeedPerDay(city.population) * 45 + OPENING_STEEL_STORE;
stock.luxury = luxuryNeedPerDay(city.population) * 45;
stock.hightech = 500;
stock.hightech = OPENING_HIGHTECH_STORE;
this.resourceStock.set(k, stock);
},
@@ -285,16 +293,10 @@ export const resourceMethods = {
}
claims.push({ civ, spots, byComponent });
}
// Two nations open with a chip foundry -- the only high-tech the opening
// world makes -- so early research is a prize two powers share. The pair is
// drawn from the world seed and never the same nation twice; a nation with
// no land to host one simply goes without.
const fabCount = Math.max(0, this.mapConfig?.startingChipFabs ?? 2);
const fabCandidates = fabCount > 0 ? claims.filter((claim) => claim.spots.length > 0) : [];
rng.shuffle(fabCandidates);
const fabCivs = new Set(fabCandidates.slice(0, fabCount).map((claim) => claim.civ));
// The covering passes below seat every industry a nation is short of,
// high-tech included, so no resource gets a special hand-placed start.
for (const claim of claims) {
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim, fabCivs.has(claim.civ));
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
}
this._gdpPerCapitaCache.clear();
},
@@ -304,7 +306,7 @@ export const resourceMethods = {
// stops as soon as a pass changes nothing. A work off the transport network
// gets a one-tile road spur where it can, so its output can actually reach a
// city.
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }, chipFab = false) {
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
const used = new Set();
let cursor = 0;
const takeSpot = (component) => {
@@ -346,13 +348,10 @@ export const resourceMethods = {
}
return laid;
};
// A chip foundry is laid before the covering passes so the power seeding
// sees its draw and builds the grid needed to run it.
if (chipFab) place(tileImprovementById("chip_foundry"), undefined, 1);
for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
let changed = false;
const deficits = this._resourceSeedDeficits(civ);
for (const id of ["steel", "luxury"]) {
for (const id of ["steel", "luxury", "hightech"]) {
const target = deficits[id] * SEED_MATERIAL_MARGIN;
if (target <= 0) continue;
const proto = this._pickSeedProducer(rng, id);
@@ -387,10 +386,17 @@ export const resourceMethods = {
const population = this.getPlayerPopulation(civ);
const upkeep = this.getCivUpkeepResources(civ);
const made = this._civResourceProduction(civ);
// A nation with no high-tech works of its own is treated as short one chip
// foundry, so every power opens the game with a high-tech industry just as
// it opens with steel mills and mines. Once one stands, the real deficit
// takes over.
const hightechFloor = made.hightech > 0
? 0
: tileImprovementById("chip_foundry").outputPerDay / SEED_MATERIAL_MARGIN;
return {
steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
luxury: luxuryNeedPerDay(population) - made.luxury,
hightech: (upkeep.hightech || 0) - made.hightech,
hightech: (upkeep.hightech || 0) - made.hightech + hightechFloor,
};
},
@@ -573,29 +579,77 @@ export const resourceMethods = {
},
// What one region holds and draws per day, for its city panel: the reserve of
// every storable resource, that reserve's market value, and the private
// sector's daily consumption -- the people's staples, the region's building
// upkeep and the grid power its industry pulls.
// every storable resource, that reserve's market value, the private sector's
// daily consumption, and a per-building breakdown of who demanded what. The
// breakdown is inverted by resource -- `demand[id]` is a list of
// `{ name, coords, amount }` consumers -- so the panel can expand any
// resource into the buildings that drew it.
getRegionResourceInfo(city) {
const economy = this.getCityEconomy(city);
const population = economy.population;
const store = this.getCityResourceStock(city);
const consumption = {
const consumers = [];
const addConsumer = (name, coords, consume) => {
consumers.push({ name, coords, consume });
};
addConsumer("People", city.coords, {
food: foodNeedPerDay(population),
steel: steelNeedPerDay(population),
luxury: luxuryNeedPerDay(population),
hightech: 0,
energy: cityEnergyPerDay(economy.gdp),
};
});
// The region's baseline electric appetite, from its yearly GDP.
const baseline = cityEnergyPerDay(economy.gdp);
if (baseline > 0) addConsumer("Region grid", city.coords, { energy: baseline });
// Every building raised in the city wears out its daily materials.
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (!proto) continue;
const costs = this.buildingUpkeepResources(proto, level);
consumption.steel += costs.steel || 0;
consumption.energy += costs.energy || 0;
consumption.hightech += costs.hightech || 0;
const up = this.buildingUpkeepResources(proto, level);
addConsumer(proto.name, city.coords, {
steel: up.steel || 0,
energy: up.energy || 0,
hightech: up.hightech || 0,
});
}
// Every production building standing in this region draws power and wears
// its own materials, wherever it sits.
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== city.civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue;
const coords = parseKey(k);
if (this.regionCityAt(coords) !== city) continue;
const up = protoUpkeep(proto, proto.buildCost || 0);
const energy = this.resourceBuildingInputAt(k, proto, "energyPerDay") +
this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
addConsumer(proto.name, coords, {
steel: up.steel || 0,
hightech: up.hightech || 0,
energy: energy + (up.energy || 0),
});
}
const consumption = { food: 0, steel: 0, luxury: 0, hightech: 0, energy: 0 };
const demand = {};
for (const id of RESOURCE_IDS) demand[id] = [];
for (const consumer of consumers) {
for (const id of RESOURCE_IDS) {
const amount = consumer.consume[id] || 0;
if (!(amount > 0)) continue;
consumption[id] += amount;
demand[id].push({
name: consumer.name,
coords: [consumer.coords.x, consumer.coords.y],
amount,
});
}
}
const stock = {};
let value = 0;
for (const id of STORABLE_RESOURCE_IDS) {
@@ -603,7 +657,7 @@ export const resourceMethods = {
stock[id] = amount;
value += amount * this.getResourcePrice(id);
}
return { stock, consumption, value };
return { stock, consumption, value, demand };
},
// The region's unmet resource needs over the last day: for each resource the
@@ -2140,66 +2194,69 @@ export const resourceMethods = {
},
// The storage nodes a city can haul from over the transport network: a
// breadth-first search over land and sea, capped at RESOURCE_TRADE_RADIUS
// breadth-first spread over land and sea, capped at RESOURCE_TRADE_RADIUS
// tiles, that hands every reachable node to `accept`. `resource` only names
// the commodity charged for the haul; the transport rules themselves are
// commodity-independent, so the delivery graph passes one to price its rides.
// The traversal itself lives in the economy-graph toolkit; this method only
// describes the network it walks.
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
const results = [];
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y);
const blocked = this._hostileOccupied(city.civ);
const visited = new Set([origin]);
// Every delivery runs over the transport network: railways and roads on
// land, shipping lanes across water (which need a working port).
const queue = [{ coords: city.coords, tiles: 0, energy: 0 }];
while (queue.length > 0) {
const current = queue.shift();
const here = byKey.get(key(current.coords.x, current.coords.y));
if (here && key(here.coords.x, here.coords.y) !== origin) {
const reached = traverse(city.coords, {
id: (coords) => key(coords.x, coords.y),
maxDistance: RESOURCE_TRADE_RADIUS,
neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, resource, blocked),
accept: (coords) => {
const here = byKey.get(key(coords.x, coords.y));
if (!here || key(here.coords.x, here.coords.y) === origin) return false;
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
if (!hostile && accept(here)) {
results.push({
...here,
tiles: current.tiles,
routeEnergyPerTonne: current.energy,
});
}
}
if (current.tiles >= RESOURCE_TRADE_RADIUS) continue;
for (const neighbour of this._neighbours(current.coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
const sea = tile.terrainClass === "Sea";
let mode;
if (sea) {
// A shipping lane needs a working port to load and unload.
if (!allowSea) continue;
mode = "ship";
} else {
// Ground legs must run on a road or railway; there is no off-road
// trucking in the trade network.
if (this.railways.has(nk)) mode = "train";
else if (this.roads.has(nk)) mode = "road";
else continue;
// Goods never cross the land of a nation you are at war with.
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
const stepEnergy = deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1);
visited.add(nk);
queue.push({
coords: neighbour,
tiles: current.tiles + 1,
energy: current.energy + stepEnergy,
});
return !hostile && accept(here);
},
});
return reached.map((entry) => ({
...byKey.get(key(entry.node.x, entry.node.y)),
tiles: entry.distance,
routeEnergyPerTonne: entry.cost,
}));
},
// The passable outward edges of one tile for the trade network: land legs
// must run on a road or railway, sea legs cross open water and need a working
// port, and goods never cross the land of a nation you are at war with. Each
// edge is priced in the transport energy one tonne of `resource` burns over
// it, so the graph is commodity-aware while the traversal is not.
_tradeNeighbours(city, coords, allowSea, resource, blocked) {
const edges = [];
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
const sea = tile.terrainClass === "Sea";
let mode;
if (sea) {
// A shipping lane needs a working port to load and unload.
if (!allowSea) continue;
mode = "ship";
} else {
// Ground legs must run on a road or railway; there is no off-road
// trucking in the trade network.
if (this.railways.has(nk)) mode = "train";
else if (this.roads.has(nk)) mode = "road";
else continue;
// Goods never cross the land of a nation you are at war with.
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
edges.push({
node: neighbour,
cost: deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1),
});
}
results.sort((a, b) => a.routeEnergyPerTonne - b.routeEnergyPerTonne);
return results;
return edges;
},
// Every supplier-to-city route among a nation's own storage nodes, whether or
+2 -26
View File
@@ -11,6 +11,7 @@ import {
resourceById,
} from "./data/resources.js";
import { EFFECT_RESEARCH } from "./data/effects.js";
import { groupDigits } from "./text_format.js";
const DAYS_PER_YEAR = 365;
@@ -44,7 +45,7 @@ export function formatResourceAmount(id, value) {
}
function trim(value) {
if (value >= 100) return String(Math.round(value));
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
@@ -210,31 +211,6 @@ export function producerEnergyPerDay(id, outputPerDay) {
return 0;
}
// ---------------------------------------------------------------- delivery --
// The kWh of transport energy one material tonne costs over `tiles` tiles in the
// given mode. A material tonne and an energy tonne (10 GWh) share the formula.
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
const d = RESOURCE_RULES.deliveryEnergy;
const perTile = mode === "train"
? d.train
: mode === "ship"
? d.ship
: mode === "road"
? d.truckRoad
: d.truckOffRoad * Math.max(tileMovementCost, 0);
return tonnes * Math.max(tiles, 0) * perTile;
}
// The energy cost of delivering `amount` of a resource over `tiles`. Every
// resource carries a mass per canonical unit, so a carat or a chip is charged
// for the tonnes it actually weighs rather than as if it were a tonne.
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
const proto = resourceById(id);
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
}
// ----------------------------------------------------------------- market --
// Moves a price toward what the day's world-wide supply and demand imply. A
+3 -1
View File
@@ -1,6 +1,8 @@
// Small formatting helpers shared by the UI, ported from
// scripts/classes/text_format.gd.
// Groups a number's digits in threes. The separators are non-breaking spaces,
// so a money or resource figure never wraps in the middle of a number.
export function groupDigits(value) {
const truncated = Math.trunc(value);
const digits = String(Math.abs(truncated));
@@ -9,7 +11,7 @@ export function groupDigits(value) {
for (let i = digits.length - 1; i >= 0; i--) {
grouped = digits[i] + grouped;
count += 1;
if (count % 3 === 0 && i > 0) grouped = " " + grouped;
if (count % 3 === 0 && i > 0) grouped = "\u00a0" + grouped;
}
return (truncated < 0 ? "-" : "") + grouped;
}