import { TestCase } from "./framework/test_case.js"; import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; import { EFFECT_RESEARCH } from "../shared/data.js"; import { tileFoodOutput } from "../shared/resources.js"; import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js"; const BIG_BUDGET = 1.0e15; export class GameStateBuildingsTest extends TestCase { addBuilding(state, proto) { state.protoBuildings.push(proto); return state.protoBuildings.length - 1; } // Runs hours until every city's production queue has emptied. advanceUntilIdle(state, max = 100000) { for (let i = 0; i < max && state.training.size > 0; i++) state.advanceHour(); } // The number of hours `advanceUntilIdle` had to run. hoursUntilIdle(state, max = 100000) { let hours = 0; while (hours < max && state.training.size > 0) { state.advanceHour(); hours += 1; } return hours; } test_barracks_adds_a_parallel_training_line() { const radarId = "mobile_radar"; const oneLine = smallState(); oneLine.budgets.set(0, BIG_BUDGET); const soloCity = cityOf(oneLine, 0); const soloRadar = oneLine.protoUnits.findIndex((p) => p.id === radarId); oneLine.requestTrain(soloCity.id, soloRadar); oneLine.requestTrain(soloCity.id, soloRadar); const sequential = this.hoursUntilIdle(oneLine); const twoLines = smallState(); twoLines.budgets.set(0, BIG_BUDGET); const city = cityOf(twoLines, 0); grantBuilding(twoLines, city, "barracks"); const radar = twoLines.protoUnits.findIndex((p) => p.id === radarId); twoLines.requestTrain(city.id, radar); twoLines.requestTrain(city.id, radar); const parallel = this.hoursUntilIdle(twoLines); this.assertLess(parallel, sequential, "the barracks trains both at once"); } test_barracks_lines_work_different_units_at_once() { // Each line sticks to its own unit: a cheap unit queued behind a costly one // still finishes first, instead of the head monopolising every line. const state = smallState(); state.budgets.set(0, BIG_BUDGET); const city = cityOf(state, 0); grantBuilding(state, city, "barracks"); const artillery = state.protoUnits.findIndex((p) => p.id === "artillery"); const radar = state.protoUnits.findIndex((p) => p.id === "mobile_radar"); this.assertTrue(state.requestTrain(city.id, artillery), "queue the costly unit"); this.assertTrue(state.requestTrain(city.id, radar), "queue the cheap unit behind it"); let radarHour = -1; for (let hour = 1; hour <= 100000 && radarHour < 0; hour++) { state.advanceHour(); if (state.units.some((u) => u.civ === 0 && u.proto === radar)) radarHour = hour; if (!state.training.has(city.id)) break; } const queue = state.training.get(city.id) || []; this.assertTrue(radarHour > 0, "the cheap unit is trained"); this.assertTrue( queue.some((e) => e.kind === "unit" && e.protoIndex === artillery), "the costly unit is still building when the cheap one lands" ); } test_shipyard_adds_a_parallel_building_queue() { const solo = smallState(); solo.budgets.set(0, BIG_BUDGET); const soloIndex = this.addBuilding(solo, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); const soloCity = cityOf(solo, 0); // Construction is settled in materials; stock the city so both queued // levels can gather without depending on the transport network. solo.getCityResourceStock(soloCity).steel = 5_000_000; solo.requestBuild(soloCity.id, soloIndex); solo.requestBuild(soloCity.id, soloIndex); const sequential = this.hoursUntilIdle(solo); const yard = smallState(); yard.budgets.set(0, BIG_BUDGET); const yardIndex = this.addBuilding(yard, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); const yardCity = cityOf(yard, 0); yard.getCityResourceStock(yardCity).steel = 5_000_000; grantBuilding(yard, yardCity, "shipyard"); yard.requestBuild(yardCity.id, yardIndex); yard.requestBuild(yardCity.id, yardIndex); const parallel = this.hoursUntilIdle(yard); this.assertLess(parallel, sequential, "the shipyard builds both levels at once"); this.assertEqual(yard.getCityBuildingLevel(yardCity, yardIndex), 2); } test_infantry_needs_a_barracks_before_it_can_be_trained() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const city = cityOf(state, 0); const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); this.assertFalse(state.requestTrain(city.id, infantry), "no barracks, no infantry"); grantBuilding(state, city, "barracks"); this.assertTrue(state.requestTrain(city.id, infantry), "the barracks unlocks infantry"); } test_port_adds_gdp_per_capita_to_its_whole_region() { const state = smallState(); const city = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c)); this.assertNotNull(city, "the nation has a coastal city"); const sea = state.topology .neighbours(city.coords.x, city.coords.y) .filter((n) => { const tile = state.tiles[key(n.x, n.y)]; return tile && tile.terrainClass === "Sea"; }).length; this.assertGreater(sea, 0, "the city touches the sea"); const portIndex = state.protoBuildings.findIndex((b) => b.id === "port"); // A naval city already starts with one port level. this.assertEqual(city.buildings[portIndex], 1, "the seeded port is there"); city.buildings[portIndex] = 0; state._gdpPerCapitaCache.clear(); const sample = state.regionTiles(city).find((coords) => !state.cityAt(coords)) || state.regionTiles(city)[0]; // Isolate the port's GDP bonus: the port also lifts food output, which // feeds back into the production-based base. That is tested separately. const foodMultiplier = state._regionFoodMultiplier; state._regionFoodMultiplier = () => 1; const before = state.getTileGdpPerCapita(sample); city.buildings[portIndex] = 2; state._gdpPerCapitaCache.clear(); const after = state.getTileGdpPerCapita(sample); state._regionFoodMultiplier = foodMultiplier; // +1000 per port level for each neighbouring sea tile, on every region tile. this.assertApprox(after - before, 1000 * sea * 2, 1e-6, "the port lifts the whole region"); } test_port_lifts_its_regions_food_output() { const state = smallState(); const city = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c)); this.assertNotNull(city, "the nation has a coastal city"); const sea = state.topology .neighbours(city.coords.x, city.coords.y) .filter((n) => { const tile = state.tiles[key(n.x, n.y)]; return tile && tile.terrainClass === "Sea"; }).length; this.assertGreater(sea, 0, "the city touches the sea"); const portIndex = state.protoBuildings.findIndex((b) => b.id === "port"); city.buildings[portIndex] = 0; const before = state._regionProductionValue(city); city.buildings[portIndex] = 1; const after = state._regionProductionValue(city); // The port adds 5% of the region's food output for each adjacent sea tile. const foodValue = state.regionTiles(city).reduce((sum, coords) => { const k = key(coords.x, coords.y); const tile = state.tiles[k]; const population = state.tilePopulation.get(k) || 0; if (population <= 0) return sum; return sum + tileFoodOutput(tile) * state.getResourcePrice("food"); }, 0) * DAYS_PER_YEAR; this.assertApprox(after - before, foodValue * 0.05 * sea, foodValue * 1e-6, "the port grows more food"); } test_request_build_charges_budget_and_raises_the_level() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const city = cityOf(state, 0); const beforeBudget = state.getBudget(0); const beforeGdp = state.getPlayerGdp(0); this.assertTrue(state.requestBuild(city.id, index)); this.assertLess(state.getBudget(0), beforeBudget, "cost was paid up front"); this.assertEqual(state.getCityBuildingLevel(city, index), 0, "not built until it is produced"); this.assertNotEmpty(state.getTraining(city.id), "the level waits in the production queue"); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 1); this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied"); } test_a_building_order_gathers_its_materials_before_building() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); const city = cityOf(state, 0); // Drain every own store and cut the network, so nothing can be gathered. for (const own of state.cities) { if (own.civ !== 0) continue; const store = state.getCityResourceStock(own); store.steel = 0; store.hightech = 0; } state.roads.clear(); state.railways.clear(); this.assertTrue(state.requestBuild(city.id, index)); const queue = state.getTraining(city.id); this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds"); this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started"); // Supply the steel; the next day the order can buy and start building. state.getCityResourceStock(city).steel = 1.0e9; for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour(); this.assertEqual(queue[0].phase, "construction", "the bill is gathered"); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 1); } test_a_stalled_building_order_waits_for_materials() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); const city = cityOf(state, 0); // Nothing anywhere to buy: the order can only wait. for (const own of state.cities) { const store = state.getCityResourceStock(own); store.steel = 0; store.hightech = 0; } this.assertTrue(state.requestBuild(city.id, index)); for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour(); const entry = state.getTraining(city.id)[0]; this.assertEqual(entry.phase, "materials", "it is still gathering"); this.assertTrue(entry.stalled, "the entry reports being stalled"); this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built"); } test_building_and_unit_share_one_queue_and_are_produced_in_order() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const city = cityOf(state, 0); grantBuilding(state, city, "barracks"); const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); const beforeUnits = state.units.length; this.assertTrue(state.requestBuild(city.id, index)); this.assertTrue(state.requestTrain(city.id, infantry)); const queue = state.getTraining(city.id); this.assertSize(queue, 2, "both orders sit in the same queue"); this.assertEqual(queue[0].kind, "building"); this.assertEqual(queue[1].kind, "unit"); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 1); this.assertEqual(state.units.length, beforeUnits + 1, "the unit was produced too"); } test_repeated_build_orders_price_the_queued_levels() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding({ baseCost: 100, costAlpha: 2.0 })); const city = cityOf(state, 0); // Stock the steel so the whole bill is drawn from the stores and the two // orders can be compared cleanly. for (const own of state.cities) { if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e9; } const before = state.getBudget(0); this.assertTrue(state.requestBuild(city.id, index)); const first = before - state.getBudget(0); const mid = state.getBudget(0); this.assertTrue(state.requestBuild(city.id, index)); // The second level (money and steel) is priced off the level the first // leaves behind, so it costs twice the first. this.assertApprox(mid - state.getBudget(0), first * 2, 1e-6); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 2); } test_request_build_goes_ahead_on_an_empty_treasury() { const state = smallState(); state.budgets.set(0, 0); const index = this.addBuilding(state, gdpBuilding()); // The government can always pay, running an overdraft with the central bank. this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); this.assertLess(state.getBudget(0), 0, "the treasury went into debt"); } test_request_build_has_no_level_cap() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const city = cityOf(state, 0); // Stock the steel so the material bill is never the limit being tested. for (const own of state.cities) { if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e12; } for (let i = 0; i < 20; i++) this.assertTrue(state.requestBuild(city.id, index)); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 20); this.assertTrue(state.requestBuild(city.id, index), "still upgradable past the old cap"); } test_request_build_rejects_unknown_targets() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); this.assertFalse(state.requestBuild(99999, 0)); this.assertFalse(state.requestBuild(cityOf(state, 0).id, 9999)); } test_request_demolish_removes_the_building_without_a_refund() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const city = cityOf(state, 0); this.assertTrue(state.requestBuild(city.id, index)); this.assertTrue(state.requestBuild(city.id, index)); this.advanceUntilIdle(state); const before = state.getBudget(0); this.assertTrue(state.requestDemolish(city.id, index)); this.assertEqual(state.getCityBuildingLevel(city, index), 1); this.assertApprox(state.getBudget(0), before, 1e-6, "downgrading refunds nothing"); this.assertTrue(state.requestDemolish(city.id, index)); this.assertEqual(state.getCityBuildingLevel(city, index), 0); this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish"); } test_building_upkeep_is_added_to_material_upkeep() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding( state, gdpBuilding({ baseCost: 10_000, costAlpha: 1.0 }) ); const before = state.getBuildingUpkeep(0); this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); this.advanceUntilIdle(state); // 5e-7 t of steel and 1e-3 kWh of energy per euro of the level's cost. this.assertApprox(state.getBuildingUpkeep(0).steel, before.steel + 0.005, 1e-9); this.assertApprox(state.getBuildingUpkeep(0).energy, before.energy + 10, 1e-9); } test_research_buildings_accumulate_points_each_hour() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const building = gdpBuilding({ baseCost: 0, effects: [] }); building.effects.push({ stat: EFFECT_RESEARCH, baseValue: 2.0, exponent: 1.0 }); const index = this.addBuilding(state, building); this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); // Research only accumulates into a focused technology, so focus one. const method = state.technologies.findIndex((t) => t.id === "renewable_energy"); this.assertTrue(state.requestFocusTechnology(0, method)); this.assertEqual(state.getTechnologyProgress(0), 0); state.advanceHour(); const first = state.getTechnologyProgress(0); const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0; this.assertApprox(first - governmentRate, 2.0); state.advanceHour(); this.assertApprox(state.getTechnologyProgress(0) - first - governmentRate, 2.0); } test_viewer_stats_split_income_and_upkeep() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding( state, gdpBuilding({ baseCost: 10_000, costAlpha: 1.0, effects: [] }) ); this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); this.advanceUntilIdle(state); const breakdown = state.viewerStats(0).breakdown; this.assertHas(breakdown, "income"); this.assertHas(breakdown, "buildingUpkeep"); this.assertHas(breakdown, "unitUpkeep"); this.assertHas(breakdown, "transportUpkeep"); // Upkeep lines are the market value of the materials the nation must buy. const expected = state._upkeepCost(state.getBuildingUpkeep(0)); this.assertApprox(breakdown.buildingUpkeep.total, expected, 1e-6); // The only direct money upkeep left is propaganda. this.assertApprox(state.getPlayerUpkeep(0), breakdown.campaignUpkeep.total, 1e-9); const expected_net = breakdown.income.total - breakdown.buildingUpkeep.total - breakdown.unitUpkeep.total - breakdown.campaignUpkeep.total - breakdown.transportUpkeep.total - breakdown.tileUpkeep.total; this.assertApprox(breakdown.net, expected_net, 1e-6); // Income is the trade tax collected over the day, as an hourly average. this.assertApprox(breakdown.income.total, state.getCivHourlyIncome(0), 1e-6); } test_budget_breakdown_names_tax_sources() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const city = cityOf(state, 0); state._recordTax(0, "sales", "steel", 240, city); const breakdown = state.viewerStats(0).breakdown; const source = breakdown.income.sources.find((s) => s.label === "National sales tax"); this.assertTrue(source, "the sales tax appears as an income source"); this.assertEqual(source.kind, "tax"); this.assertApprox(source.amount, 10); this.assertApprox(breakdown.income.total, 10); } test_snapshot_exposes_buildings_and_resources() { const state = smallState(); const snapshot = state.snapshot(0); this.assertSize(snapshot.buildings, state.protoBuildings.length); this.assertHas(snapshot, "focusTechnology"); this.assertHas(snapshot, "culture"); for (const city of snapshot.cities) this.assertHas(city, "buildings"); this.assertHas(snapshot.viewerStats, "researchRate"); this.assertHas(snapshot.viewerStats, "culture"); } test_snapshot_serialises_city_building_levels() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const cityId = cityOf(state, 0).id; this.assertTrue(state.requestBuild(cityId, index)); this.advanceUntilIdle(state); const city = state.snapshot(0).cities.find((c) => c.id === cityId); this.assertEqual(city.buildings[index], 1); } }