Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
242 lines
9.3 KiB
JavaScript
242 lines
9.3 KiB
JavaScript
// Headless resource-economy simulation, used to tune the costs in
|
|
// shared/data/resources.js. It boots a game, optionally raises a fixed set of
|
|
// resource buildings for every nation, runs a number of in-game days and prints
|
|
// how the economy settles: what each nation spends on power and goods, whether
|
|
// its people are fed, and how each producer's build cost pays back at market
|
|
// prices.
|
|
//
|
|
// node scripts/simulate_resources.js
|
|
// node scripts/simulate_resources.js --days 720 --civs 2 --scenario infra
|
|
// node scripts/simulate_resources.js --map full --days 365 --civs 6
|
|
|
|
import { GameState } from "../shared/game_state.js";
|
|
import { CIVILISATIONS, MAP_CONFIG } from "../shared/data.js";
|
|
import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
|
|
import {
|
|
cityEnergyPerDay,
|
|
} from "../shared/resources.js";
|
|
import { key } from "../shared/hex.js";
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const DAYS = Number(args.days || 360);
|
|
const CIVS = Number(args.civs || 2);
|
|
const SCENARIO = args.scenario || "baseline";
|
|
const MAP = args.map || "medium";
|
|
const seed = Number(args.seed || 12345);
|
|
|
|
const state = new GameState();
|
|
state.mapConfig = mapConfig(MAP);
|
|
const civs = CIVILISATIONS.slice(0, Math.max(1, Math.min(CIVS, CIVILISATIONS.length)));
|
|
state.configure(civs, seed);
|
|
|
|
if (SCENARIO === "infra") seedInfrastructure(state);
|
|
if (SCENARIO === "developed") seedBuildings(state);
|
|
|
|
const startBudget = civs.map((_, civ) => state.getBudget(civ));
|
|
const spendTotals = civs.map(() => ({ energy: 0, goods: 0, transport: 0, construction: 0, upkeep: 0, combat: 0 }));
|
|
|
|
for (let day = 0; day < DAYS; day++) {
|
|
for (let hour = 0; hour < 24; hour++) state.advanceHour();
|
|
for (let civ = 0; civ < civs.length; civ++) {
|
|
const record = state.resourceSpend.get(civ) || {};
|
|
for (const kind of Object.keys(spendTotals[civ])) {
|
|
spendTotals[civ][kind] += record[kind] || 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseArgs(list) {
|
|
const out = {};
|
|
for (let i = 0; i < list.length; i++) {
|
|
const item = list[i];
|
|
if (!item.startsWith("--")) continue;
|
|
const name = item.slice(2);
|
|
const next = list[i + 1];
|
|
if (next === undefined || next.startsWith("--")) out[name] = true;
|
|
else {
|
|
out[name] = next;
|
|
i += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mapConfig(kind) {
|
|
if (kind === "full") return MAP_CONFIG;
|
|
return {
|
|
...MAP_CONFIG,
|
|
mapSize: { x: 48, y: 40 },
|
|
minCapitalLandmass: 8,
|
|
minCapitalDistance: 4,
|
|
citiesPerCiv: 4,
|
|
citySpacing: 3,
|
|
minContinents: 2,
|
|
smallIslandCount: 2,
|
|
};
|
|
}
|
|
|
|
// A representative national grid: one coal plant per city, one steel mill, one
|
|
// chip foundry and a gold mine, all dropped on free land so the steady state
|
|
// can be read without waiting for construction.
|
|
function seedInfrastructure(state) {
|
|
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
|
const plan = [
|
|
"coal_power_plant",
|
|
"coal_power_plant",
|
|
"coal_power_plant",
|
|
"coal_power_plant",
|
|
"coal_power_plant",
|
|
"steel_mill",
|
|
"chip_foundry",
|
|
"gold_mine",
|
|
];
|
|
const tiles = (state._territoryByCiv.get(civ) || []).filter(
|
|
(coords) => state._isLand(coords) && !state.cityAt(coords)
|
|
);
|
|
let used = 0;
|
|
for (const id of plan) {
|
|
if (used >= tiles.length) break;
|
|
const coords = tiles[used++];
|
|
const k = key(coords.x, coords.y);
|
|
state.tileImprovements.set(k, id);
|
|
state.tileImprovementOwner.set(k, civ);
|
|
state.tileImprovementHp.set(k, 1000);
|
|
state._ensureResourceStock(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A mature nation: every city carries a believable basket of buildings, so the
|
|
// material upkeep (and repair bill) can be read.
|
|
function seedBuildings(state) {
|
|
const basket = {
|
|
market: 3, bank: 2, barracks: 2, shelters: 1, aa_building: 1,
|
|
research_lab: 2, university: 1, museum: 1, cinema_studio: 1, airport: 1,
|
|
};
|
|
for (const city of state.cities) {
|
|
for (const [id, level] of Object.entries(basket)) {
|
|
const index = state.protoBuildings.findIndex((b) => b.id === id);
|
|
if (index < 0) continue;
|
|
if (state.protoBuildings[index].coastal && !state.isCoastalCity(city)) continue;
|
|
city.buildings[index] = level;
|
|
}
|
|
}
|
|
state._gdpPerCapitaCache.clear();
|
|
state._touchModifiers();
|
|
}
|
|
|
|
function eur(value) {
|
|
const abs = Math.abs(value);
|
|
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
|
if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
|
if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}k`;
|
|
return value.toFixed(0);
|
|
}
|
|
|
|
function num(value) {
|
|
const abs = Math.abs(value);
|
|
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}e9`;
|
|
if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}e6`;
|
|
if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}e3`;
|
|
return value.toFixed(1);
|
|
}
|
|
|
|
function pad(text, width, right = false) {
|
|
const value = String(text);
|
|
return right ? value.padStart(width) : value.padEnd(width);
|
|
}
|
|
|
|
console.log(`\n=== Resource production costs (base market prices, ${DAYS} days) ===`);
|
|
console.log(
|
|
pad("building", 26) + pad("out/day", 12, true) + pad("energy/day", 13, true) +
|
|
pad("energy €/d", 11, true) + pad("upkeep €/d", 11, true) + pad("gross €/d", 11, true) +
|
|
pad("net €/d", 11, true) + pad("payback d", 10, true)
|
|
);
|
|
for (const building of RESOURCE_BUILDINGS) {
|
|
const energyPrice = RESOURCE_MARKET_BASE.energy;
|
|
const outputValue = building.resource === "energy"
|
|
? building.outputPerDay * RESOURCE_MARKET_BASE.energy
|
|
: building.outputPerDay * (RESOURCE_MARKET_BASE[building.resource] || 0);
|
|
const energyCost = (building.energyPerDay || 0) * energyPrice;
|
|
const upkeep = building.maintenance * 24;
|
|
const net = outputValue - energyCost - upkeep;
|
|
const payback = net > 0 ? building.buildCost / net : Infinity;
|
|
console.log(
|
|
pad(building.id, 26) + pad(num(building.outputPerDay), 12, true) +
|
|
pad(num(building.energyPerDay || 0), 13, true) + pad(eur(energyCost), 11, true) +
|
|
pad(eur(upkeep), 11, true) + pad(eur(outputValue), 11, true) +
|
|
pad(eur(net), 11, true) + pad(Number.isFinite(payback) ? payback.toFixed(0) : "never", 10, true)
|
|
);
|
|
}
|
|
|
|
console.log(`\n=== Settled economy after ${DAYS} days (scenario: ${SCENARIO}, map: ${MAP}) ===`);
|
|
for (let civ = 0; civ < civs.length; civ++) {
|
|
const population = state.getPlayerPopulation(civ);
|
|
const gdp = state.getPlayerGdp(civ);
|
|
const income = state.getCivHourlyIncome(civ) * 24;
|
|
const moneyUpkeep = state.getPlayerUpkeep(civ) * 24;
|
|
const upkeepDemand = state.getCivUpkeepResources(civ);
|
|
const spend = spendTotals[civ];
|
|
let energyNeed = 0;
|
|
let energySupply = 0;
|
|
for (const city of state.cities) {
|
|
if (city.civ !== civ) continue;
|
|
energyNeed += cityEnergyPerDay(state.getCityEconomy(city).gdp);
|
|
}
|
|
for (const [k, id] of state.tileImprovements) {
|
|
if (state.tileImprovementOwner.get(k) !== civ) continue;
|
|
const proto = RESOURCE_BUILDINGS.find((b) => b.id === id);
|
|
if (proto && proto.resource === "energy") energySupply += proto.outputPerDay;
|
|
}
|
|
const stock = state.getCivResourceSummary(civ);
|
|
console.log(
|
|
`\n-- ${civs[civ].name} --`
|
|
);
|
|
console.log(
|
|
`pop ${num(population)} gdp ${eur(gdp)} gdp/cap ${eur(population ? gdp / population : 0)}` +
|
|
` budget ${eur(state.getBudget(civ))} (start ${eur(startBudget[civ])})`
|
|
);
|
|
console.log(
|
|
`income/d ${eur(income)} campaign upkeep/d ${eur(moneyUpkeep)} net/d ${eur(income - moneyUpkeep)}`
|
|
);
|
|
console.log(
|
|
`material upkeep/d: steel ${num(upkeepDemand.steel)} t energy ${num(upkeepDemand.energy)} kWh` +
|
|
` high-tech ${num(upkeepDemand.hightech)} u`
|
|
);
|
|
console.log(
|
|
`resource spend over run: energy ${eur(spend.energy)} goods ${eur(spend.goods)}` +
|
|
` transport ${eur(spend.transport)} construction ${eur(spend.construction)}` +
|
|
` upkeep ${eur(spend.upkeep)} combat ${eur(spend.combat)}`
|
|
);
|
|
console.log(
|
|
`energy need/d ${num(energyNeed)} kWh own supply/d ${num(energySupply)} kWh` +
|
|
` bought-ish/d ${num((spend.energy / Math.max(DAYS, 1)) / Math.max(state.getResourcePrice("energy"), 1e-9))} kWh`
|
|
);
|
|
for (const id of ["food", "steel", "luxury", "hightech"]) {
|
|
console.log(
|
|
` ${pad(id, 9)} stock ${num(stock.stock[id])} need/d ${num(stock.need[id] || 0)}` +
|
|
` produced/d ${num(stock.production[id] || 0)}`
|
|
);
|
|
}
|
|
let shortage = 0;
|
|
for (const city of state.cities) {
|
|
if (city.civ !== civ) continue;
|
|
const info = state.getCityResourceInfo(city);
|
|
for (const value of Object.values(info.shortage)) shortage += value;
|
|
}
|
|
console.log(` summed city shortage fraction: ${shortage.toFixed(3)}`);
|
|
}
|
|
|
|
console.log("\n=== Market prices at the end ===");
|
|
for (const id of Object.keys(RESOURCE_MARKET_BASE)) {
|
|
const stats = state.resourceMarketStats.get(id) || {};
|
|
console.log(
|
|
` ${pad(id, 9)} price ${state.getResourcePrice(id).toFixed(4)}` +
|
|
` (base ${RESOURCE_MARKET_BASE[id]}) supply ${num(stats.supply || 0)} demand ${num(stats.demand || 0)}`
|
|
);
|
|
}
|
|
console.log(`\nRules: food ${RESOURCE_RULES.foodPerPersonPerDay} t/pp/d, steel ${RESOURCE_RULES.steelPerPersonPerDay} t/pp/d, luxury ${RESOURCE_RULES.luxuryPerPersonPerDay} ct/pp/d`);
|
|
console.log(`energy intensity ${RESOURCE_RULES.gdpEnergyIntensity} kWh/€/yr, steel ${RESOURCE_RULES.energyPerSteelTonne / 1e6} GWh/t, food ${RESOURCE_RULES.energyPerFoodTonne / 1e6} GWh/t\n`);
|