Files
Battle-for-Tismo/tests/industry_test.js
adrien 80429918c6 Made producers pay their own way and settled the world to steady prices
- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.
- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.
- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.
- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).
- The shipped month report carries its label again, so the budget header and pager name the month.
2026-09-23 21:45:28 +02:00

328 lines
14 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
RESOURCE_BUILDING_UPGRADE,
resourceBuildingOutputAt,
resourceBuildingInputAt,
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
} from "../shared/data.js";
import { protoUpkeep } from "../shared/resources.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
// A production building of `id` on the first land tile of `civ`'s that is not a
// city, wired into the agent maps and with an empty stock. The tile may already
// carry a work; it is replaced.
function placeProducer(state, id, civ = 0) {
let coords = null;
for (const candidate of state._territoryByCiv.get(civ) || []) {
if (state.cityAt(candidate)) continue;
if (!state._isLand(candidate)) continue;
if (state.tileImprovements.has(key(candidate.x, candidate.y))) continue;
coords = candidate;
break;
}
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);
const agent = state._ensureBuildingAgent(k);
return { coords, k, agent };
}
// Gives the nation's cities enough stores to cover an upgrade's steel bill.
function fillMaterialStores(state, civ, amount = 1e7) {
for (const city of state.cities) {
if (city.civ !== civ) continue;
state.getCityResourceStock(city).steel = amount;
state.getCityResourceStock(city).hightech = amount;
}
}
export class ProductionBuildingUpgradeMathTest extends TestCase {
test_output_compounds_exponentially_with_the_level() {
const proto = { outputPerDay: 1000 };
const alpha = RESOURCE_BUILDING_UPGRADE.alpha;
this.assertApprox(resourceBuildingOutputAt(proto, 0), 1000);
this.assertApprox(resourceBuildingOutputAt(proto, 1), 1000 * alpha);
this.assertApprox(resourceBuildingOutputAt(proto, 2), 1000 * alpha * alpha);
}
test_input_grows_linearly_with_the_level() {
const proto = { energyPerDay: 500 };
this.assertApprox(resourceBuildingInputAt(proto, 0, "energyPerDay"), 500);
this.assertApprox(resourceBuildingInputAt(proto, 2, "energyPerDay"), 1500);
this.assertApprox(
resourceBuildingInputAt(proto, 1, "energyPerDay"),
500 * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel)
);
}
test_each_upgrade_cost_more_than_the_last() {
const proto = { buildCost: 800_000_000 };
const first = resourceBuildingUpgradeMoneyCost(proto, 0);
const second = resourceBuildingUpgradeMoneyCost(proto, 1);
this.assertApprox(first, 800_000_000);
this.assertGreater(second, first);
this.assertApprox(second, 800_000_000 * RESOURCE_BUILDING_UPGRADE.costAlpha);
}
}
export class ProductionBuildingAgentTest extends TestCase {
test_a_production_building_keeps_what_it_sells() {
const state = smallState();
const { coords, k, agent } = placeProducer(state, "steel_mill");
const code = state.currencyOf(0).code;
const city = cityOf(state, 0);
const regionBefore = state.getRegionCashValue(city);
state._paySupplier({ kind: "building", coords, civ: 0 }, code, 12_345);
this.assertApprox(agent.cash, 12_345, 1e-6, "the sale reached the building");
this.assertApprox(state.getRegionCashValue(city), regionBefore, 1e-6, "not the region");
this.assertEqual(state.getPrivateBuildingCash(0), agent.cash);
}
test_plants_trade_their_power_like_any_producer() {
const state = smallState();
const plant = tileImprovementById("coal_power_plant");
const mill = tileImprovementById("steel_mill");
this.assertTrue(state._isProductionAgent(plant), "a plant sells power");
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
}
test_a_producer_borrows_what_its_own_wear_cannot_cover() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const value = costs.steel * state._marketUnitCost("steel") +
costs.hightech * state._marketUnitCost("hightech");
// Half a day's wear in the account: the rest becomes a central-bank loan.
agent.cash = value / 2;
const categories = {
works: { steel: costs.steel, hightech: costs.hightech, sources: [] },
};
state._applyWorksMaintenance(0, categories);
this.assertApprox(agent.cash, 0, 1e-6, "the cash was spent first");
this.assertApprox(agent.debt, value / 2, 1e-6, "the rest is a loan");
this.assertApprox(categories.works.steel, 0, 1e-6, "the state is not billed for a producer");
this.assertApprox(categories.works.hightech, 0, 1e-6, "nor for its high-tech");
}
test_a_producers_loan_compounds_and_is_repaid_from_its_cash() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.debt = 1_000;
const rate = state.getCentralBankRate(0) / DAYS_PER_YEAR;
// Exactly one day's interest plus the principal, so repayment clears it.
agent.cash = 1_000 * (1 + rate);
state._tickMoney();
this.assertApprox(agent.debt, 0, 1e-6, "the loan is repaid");
this.assertApprox(agent.cash, 0, 1e-6, "the cash it held paid it down");
}
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.sales = [10, 10, 10];
this.assertFalse(state._buildingDemandGrowing(agent), "flat demand is not growth");
agent.sales = [20, 20, 20, 30, 30, 30];
this.assertTrue(state._buildingDemandGrowing(agent), "rising demand is growth");
agent.sales = [30, 30, 30, 20, 20, 20];
this.assertFalse(state._buildingDemandGrowing(agent), "falling demand is not growth");
}
test_a_building_starts_an_upgrade_and_cannot_start_a_second() {
const state = smallState();
const { coords, k, agent } = placeProducer(state, "steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
state._tickBuildingDecisions();
this.assertNotNull(state.constructionSiteAt(coords), "the upgrade started");
const job = state.constructionSiteAt(coords);
this.assertEqual(job.kind, "upgrade");
this.assertEqual(job.targetLevel, 1);
this.assertEqual(agent.level, 0, "the level waits for the work");
// A second read may not replace the work already under way.
agent.sales = [100, 200, 300, 400, 500, 600];
state._tickBuildingDecisions();
this.assertEqual(state.constructionSiteAt(coords), job, "only one upgrade at a time");
this.assertEqual(state.tileImprovements.get(k), "steel_mill", "the building still stands");
}
test_spending_on_an_upgrade_consumes_cash_and_materials() {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
const before = agent.cash;
state._tickBuildingDecisions();
const site = state.constructionSiteAt(coords);
this.assertNotNull(site, "the agent opened a site");
this.assertLess(agent.cash, before, "the agent handed its cash to the site");
this.assertLess(site.budget, before, "the site spent some of it on materials");
this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over");
}
test_completing_an_upgrade_raises_the_level_and_its_output() {
const state = smallState();
const { coords, k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
const baseOutput = state.resourceBuildingOutputAt(k, proto);
// Keep the day-boundary decision from firing while the work is completed.
state.totalHours = 1;
state._tickBuildingDecisions();
const site = state.constructionSiteAt(coords);
this.assertNotNull(site);
site.phase = "construction";
site.elapsedHours = 0;
site.totalHours = 1;
state.advanceHour();
this.assertEqual(agent.level, 1, "the level arrived");
this.assertNull(state.constructionSiteAt(coords), "the work is done");
this.assertGreater(
state.resourceBuildingOutputAt(k, proto), baseOutput,
"the upgraded building makes more"
);
}
test_an_agent_without_enough_cash_waits() {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 0;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
state._tickBuildingDecisions();
this.assertNull(agent.upgrading, "no cash, no upgrade");
this.assertNull(state.constructionSiteAt(coords), "and no site");
}
test_the_snapshot_ships_the_production_agents_apart_from_the_regions() {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 4_000_000;
agent.debt = 250_000;
const stats = state.snapshot(0).viewerStats;
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
this.assertApprox(stats.privateBuildingDebt, 250_000, 1e-6, "the loan is shipped too");
const entry = stats.productionBuildings.find(
(item) => item.coords[0] === coords.x && item.coords[1] === coords.y
);
this.assertNotNull(entry, "the building is listed on its own row");
this.assertEqual(entry.level, 0);
this.assertApprox(entry.cash, 4_000_000, 1e-6);
this.assertApprox(entry.debt, 250_000, 1e-6);
}
}
export class PowerMarketTest extends TestCase {
test_a_grid_pays_its_plants_for_the_power_it_delivers() {
const state = smallState();
const plant = placeProducer(state, "coal_power_plant");
const converter = placeProducer(state, "steel_mill");
const city = cityOf(state, 0);
const plantBefore = plant.agent.cash;
const converterDebtBefore = converter.agent.debt;
const regionBefore = state.getRegionCashValue(city);
const price = state.getResourcePrice("energy");
const ledger = new Map([[0, {
civ: 0,
supply: 1000,
cityEnergy: 100,
converterEnergy: 200,
foodEnergy: 0,
billableCityEnergy: 100,
cityShortage: 0,
industrialScale: 1,
producers: [{ k: plant.k, output: 1000 }],
cities: [{ city, amount: 100 }],
converters: [{ k: converter.k, amount: 200 }],
foodConsumers: [],
imports: 0,
importCost: 0,
}]]);
state._settleGridPower(ledger);
this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid");
this.assertApprox(converter.agent.debt - converterDebtBefore, 200 * price, 1e-6,
"the converter borrows to pay");
this.assertApprox(converter.agent.cash, 0, 1e-6, "and its cash stays out of the red");
this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6,
"the region pays");
this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend");
}
test_imported_power_is_paid_to_the_exporting_grid_s_plants() {
const state = smallState();
const plant = placeProducer(state, "coal_power_plant");
const component = { civ: 0, producers: [{ k: plant.k, output: 100 }] };
const before = plant.agent.cash;
state._payGridProducers(component, 5_000, 40);
this.assertApprox(plant.agent.cash - before, 5_000, 1e-6);
this.assertApprox(plant.agent.salesToday, 40, 1e-6);
}
test_an_overstocked_converter_throttles_to_what_it_sells() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
this.assertEqual(
state._converterProductionScale(k, proto, agent), 1,
"a building with no history runs flat out"
);
agent.sales = [proto.outputPerDay * 0.2, proto.outputPerDay * 0.2,
proto.outputPerDay * 0.2, proto.outputPerDay * 0.2];
state._ensureResourceStock(k).steel = 1e9;
const scale = state._converterProductionScale(k, proto, agent);
this.assertLess(scale, 1, "a full store eases off");
this.assertApprox(scale, 0.2, 1e-3);
}
test_a_converter_sells_out_and_then_grows() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
agent.sales = new Array(6).fill(proto.outputPerDay);
this.assertTrue(
state._buildingDemandGrowing(agent, proto.outputPerDay),
"selling out is a reason to grow"
);
}
test_a_converter_idles_when_its_output_sells_below_the_power_it_burns() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
state.setResourcePrice("steel", 1);
this.assertEqual(
state._converterProductionScale(k, proto, agent), 0,
"a loss-making mill switches off"
);
state.setResourcePrice("steel", 1e12);
this.assertGreater(
state._converterProductionScale(k, proto, agent), 0,
"and runs again once its steel is worth more than its power"
);
}
test_output_follows_sustained_sales_not_nameplate_capacity() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
const capacity = resourceBuildingOutputAt(proto, 0);
// Selling a third of what it can make, with a little over two days of that
// demand in store: the mill makes what it sells, not its nameplate output.
agent.sales = new Array(6).fill(capacity * (1 / 3));
state._ensureResourceStock(k).steel = capacity * 0.8;
this.assertApprox(
state._converterProductionScale(k, proto, agent), 1 / 3, 1e-6,
"output tracks sustained demand"
);
}
}