// Simple construction introduced by 03-construction.md: an order starts at // once, its material bill comes out of the nation's pool, the clock is set by // the region's spare power, and every opening region keeps a fuel-free plant. // The hard economy's gather-then-build path is exercised by // `construction_site_test.js` and must stay untouched. import { TestCase } from "./framework/test_case.js"; import { simpleState, cityOf, grantBuilding, smallConfig, SEED } from "./framework/helpers.js"; import { GameServer } from "../server/game_server.js"; import { key, parseKey } from "../shared/hex.js"; import { SIMPLE_ECONOMY, tileImprovementById, isResourceTileBuilding, } from "../shared/data.js"; import { buildingBuildCost } from "../shared/rules.js"; // The first land tile a nation owns that carries nothing, not even a road, so a // transport order cannot be refused for replacing the same improvement. function bareOwnedLand(state, civ = 0) { return state.landCells.find((coords) => { const k = key(coords.x, coords.y); return state.civAt(coords) === civ && !state.cityAt(coords) && !state.roads.has(k) && !state.railways.has(k) && !state.tileImprovements.has(k) && !state.constructionSiteAt(coords); }); } // A server that has configured its game but never listened, so the warm-up // branch can be observed without opening a socket. function configuredServer(setup = {}) { const network = { getPeerIds: () => [], send: () => {} }; const server = new GameServer(network); server.configureGame({ seed: SEED, player_civ: 0, civilisations: ["france", "britain"], mapConfig: smallConfig(), ...setup, }); return server; } // Strips every power plant out of one city's region, so the energy floor can be // exercised without the world's opening plants in the way. function stripRegionPlants(state, city) { for (const coords of state.regionTiles(city)) { const k = key(coords.x, coords.y); const proto = tileImprovementById(state.tileImprovements.get(k)); if (isResourceTileBuilding(proto) && proto.resource === "energy") { state.tileImprovements.delete(k); state.tileImprovementOwner.delete(k); state.tileImprovementHp.delete(k); // A real removal bumps the improvement versions, which is what drops the // memoised region power balances. Mirror that here. state._improvementVersion += 1; state._tileImprovementVersion += 1; } } } export class SimpleConstructionTest extends TestCase { test_a_build_order_starts_in_construction_with_no_materials_phase() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); const proto = state.protoBuildings[barracks]; const levelBefore = state.getCityBuildingLevel(city, barracks); const pool = state.getResourcePool(0); const steelBefore = pool.steel; const cost = buildingBuildCost(proto, levelBefore); const bill = state.constructionResourceCost(proto, levelBefore, cost); this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted"); const entry = state.training.get(city.id)[0]; this.assertNotNull(entry, "the order is queued"); this.assertEqual(entry.phase, "construction", "it starts building at once"); this.assertEqual(entry.budget, 0, "no material budget is reserved"); this.assertEqual(entry.needed.steel, 0, "it names no materials to gather"); this.assertEqual(entry.bought.steel, 0, "and gathers none"); this.assertApprox( pool.steel, steelBefore - bill.steel, 1e-6, "the pool paid the bill up front" ); this.assertEqual( state.getCityBuildingLevel(city, barracks), levelBefore, "the level is not raised until the clock runs out" ); } test_a_building_is_raised_on_the_hourly_tick() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); const levelBefore = state.getCityBuildingLevel(city, barracks); this.assertTrue(state.requestBuild(city.id, barracks)); for (let hour = 0; hour < 10_000 && state.training.has(city.id); hour++) { state.advanceHour(); } this.assertFalse(state.training.has(city.id), "the order finished"); this.assertEqual( state.getCityBuildingLevel(city, barracks), levelBefore + 1, "the level arrived" ); } test_a_unit_order_trains_without_a_materials_phase() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); grantBuilding(state, city, "barracks"); const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length; this.assertTrue(state.requestTrain(city.id, infantry), "the order is accepted"); const entry = state.training.get(city.id)[0]; this.assertEqual(entry.phase, "construction", "training starts at once"); for (let hour = 0; hour < 2_000 && state.training.has(city.id); hour++) { state.advanceHour(); } this.assertFalse(state.training.has(city.id), "the order finished"); this.assertEqual( state.units.filter((u) => u.civ === 0 && u.proto === infantry).length, before + 1, "the unit was trained" ); } test_more_regional_energy_strictly_shortens_the_clock() { const state = simpleState(); const city = cityOf(state, 0); const cost = 100_000_000; state.regionAvailableEnergy = () => 100_000_000; const low = state._simpleConstructionHours(city, cost, "building"); state.regionAvailableEnergy = () => 1_000_000_000; const high = state._simpleConstructionHours(city, cost, "building"); this.assertLess(high, low, "more spare power builds faster"); this.assertGreater(high, 0, "and never instant"); } test_a_blacked_out_region_builds_slowly_but_not_at_once() { const state = simpleState(); const city = cityOf(state, 0); const cost = 100_000_000; state.regionAvailableEnergy = () => 0; const dead = state._simpleConstructionHours(city, cost, "building"); state.regionAvailableEnergy = () => 100_000_000; const lit = state._simpleConstructionHours(city, cost, "building"); this.assertGreater(dead, lit, "a power-less region builds clearly slower"); this.assertTrue(Number.isFinite(dead), "the clock is finite, not a divide-by-zero"); this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "the floor holds"); } test_a_region_with_no_plant_reports_no_spare_power() { const state = simpleState(); const city = cityOf(state, 0); this.assertGreater(state.regionAvailableEnergy(city), 0, "the region opens powered"); stripRegionPlants(state, city); this.assertEqual(state.regionAvailableEnergy(city), 0, "no plants, no spare power"); } test_every_starting_region_has_a_working_power_plant() { const state = simpleState(); for (const city of state.cities) { this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} has a plant`); this.assertGreater( state.regionAvailableEnergy(city), 0, `city ${city.name}'s grid has spare power` ); } } test_the_seeder_fills_a_region_that_has_no_plant() { const state = simpleState(); for (const city of state.cities) stripRegionPlants(state, city); for (const city of state.cities) { this.assertFalse(state._regionHasPowerPlant(city), "the region was stripped bare"); } state._seedSimpleRegionPlants(); for (const city of state.cities) { this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} got a plant`); } // The guaranteed plant is fuel-free, so the simple economy implies no fuel. for (const [k, id] of state.tileImprovements) { const proto = tileImprovementById(id); if (isResourceTileBuilding(proto) && proto.resource === "energy") { this.assertTrue(proto.renewable, `${proto.name} is fuel-free`); this.assertFalse(!!proto.fuelEnergyPerDay, `${proto.name} burns no fuel`); } } } test_an_order_the_pool_cannot_pay_is_refused() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); const levelBefore = state.getCityBuildingLevel(city, barracks); const pool = state.getResourcePool(0); pool.steel = 0; pool.hightech = 0; this.assertFalse(state.requestBuild(city.id, barracks), "an unfunded order is refused"); this.assertFalse(state.training.has(city.id), "nothing was queued"); this.assertEqual( state.getCityBuildingLevel(city, barracks), levelBefore, "and nothing was raised" ); } test_simple_construction_spends_no_treasury_money() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); const budgetBefore = state.getBudget(0); this.assertTrue(state.requestBuild(city.id, barracks)); this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing"); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "a road is ordered"); this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the road paid nothing either"); } test_a_tile_order_opens_already_building_and_finishes() { const state = simpleState(); state.applyStartingScenario(); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const site = state.constructionSiteAt(coords); this.assertNotNull(site, "a site opened"); this.assertEqual(site.phase, "construction", "it is already building"); this.assertEqual(site.budget, 0, "no budget was moved"); this.assertNull(site.cityId, "and it hauls from no city"); for (let hour = 0; hour < 10_000 && state.constructionSiteAt(coords); hour++) { state.advanceHour(); } this.assertTrue(state.roads.has(key(coords.x, coords.y)), "the road was raised"); } test_a_public_upgrade_is_immediate_and_energy_driven() { const state = simpleState(); state.applyStartingScenario(); let coords = null; let k = null; let proto = null; for (const [tile, id] of state.tileImprovements) { if (state.tileImprovementOwner.get(tile) !== 0) continue; const candidate = tileImprovementById(id); if (isResourceTileBuilding(candidate) && candidate.resource !== "energy") { k = tile; coords = parseKey(tile); proto = candidate; break; } } this.assertNotNull(proto, "the fixture has a converter to upgrade"); const pool = state.getResourcePool(0); pool.steel = 1e9; pool.hightech = 1e6; const agent = state._ensureBuildingAgent(k); const levelBefore = agent.level; const budgetBefore = state.getBudget(0); this.assertTrue(state._startBuildingUpgrade(k, proto, agent), "the upgrade starts"); const site = state.constructionSiteAt(coords); this.assertNotNull(site, "a site opened"); this.assertEqual(site.kind, "upgrade"); this.assertEqual(site.phase, "construction", "it is already building"); this.assertFalse(site.private, "it is a public work"); this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing"); for (let hour = 0; hour < 100_000 && state.constructionSiteAt(coords); hour++) { state.advanceHour(); } this.assertNull(state.constructionSiteAt(coords), "the upgrade finished"); this.assertEqual(agent.level, levelBefore + 1, "the level rose"); } test_ordering_a_build_touches_no_supplier_search() { const state = simpleState(); state.applyStartingScenario(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); let searched = false; state._nearbySuppliers = () => { searched = true; return []; }; state._resourceNodes = () => { searched = true; return []; }; this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted"); this.assertFalse(searched, "the order did no trade-graph lookup"); this.assertEqual(state.training.get(city.id)[0].phase, "construction", "it started building"); } // 04-warmup.md: the simple economy opens on its authored scenario, so the // server settles no days and the clock never moves before the first player // joins. The hard model keeps its settle for comparison. test_a_simple_game_skips_the_warmup() { const server = configuredServer({ economyModel: "simple", warmupDays: 150 }); this.assertEqual(server.settledDays, 0, "no days were settled"); this.assertEqual(server.state.totalHours, 0, "the clock never advanced"); } test_a_simple_game_ignores_an_explicit_warmup_cap() { const capped = configuredServer({ economyModel: "simple", warmupDays: 150 }); const none = configuredServer({ economyModel: "simple", warmupDays: 0 }); this.assertEqual(capped.settledDays, 0, "the cap settled nothing"); this.assertEqual(capped.state.totalHours, none.state.totalHours, "the same opening hour"); for (const civ of [0, 1]) { this.assertEqual( capped.state.getResourcePool(civ), none.state.getResourcePool(civ), `civ ${civ}'s pool is the authored opening pool` ); } } test_a_hard_game_still_settles_up_to_its_cap() { const server = configuredServer({ economyModel: "hard", warmupDays: 2 }); this.assertEqual(server.settledDays, 2, "the hard settle ran the cap"); this.assertGreater(server.state.totalHours, 0, "the clock advanced"); } }