Files
Battle-for-Tismo/tests/resources_test.js
T
adrien 91dfedf7ec Gave regions their own economies and removed the global market
- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.
- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.
- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.
- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.
- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.
- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
2026-09-22 06:41:58 +02:00

907 lines
37 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, grantBuilding } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_BUILDINGS,
EFFECT_RESEARCH,
technologyCost,
} from "../shared/data.js";
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
import {
formatEnergy,
formatResourceAmount,
foodNeedPerDay,
steelNeedPerDay,
luxuryNeedPerDay,
foodMonthlyTarget,
cityEnergyPerDay,
deliveryEnergyKwh,
deliveryEnergyForResource,
nextMarketPrice,
} from "../shared/resources.js";
function freeLandTile(state, civ = 0) {
for (const coords of state._territoryByCiv.get(civ) || []) {
if (state.cityAt(coords)) continue;
if (state.tileImprovements.has(key(coords.x, coords.y))) continue;
if (state._isLand(coords)) return coords;
}
return null;
}
function placeResourceBuilding(state, coords, id, civ = 0) {
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);
return k;
}
export class ResourceRulesTest extends TestCase {
test_energy_formats_in_friendly_units() {
this.assertEqual(formatEnergy(500), "500 kWh");
this.assertTrue(formatEnergy(1_500).includes("MWh"));
this.assertTrue(formatEnergy(2_500_000).includes("GWh"));
this.assertTrue(formatEnergy(3_000_000_000).includes("TWh"));
}
test_material_units_step_up() {
this.assertEqual(formatResourceAmount("steel", 250), "250 t");
this.assertTrue(formatResourceAmount("steel", 2_500_000).includes("kt"));
this.assertTrue(formatResourceAmount("steel", 2_500_000_000).includes("Mt"));
this.assertTrue(formatResourceAmount("luxury", 2500).includes("ct"));
}
test_per_person_needs_follow_the_rules() {
// 1 kg a day of food and steel, 0.01 ct of luxury, for 2 million people.
this.assertApprox(foodNeedPerDay(2_000_000), 2000);
this.assertApprox(steelNeedPerDay(2_000_000), 2000);
this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000);
}
test_food_target_is_a_month_with_a_cushion() {
const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer;
this.assertApprox(foodMonthlyTarget(2_000_000), need);
}
test_city_energy_scales_with_gdp() {
this.assertApprox(cityEnergyPerDay(0), 0);
this.assertGreater(cityEnergyPerDay(1e11), 0);
}
test_delivery_energy_matches_the_roadmap_figures() {
// 1 tonne, 1 tile: 10 MWh by road, 1 MWh by train, 50 kWh by ship.
this.assertApprox(deliveryEnergyKwh(1, 1, "road"), 10_000);
this.assertApprox(deliveryEnergyKwh(1, 1, "train"), 1_000);
this.assertApprox(deliveryEnergyKwh(1, 1, "ship"), 50);
// Off-road trucks burn 50 MWh a tile, times the tile's movement cost.
this.assertApprox(deliveryEnergyKwh(1, 1, "truck", 4), 200_000);
}
test_energy_delivery_counts_ten_gwh_per_tonne() {
// Delivering 10 GWh of energy is one tonne, so its transport is the same
// as moving one tonne of steel the same distance.
const asEnergy = deliveryEnergyForResource("energy", 10_000_000, 1, "road");
const asSteel = deliveryEnergyForResource("steel", 1, 1, "road");
this.assertApprox(asEnergy, asSteel);
}
test_market_price_rises_on_shortage_and_falls_on_glut() {
const base = RESOURCE_MARKET_BASE.steel;
const scarce = nextMarketPrice(base, base, 10, 20);
const glut = nextMarketPrice(base, base, 20, 10);
this.assertGreater(scarce, base);
this.assertLess(glut, base);
}
}
export class ResourceModelTest extends TestCase {
test_cities_start_with_a_food_store() {
const state = smallState();
for (const city of state.cities) {
const stock = state.getCityResourceStock(city);
this.assertGreater(stock.food, 0, "the city opens with food");
this.assertGreater(stock.steel, 0, "the city opens with steel");
}
}
test_construction_resource_cost_follows_the_money_cost() {
const state = smallState();
const cost = state.constructionResourceCost({ buildCost: 1_000_000, effects: [] });
this.assertApprox(cost.steel, 1_000_000 * RESOURCE_RULES.steelPerBudgetEuro);
this.assertEqual(cost.hightech, 0);
const lab = state.protoBuildings.find((b) => b.id === "research_lab");
this.assertGreater(state.constructionResourceCost(lab, 0, lab.baseCost).hightech, 0);
}
test_resource_money_tracks_buying_and_selling() {
const state = smallState();
state._recordResourceSpend(0, "goods", 1000, "steel");
state._recordResourceEarn(0, "steel", 400);
const breakdown = state.budgetBreakdown(0);
const steel = breakdown.resourceMoney.find((entry) => entry.id === "steel");
this.assertNotNull(steel, "steel appears in the trade breakdown");
this.assertApprox(steel.bought, 1000);
this.assertApprox(steel.sold, 400);
}
test_external_trade_ledger_counts_buying_and_selling() {
const state = smallState();
state._recordExternalBuy(0, "steel", 100, 1_000);
state._recordExternalSell(0, "food", 40, 500);
const summary = state.getCivResourceSummary(0);
this.assertApprox(summary.external.buy.steel.qty, 100);
this.assertApprox(summary.external.buy.steel.value, 1_000);
this.assertApprox(summary.external.sell.food.qty, 40);
this.assertApprox(summary.external.sell.food.value, 500);
}
test_the_day_s_shortfalls_are_bought_abroad() {
const state = smallState();
state.budgets.set(0, 1.0e15);
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.food = 0;
stock.steel = 0;
stock.luxury = 0;
}
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
const summary = state.getCivResourceSummary(0);
const bought = Object.values(summary.external.buy)
.reduce((sum, entry) => sum + entry.qty, 0);
this.assertGreater(bought, 0, "the day's shortfalls were bought across the border");
const spent = Object.values(summary.external.buy)
.reduce((sum, entry) => sum + entry.value, 0);
this.assertGreater(spent, 0, "and the money it cost is recorded");
}
test_a_region_reports_what_it_ran_short_of() {
const state = smallState();
const city = cityOf(state, 0);
state._resourceShortages.set(city.id, { steel: 0.5 });
const shortages = state.getRegionShortages(city);
this.assertTrue(shortages.steel, "steel is reported");
this.assertApprox(shortages.steel.fraction, 0.5);
const population = state.getCityEconomy(city).population;
this.assertApprox(shortages.steel.amount, 0.5 * steelNeedPerDay(population));
}
test_the_snapshot_ships_each_region_s_shortages() {
const state = smallState();
const city = cityOf(state, 0);
state._resourceShortages.set(city.id, { food: 0.25 });
const snapshot = state.snapshot(0);
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
this.assertTrue(stats.shortage.food, "the shortage is shipped");
this.assertApprox(stats.shortage.food.fraction, 0.25);
}
test_a_research_building_without_high_tech_raises_a_notice() {
const state = smallState();
const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab");
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
}
state._tickBuildings();
const alert = state._resourceAlertsFor(0)
.find((entry) => entry.resource === "hightech" && entry.region === city.name);
this.assertNotNull(alert, "the lab's missing high-tech is reported");
this.assertTrue(alert.building.includes("Research lab"), "the building is named");
}
test_a_power_starved_work_raises_a_notice() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const tile = freeLandTile(state, 0);
const k = placeResourceBuilding(state, tile, "steel_mill", 0);
const components = new Map([
[k, 0],
[key(city.coords.x, city.coords.y), 0],
]);
const ledger = new Map([[0, { civ: 0, industrialScale: 0.5 }]]);
state._noteGridPowerShortages(ledger, components);
const alert = state._resourceAlertsFor(0)
.find((entry) => entry.building === "Steel mill" && entry.resource === "energy");
this.assertNotNull(alert, "the idle work is reported");
this.assertEqual(alert.region, city.name);
}
test_region_resource_info_reports_reserves_value_and_consumption() {
const state = smallState();
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
store.food = 1000;
store.steel = 2000;
store.hightech = 50;
store.luxury = 10;
const info = state.getRegionResourceInfo(city);
this.assertEqual(info.stock.food, 1000);
this.assertEqual(info.stock.steel, 2000);
const expected = 1000 * state.getResourcePrice("food") +
2000 * state.getResourcePrice("steel") +
50 * state.getResourcePrice("hightech") +
10 * state.getResourcePrice("luxury");
this.assertApprox(info.value, expected);
const population = state.getCityEconomy(city).population;
this.assertApprox(info.consumption.food, foodNeedPerDay(population));
this.assertApprox(info.consumption.steel, steelNeedPerDay(population));
this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population));
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
}
test_city_stats_ship_the_region_resources() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 42;
const stats = state.snapshot(0).cityStats.find((entry) => entry.id === city.id);
this.assertNotNull(stats.resources, "the region's reserves travel in the snapshot");
this.assertEqual(stats.resources.stock.steel, 42);
this.assertTrue(stats.resources.value > 0, "the reserve carries a value");
}
test_shortage_notices_are_private_to_the_owner() {
const state = smallState();
state._noteResourceShortage(0, "University", "Paris", "hightech");
const mine = state.snapshot(0).viewerStats.resourceAlerts;
const theirs = state.snapshot(1).viewerStats.resourceAlerts;
this.assertSize(mine, 1, "the owner sees the notice");
this.assertSize(theirs, 0, "another nation does not");
this.assertEqual(mine[0].building, "University");
this.assertEqual(mine[0].resource, "hightech");
this.assertGreaterOrEqual(mine[0].day, 0);
}
test_national_gdp_per_capita_matches_the_regions() {
const state = smallState();
const snapshot = state.snapshot(0);
let gdp = 0;
let population = 0;
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
gdp += stats.gdp;
population += stats.population;
}
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
}
test_construction_draws_steel_from_the_city_before_the_market() {
const state = smallState();
const city = cityOf(state, 0);
const code = state.currencyOf(0).code;
const stock = state.getCityResourceStock(city);
stock.steel = 1000;
const cashBefore = state.getRegionCash(city).get(code) || 0;
const before = state.getBudget(0);
const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 });
this.assertTrue(ok);
this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store");
// The region is not charged: the state buys the steel from it.
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
this.assertApprox(
(state.getRegionCash(city).get(code) || 0) - cashBefore,
value,
1e-3,
"the region received the money"
);
}
test_drawing_a_national_store_pays_the_region() {
const state = smallState();
const city = cityOf(state, 0);
const code = state.currencyOf(0).code;
state.getCityResourceStock(city).steel = 1000;
const cashBefore = state.getRegionCash(city).get(code) || 0;
const before = state.getBudget(0);
const payer = state._treasuryPayer(0);
const drawn = state._drawFromNearest(
0, city.coords, { steel: 400, hightech: 0 }, payer
);
this.assertApprox(drawn.steel, 400);
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
this.assertApprox(payer.total, value, 1e-3, "the treasury carried the bill");
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
this.assertApprox(
(state.getRegionCash(city).get(code) || 0) - cashBefore,
value,
1e-3,
"the region's private sector received it"
);
}
test_construction_buys_the_shortfall_from_a_reachable_region() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const seller = cityOf(state, 1);
// Every city of the nation must be empty, or the draw would find their
// stores before it reaches a foreign region.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
}
state.getCityResourceStock(seller).steel = 1.0e9;
const before = state.getBudget(0);
const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 });
this.assertTrue(ok, "the steel was found across the border");
this.assertLess(state.getBudget(0), before, "the treasury paid for it");
this.assertLess(state.getCityResourceStock(seller).steel, 1.0e9, "the seller's store shrank");
}
test_research_spends_high_tech() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab");
const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
this.assertTrue(state.requestFocusTechnology(0, tech));
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
// With a store of high-tech the lab's one point an hour comes through.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).hightech = 1000;
}
const before = state.getTechnologyProgress(0, tech);
state.advanceHour();
const withTech = state.getTechnologyProgress(0, tech) - before;
this.assertApprox(withTech, governmentRate + 1.0, 1e-6);
// With no store, the lab's point is simply not produced.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
}
const before2 = state.getTechnologyProgress(0, tech);
state.advanceHour();
const withoutTech = state.getTechnologyProgress(0, tech) - before2;
this.assertApprox(withoutTech, governmentRate, 1e-6);
}
test_a_power_plant_supplies_its_grid() {
const withPlant = smallState();
const tile = freeLandTile(withPlant, 0);
placeResourceBuilding(withPlant, tile, "coal_power_plant", 0);
withPlant._tickResources();
const stats = withPlant.resourceMarketStats.get("energy");
this.assertGreater(stats.supply, 0, "the plant fed the grid");
}
test_gdp_is_the_market_value_of_what_a_region_produces() {
const state = smallState();
const tile = freeLandTile(state, 0);
const region = state.regionCityAt(tile);
this.assertNotNull(region, "the tile belongs to a region");
const before = state._regionProductionValue(region);
placeResourceBuilding(state, tile, "steel_mill", 0);
const after = state._regionProductionValue(region);
const proto = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
const outputValue = proto.outputPerDay * state.getResourcePrice("steel") * DAYS_PER_YEAR;
this.assertApprox(after - before, outputValue, outputValue * 1e-6, "the mill's output is GDP");
}
test_a_steel_mill_consumes_energy_and_makes_steel() {
const state = smallState();
const plantTile = freeLandTile(state, 0);
placeResourceBuilding(state, plantTile, "coal_power_plant", 0);
// A second owned tile for the mill, away from the city.
const millTile = state._territoryByCiv.get(0).find(
(coords) => state._isLand(coords) && !state.cityAt(coords) &&
key(coords.x, coords.y) !== key(plantTile.x, plantTile.y)
);
const k = placeResourceBuilding(state, millTile, "steel_mill", 0);
state._tickResources();
const stock = state._ensureResourceStock(k);
this.assertGreater(stock.steel, 0, "the mill produced steel");
}
test_a_city_buys_from_a_neighbour_before_the_market() {
const state = smallState();
const cities = state.cities.filter((c) => c.civ === 0);
this.assertGreaterOrEqual(cities.length, 2, "the fixture has two cities");
const buyer = cities[0];
const seller = cities[1];
state.getCityResourceStock(seller).food = 1e9;
state.getCityResourceStock(buyer).food = 0;
const store = state.getCityResourceStock(buyer);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, store, state._resourceNodes()
);
this.assertGreater(bought, 0, "the neighbour sold food");
this.assertGreater(store.food, 0, "the buyer received it");
this.assertTrue(
state.resourceLinks.some((link) => link.resource === "food"),
"the delivery is on the graph"
);
}
test_materials_come_from_home_regions_before_foreign_ones() {
const state = smallState();
const buyer = cityOf(state, 0);
const land = state.topology.neighbours(buyer.coords.x, buyer.coords.y)
.filter((n) => state._isLand(n));
this.assertGreaterOrEqual(land.length, 2, "the city has land neighbours");
for (const n of land) state.roads.add(key(n.x, n.y));
// Two reachable suppliers, home and foreign, both with a surplus. Home wins.
const homeNode = { coords: land[0], civ: 0, stock: { food: 1.0e9 }, kind: "building" };
const foreignNode = { coords: land[1], civ: 1, stock: { food: 1.0e9 }, kind: "building" };
const store = state.getCityResourceStock(buyer);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, store, [homeNode, foreignNode]
);
this.assertGreater(bought, 0, "the order was filled");
this.assertApprox(homeNode.stock.food, 1.0e9 - 1000, 1e-6, "the home region supplied it");
this.assertApprox(foreignNode.stock.food, 1.0e9, 1e-6, "the foreign region was left alone");
}
test_without_a_market_unreachable_goods_are_not_bought() {
const state = smallState();
const buyer = cityOf(state, 0);
for (const [, stock] of state.resourceStock) stock.food = 0;
state.roads.clear();
state.railways.clear();
const store = state.getCityResourceStock(buyer);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, store, state._resourceNodes(), { allowSea: false }
);
this.assertEqual(bought, 0, "no trade route, no goods");
this.assertEqual(store.food, 0, "and nothing arrives");
}
test_power_flows_between_reachable_grids() {
const state = smallState();
const city = cityOf(state, 0);
const neighbour = state.topology.neighbours(city.coords.x, city.coords.y)
.find((n) => state._isLand(n) && state.civAt(n) === 0);
this.assertNotNull(neighbour, "the city has an owned land neighbour");
const nk = key(neighbour.x, neighbour.y);
state.roads.add(nk);
const components = new Map([
[key(city.coords.x, city.coords.y), 0],
[nk, 1],
]);
const ledger = new Map([
[0, {
civ: 0, supply: 0, demand: 1000, imports: 0,
cityEnergy: 1000, converterEnergy: 0, foodEnergy: 0,
}],
[1, {
civ: 0, supply: 5000, demand: 0, imports: 0,
cityEnergy: 0, converterEnergy: 0, foodEnergy: 0,
}],
]);
state._tradeGridPower(ledger, components);
this.assertApprox(ledger.get(0).imports, 1000, 1e-6, "the deficit grid imported the power");
}
test_foreign_trade_runs_on_the_transport_network() {
const state = smallState();
state.budgets.set(0, 1e15);
const buyer = cityOf(state, 0);
const seller = cityOf(state, 1);
state.getCityResourceStock(seller).food = 1e9;
state.getCityResourceStock(buyer).food = 0;
const store = state.getCityResourceStock(buyer);
const boughtOnNetwork = state._procureFromNeighbours(
buyer, "food", 1000, store, state._resourceNodes()
);
this.assertGreater(boughtOnNetwork, 0, "a foreign market on the network is reachable");
// Tear up every road and railway: the foreign market is now unreachable.
state.roads.clear();
state.railways.clear();
store.food = 0;
state.resourceLinks = [];
const boughtOffNetwork = state._procureFromNeighbours(
buyer, "food", 1000, store, state._resourceNodes()
);
this.assertEqual(boughtOffNetwork, 0, "no road, no foreign market");
}
test_cross_nation_deliveries_are_booked_as_imports_and_exports() {
const state = smallState();
state.budgets.set(0, 1e15);
const buyer = cityOf(state, 0);
const seller = cityOf(state, 1);
state.getCityResourceStock(seller).food = 1e9;
state.getCityResourceStock(buyer).food = 0;
const bought = state._procureFromNeighbours(
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
);
this.assertGreater(bought, 0, "the neighbour sold food");
this.assertApprox(state._civResourceTrade(0).imports.food, bought, 1e-6, "the buyer imported it");
this.assertApprox(state._civResourceTrade(1).exports.food, bought, 1e-6, "the seller exported it");
// The snapshot carries every nation's trade ledger, so the panel can rank
// the world's major exporters and importers.
const snapshot = state.snapshot(0);
this.assertSize(snapshot.resourceTrade, 2);
this.assertApprox(snapshot.resourceTrade[1].exports.food, bought, 1e-3);
}
test_a_shortage_costs_popularity() {
const state = smallState();
const city = cityOf(state, 0);
const before = state.getCivPopularity(0);
state._resourceShortages.set(city.id, { steel: 1 });
state._applyResourceShortages(new Map());
this.assertLess(state.getCivPopularity(0), before, "a shortage hurt the government");
}
test_a_resource_building_cannot_stand_inside_a_city() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
this.assertFalse(
state.requestBuildTileImprovement(0, city.coords, "coal_power_plant"),
"a power plant belongs on open land"
);
const tile = freeLandTile(state, 0);
state.budgets.set(0, 1e15);
this.assertTrue(
state.requestBuildTileImprovement(0, tile, "coal_power_plant"),
"and is accepted on a controlled land tile"
);
}
test_snapshot_ships_the_market_and_the_delivery_graph() {
const state = smallState();
const snapshot = state.snapshot(0);
this.assertSize(snapshot.resourceMarket, 5);
for (const entry of snapshot.resourceMarket) {
this.assertHas(entry, "price");
this.assertGreater(entry.price, 0);
}
this.assertHas(snapshot.viewerStats, "resources");
this.assertHas(snapshot.resourceGraph, "nodes");
this.assertHas(snapshot.resourceGraph, "links");
}
// ------------------------------------------------- starting industry ----
test_the_opening_economy_is_seeded_with_covering_industry() {
const state = smallState();
this.assertGreater(state.tileImprovements.size, 0, "the world opens with resource works");
const components = state._resourceLandComponents();
const foodEnergy = state._foodEnergyByComponent(components);
for (let civ = 0; civ < state.civilisations.length; civ++) {
const deficits = state._resourceSeedDeficits(civ);
this.assertTrue(deficits.steel <= 0, "steel production covers the people and the works");
this.assertTrue(deficits.luxury <= 0, "luxury production covers the people");
const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
this.assertEmpty(energy, "every grid has the power its cities and industry draw");
}
}
test_seeded_industry_is_connected_to_the_transport_network() {
const state = smallState();
this.assertGreater(state.tileImprovements.size, 0, "there are seeded works");
for (const k of state.tileImprovements.keys()) {
this.assertTrue(
state.roads.has(k) || state.railways.has(k),
"a seeded work sits on the transport network"
);
}
}
test_seeding_is_deterministic_from_the_world_seed() {
const first = smallState(["france", "britain"], 12345);
const again = smallState(["france", "britain"], 12345);
const other = smallState(["france", "britain"], 999);
const describe = (state) =>
Array.from(state.tileImprovements.entries())
.map(([k, id]) => `${k}:${id}`)
.sort()
.join("|");
this.assertEqual(describe(first), describe(again), "the same seed lays out the same works");
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
}
// ------------------------------------------------------- upkeep -------
test_building_upkeep_is_materials_per_level_per_day() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const before = state.getBuildingUpkeep(0);
const index = grantBuilding(state, city, "research_lab", 1);
const after = state.getBuildingUpkeep(0);
// A 100 M lab: 50 t of steel, 100 MWh of energy and 200 high-tech a day.
this.assertApprox(after.steel - before.steel, 50, 1e-6);
this.assertApprox(after.hightech - before.hightech, 200, 1e-6);
const beforeLevel = after.steel;
city.buildings[index] = 2;
this.assertGreater(state.getBuildingUpkeep(0).steel, beforeLevel, "a higher level wears more");
}
test_the_state_pays_upkeep_materials_from_the_budget() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
grantBuilding(state, city, "market", 4);
// Drain the stores so the day's upkeep has to be bought.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
}
state.advanceHour();
// The material bill is settled on the daily tick, not hourly.
for (let i = 0; i < 23; i++) state.advanceHour();
const spend = state.resourceSpend.get(0) || {};
this.assertGreater(spend.upkeep || 0, 0, "the spend is booked as upkeep");
// The materials bill lands in the month's budget under an upkeep category.
const report = state.budgetMonthReports(0).at(-1);
const upkeep = report.expenses.filter(
(entry) => ["buildings", "units", "works", "transport"].includes(entry.id)
);
this.assertGreater(upkeep.length, 0, "the treasury paid it, filed as upkeep");
}
test_unaffordable_upkeep_costs_popularity() {
const state = smallState();
const before = state.getCivPopularity(0);
state._civilianUpkeepShortage(0, 1);
this.assertLess(state.getCivPopularity(0), before, "an unpaid bill is unrest");
}
// ------------------------------------------------------- repairs ------
test_a_razed_building_level_is_rebuilt_with_materials() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const index = grantBuilding(state, city, "market", 1);
city.buildings[index] = 0;
state._queueCityRepair(city, index, 1);
state._tickRepairs();
this.assertEqual(city.buildings[index], 1, "the level came back once its materials were paid");
}
test_a_damaged_tile_work_heals_once_its_debt_is_paid() {
const state = smallState();
state.budgets.set(0, 1e15);
const tile = freeLandTile(state, 0);
const k = placeResourceBuilding(state, tile, "fortifications", 0);
state.tileImprovementHp.set(k, 200);
state._recordRepairDebt(tile, 200);
const debt = state.tileRepairDebt.get(k);
this.assertGreater(debt, 0, "damage left a debt");
for (let i = 0; i < 20; i++) state._tickRepairs();
this.assertFalse(state.tileRepairDebt.has(k), "the debt was paid off");
this.assertGreater(state.tileImprovementHp.get(k), 200, "the work healed");
}
test_a_damaged_unit_heals_at_a_materials_cost() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
unit.hp = 100;
// Empty the stores so the heal must be bought.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
}
const before = state.getBudget(0);
state._tickGarrisonHealing();
this.assertGreater(unit.hp, 100, "the unit healed");
this.assertLess(state.getBudget(0), before, "healing cost the treasury");
}
test_a_tile_with_repair_debt_does_not_recover_its_gdp() {
const state = smallState();
const tile = freeLandTile(state, 0);
const k = key(tile.x, tile.y);
state.tileBattleProductionDeficit.set(k, 1000);
state.tileBattleRecoveryStart.set(k, 0);
state._recordRepairDebt(tile, 100);
state._tickProductionRecovery();
this.assertApprox(state.tileBattleProductionDeficit.get(k), 1000, 1e-6, "the scar waits for its materials");
}
// ------------------------------------------------ tech gate / combat --
test_fusion_needs_its_technology() {
const state = smallState();
state.budgets.set(0, 1e15);
for (const own of state.cities) {
if (own.civ !== 0) continue;
const stock = state.getCityResourceStock(own);
stock.steel = 1.0e12;
stock.hightech = 1.0e12;
}
const tile = freeLandTile(state, 0);
this.assertFalse(
state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
"fusion is locked without the technology"
);
const index = state.technologies.findIndex((t) => t.id === "fusion_power");
this.assertGreater(index, 0, "the fusion technology exists");
this.assertEqual(technologyCost(state.technologies[index]), 500_000, "it costs half a million points");
state.researched.get(0).add(index);
state.budgets.set(0, 1e15);
this.assertTrue(
state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
"and unlocks the plant once researched"
);
}
test_infantry_food_multiplies_in_combat_and_when_resting() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const unit = state._spawnUnit(city.coords, 0, proto);
const base = foodNeedPerDay(proto.population);
this.assertApprox(state._unitFoodRate(unit, proto, false), base, 1e-9, "idle rations");
this.assertApprox(
state._unitFoodRate(unit, proto, true),
base * RESOURCE_RULES.infantryCombatFoodMultiplier,
1e-9,
"combat rations"
);
unit.hp = 100;
this.assertApprox(
state._unitFoodRate(unit, proto, false),
base * RESOURCE_RULES.infantryRestingFoodMultiplier,
1e-9,
"wounded and resting rations"
);
}
test_a_land_unit_carries_fifty_days_of_food() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const unit = state._spawnUnit(city.coords, 0, proto);
this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "a full pack");
}
test_a_supplied_land_unit_tops_up_its_pack() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const unit = state._spawnUnit(city.coords, 0, proto);
unit.foodDays = 10;
state._tickUnitSupply();
this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "it is fed at home");
}
test_a_cut_off_land_unit_starves() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const unit = state._spawnUnit(city.coords, 0, proto);
// No reachable allied city, and an empty pack: the soldiers start dying.
state._unitSupplyCity = () => null;
unit.foodDays = 0;
const before = unit.hp;
state._tickUnitSupply();
this.assertLess(unit.hp, before, "starvation costs hit points");
}
test_air_and_naval_units_do_not_carry_supply() {
const state = smallState();
const city = cityOf(state, 0);
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
unit.foodDays = 5;
state._unitSupplyCity = () => null;
state._tickUnitSupply();
this.assertEqual(unit.foodDays, 5, "an aircraft uses endurance, not a food pack");
}
test_an_encircled_city_cannot_buy_from_the_market() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
// Ring the city with enemies and empty its stores.
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
}
const store = state.getCityResourceStock(city);
store.food = 0;
store.steel = 0;
store.luxury = 0;
const market = new Map(
["food", "steel", "luxury", "energy", "hightech"].map((id) => [id, { demand: 0 }])
);
state._consumeCityResources(new Map(), market);
this.assertEqual(store.food, 0, "no food came in through the ring");
this.assertGreater(state._resourceShortages.size, 0, "the shortage is recorded");
}
test_an_encircled_unit_cannot_resupply() {
const state = smallState();
const unit = state.units[0];
for (const neighbour of state.topology.neighbours(unit.coords.x, unit.coords.y)) {
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
}
unit.foodDays = 10;
state._tickUnitSupply();
this.assertLess(unit.foodDays, 10, "a cut-off unit eats its reserves");
}
test_a_unit_off_the_network_starves() {
const state = smallState();
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const tile = freeLandTile(state, 0);
const unit = state._spawnUnit(tile, 0, proto);
// Tear up the network: the unit is cut off and eats its reserves.
state.roads.clear();
state.railways.clear();
unit.foodDays = 10;
state._tickUnitSupply();
this.assertLess(unit.foodDays, 10, "no road, no resupply");
}
test_naval_cities_start_with_a_port() {
const state = smallState();
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
const inland = state.cities.find((c) => c.civ === 0 && !state.isCoastalCity(c));
this.assertNotNull(coastal, "the nation has a naval city");
this.assertTrue(state.hasCityBuilding(coastal, "port"), "a naval city starts with a port");
this.assertFalse(inland && state.hasCityBuilding(inland, "port"), "an inland city does not");
}
test_a_city_without_a_port_has_no_sea_lane() {
const state = smallState();
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
for (const own of state.cities) {
if (own.civ === 0) delete own.buildings[portIndex];
}
this.assertFalse(state._civMarketAccess(0), "no working port, no sea trade");
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
this.assertNotNull(coastal, "the nation has a coastal city");
coastal.buildings[portIndex] = 1;
this.assertTrue(state._civMarketAccess(0), "the port opens the sea lane");
}
test_air_defense_spends_ammunition_when_it_fires() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
grantBuilding(state, city, "aa_building");
for (const own of state.cities) {
if (own.civ === 0) {
const store = state.getCityResourceStock(own);
store.steel = 0;
store.hightech = 0;
}
}
const plane = state.protoUnits.find((p) => p.id === "jet_fighter");
const intruder = state._spawnUnit(city.coords, 1, plane);
state._airDefenseVolley(intruder);
const spend = state.resourceSpend.get(0) || {};
this.assertGreater(spend.combat || 0, 0, "firing the air defense cost materials");
}
test_training_a_unit_draws_its_soldiers_from_the_region() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const before = state.regionPopulation(city);
state._spawnTrainedUnit(city, proto);
const after = state.regionPopulation(city);
this.assertApprox(before - after, proto.population, 1e-6, "1000 people became soldiers");
}
test_healing_a_unit_draws_replacement_soldiers() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
const unit = state._spawnUnit(city.coords, 0, proto);
unit.hp = 100;
const before = state.regionPopulation(city);
state._tickGarrisonHealing();
const gained = unit.hp - 100;
this.assertGreater(gained, 0, "the unit healed");
// Each hit point is a share of the unit's soldiers, drawn from the people.
this.assertApprox(
before - state.regionPopulation(city),
(gained / unit.maxHp) * proto.population,
1e-6
);
}
}