Files
Battle-for-Tismo/tests/resources_test.js
T
adrien 31683be27d Rolled the commodity index over ninety days and gave the market an equilibrium
The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put.
2026-09-23 18:54:07 +02:00

997 lines
42 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { smallState, defaultState, cityOf, grantBuilding, settleInlandCity, stripPorts } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_MARKET,
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,
nextMarketPrice,
} from "../shared/resources.js";
import { deliveryEnergyKwh, deliveryEnergyForResource } from "../shared/economy_graph.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_flat_month() {
const need = foodNeedPerDay(2_000_000) * RESOURCE_RULES.stockpileDays;
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);
}
test_market_price_settles_at_its_equilibrium_without_a_clamp() {
const base = RESOURCE_MARKET_BASE.steel;
// A persistent shortage of half the supply settles at twice the base price:
// a price above the old 1.8x band, reached without a clamp and without
// compounding away to infinity.
let price = base;
for (let day = 0; day < 200; day++) price = nextMarketPrice(base, price, 10, 20);
this.assertApprox(price, base * 2, base * 1e-6, "the price eases to base * demand/supply");
// A day with no supply at all reads as the sharpest shortage the ratio may
// express, so the price settles at that multiple rather than running away.
let scarce = base;
for (let day = 0; day < 200; day++) scarce = nextMarketPrice(base, scarce, 0, 10);
this.assertApprox(scarce, base * RESOURCE_MARKET.maxRatio, base * 1e-6);
}
}
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();
// The consumption compared here is the people's need alone; a seeded port
// would add its own upkeep to the region's steel and energy draw.
stripPorts(state);
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
store.food = 1000;
store.steel = 2000;
store.hightech = 50;
store.luxury = 10;
store.energy = 0;
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.luxury, luxuryNeedPerDay(population));
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
// The people's own steel need is named in the breakdown, and any industry
// in the region adds its wear on top.
const people = info.demand.steel.find((entry) => entry.name === "People");
this.assertNotNull(people, "the people are a named steel consumer");
this.assertApprox(people.amount, steelNeedPerDay(population));
this.assertTrue(info.consumption.steel >= steelNeedPerDay(population) - 1e-6);
}
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;
}
// The national figure is the exact GDP over the exact population; the
// regional figures ship rounded populations, so allow a person's worth of
// rounding drift in the per-capita comparison.
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 0.01);
}
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);
// Empty every other store, or a nearer home or foreign seller would be
// reached first now that a port connects the whole coast.
for (const own of state.cities) 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);
// Only the intended foreign seller holds food, so a home region never
// satisfies the buy now that the trade net spans the coast.
for (const own of state.cities) state.getCityResourceStock(own).food = 0;
state.getCityResourceStock(seller).food = 1e9;
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");
}
test_every_nation_opens_with_a_chip_foundry() {
// High-tech is seeded like any other industry: a nation with no works of
// its own opens with a chip foundry, on the transport network, and the
// layout is drawn from the world seed.
const state = smallState(["france", "britain", "poland"], 12345);
const owners = new Set();
for (const [k, id] of state.tileImprovements) {
if (id !== "chip_foundry") continue;
owners.add(state.tileImprovementOwner.get(k));
this.assertTrue(
state.roads.has(k) || state.railways.has(k),
"a seeded foundry sits on the transport network"
);
}
this.assertTrue(owners.size > 0, "the world opens with chip foundries");
// The layout is drawn from the world seed, so a replay lays it out again.
const again = smallState(["france", "britain", "poland"], 12345);
const describe = (s) =>
Array.from(s.tileImprovements.entries())
.filter(([, id]) => id === "chip_foundry")
.map(([k]) => k)
.sort()
.join("|");
this.assertEqual(describe(state), describe(again), "the foundries are deterministic from the seed");
}
test_the_chip_foundries_are_powered_on_a_full_map() {
// A full roster: the seeded chip foundries draw a lot, so the power pass
// must build the grid to run them on every nation's land.
const state = defaultState(["france", "britain", "germany", "poland"]);
const fabs = [];
for (const [k, id] of state.tileImprovements) {
if (id === "chip_foundry") fabs.push(k);
}
this.assertTrue(fabs.length > 0, "a full map opens with chip foundries");
// Their huge draw is settled like any other converter's, so every grid opens
// with the power to run them.
const components = state._resourceLandComponents();
const foodEnergy = state._foodEnergyByComponent(components);
for (let civ = 0; civ < state.civilisations.length; civ++) {
const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
this.assertEmpty(energy, "the grid opens with the power the foundry draws");
}
}
// ------------------------------------------------------- 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, "barracks", 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, "barracks", 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);
// A coastal city would keep a sea lane open, so besiege an inland one and
// ring every land neighbour with enemies.
const city = settleInlandCity(state, 0, (coords) => {
const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
});
this.assertNotNull(city, "the fixture has an inland city to encircle");
// 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();
// A fully inland tile: with every land neighbour held by the enemy there is
// no route, land or maritime, back to a friendly city.
let spot = null;
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
const neighbours = state.topology.neighbours(cell.x, cell.y);
if (neighbours.every((n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n))) {
spot = cell;
break;
}
}
this.assertNotNull(spot, "the fixture has an inland tile");
const unit = state._spawnUnit(spot, 0, state.protoUnits[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
);
}
}