import { TestCase } from "./framework/test_case.js"; import { smallState, cityOf } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; import { tileImprovementById, RESOURCE_RULES } from "../shared/data.js"; import { protoUpkeep } from "../shared/resources.js"; const BIG_BUDGET = 1.0e15; // A bare owned land tile with no improvement and no work already on it. function bareOwnedLand(state, civ = 0) { return state.landCells.find((coords) => { const k = key(coords.x, coords.y); return state.civAt(coords) === civ && !state.roads.has(k) && !state.railways.has(k) && !state.constructionSiteAt(coords); }); } // Makes `id` the only tile improvement in the world, so the works-upkeep math // is not disturbed by the producers the world opens with. function soleWork(state, coords, id) { state.tileImprovements.clear(); state.tileImprovementOwner.clear(); state.tileImprovementHp.clear(); if (state.buildingAgents) state.buildingAgents.clear(); const k = key(coords.x, coords.y); state.tileImprovements.set(k, id); state.tileImprovementOwner.set(k, 0); return k; } // Registers a construction site literal, so the site tick can be exercised // without a full build order. function makeSite(state, coords, overrides = {}) { const k = key(coords.x, coords.y); const site = { coords: { x: coords.x, y: coords.y }, id: "fortifications", civ: 0, kind: "build", targetLevel: 0, phase: "materials", budget: 0, prices: state._lockedUnitPrices(), needed: { steel: 0, hightech: 0, energy: 0 }, bought: { steel: 0, hightech: 0, energy: 0 }, elapsedHours: 0, totalHours: 10, cityId: null, path: [], stalled: false, budgetRaises: 0, lastBuyDay: -1, ...overrides, }; state.constructionSites.set(k, site); return site; } // Empties every reachable store, so a site with no stock of its own buys // nothing from the market either. function clearMaterials(state) { for (const city of state.cities) { const stock = state.getCityResourceStock(city); stock.steel = 0; stock.hightech = 0; stock.energy = 0; } if (state.resourceStock) { for (const stock of state.resourceStock.values()) { stock.steel = 0; stock.hightech = 0; stock.energy = 0; } } } export class ConstructionSiteTest extends TestCase { test_an_order_opens_a_site_without_raising_the_improvement() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); for (const city of state.cities) { if (city.civ !== 0) continue; const stock = state.getCityResourceStock(city); stock.steel = 1e9; stock.hightech = 1e6; stock.energy = 1e9; } const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const site = state.constructionSiteAt(coords); this.assertNotNull(site, "a site is opened on the tile"); this.assertEqual(site.civ, 0); this.assertEqual(site.id, "road"); this.assertFalse(state.roads.has(key(coords.x, coords.y)), "nothing raised yet"); // The locked prices are recorded from the market when the work was decided. this.assertApprox(site.prices.steel, state._marketUnitCost("steel"), 1e-9); this.assertNotNull(site.cityId, "an allied city was found to haul to"); } test_a_site_does_not_freeze_a_price_when_decided() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const site = state.constructionSiteAt(coords); const before = state._marketUnitCost("steel"); state.setResourcePrice("steel", state.getResourcePrice("steel") * 5); this.assertTrue(state._marketUnitCost("steel") > before, "the market moved"); // The stored figure is only a running quote; the next gather refreshes it. state._gatherSiteMaterials(site); this.assertApprox( site.prices.steel, state._marketUnitCost("steel"), 1e-6, "the site follows the market" ); } test_a_site_buys_from_its_hub_city_then_reaches_the_market() { const state = smallState(); const city = cityOf(state, 0); const stock = state.getCityResourceStock(city); stock.steel = 800; stock.hightech = 200; const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e9, needed: { steel: 1000, hightech: 100, energy: 0 }, }); state._gatherSiteMaterials(site); this.assertApprox(site.bought.steel, 1000, 1e-6, "all the steel was gathered"); this.assertApprox(site.bought.hightech, 100, 1e-6, "all the high-tech was gathered"); this.assertEqual(site.phase, "construction", "gathering is done, building begins"); this.assertFalse(site.stalled); this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials"); } test_a_site_files_what_it_bought_and_what_it_paid() { const state = smallState(); const city = cityOf(state, 0); state.getCityResourceStock(city).steel = 1000; const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e9, needed: { steel: 100, hightech: 0, energy: 0 }, }); state._gatherSiteMaterials(site); const report = state.budgetMonthReports(0).at(-1); const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild"); this.assertNotNull(tilebuild, "the tile-improvement expense is recorded"); const steel = tilebuild.buys.find((entry) => entry[0] === "steel"); this.assertNotNull(steel, "the steel it bought is broken out"); this.assertApprox(steel[3], 100, 1e-6, "the quantity bought travels with the money"); this.assertGreater(steel[1], 0, "and so does what it paid for it"); } test_a_day_that_buys_nothing_stalls_the_site() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); state.getCityResourceStock(city).steel = 1000; const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e9, needed: { steel: 100000, hightech: 0, energy: 0 }, }); // The first day buys what the store held. state._gatherSiteMaterials(site); this.assertGreater(site.bought.steel, 0, "some steel was bought"); this.assertFalse(site.stalled, "a purchase was made"); // The next day the store is empty, so nothing is bought and the site stalls. state._gatherSiteMaterials(site); this.assertTrue(site.stalled, "a day without a purchase stalls the work"); this.assertEqual(site.phase, "materials", "it never reached construction"); } test_a_stalled_site_tops_up_to_the_current_market() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 0, needed: { steel: 100, hightech: 0, energy: 0 }, stalled: true, }); // A token of stock in the hub city keeps the material reachable, so the // top-up applies; it is nowhere near enough to finish the work. state.getCityResourceStock(city).steel = 1; const treasury = 1e9; state.budgets.set(0, treasury); state._raiseSiteBudgetIfStalled(site); this.assertApprox( site.prices.steel, state._marketUnitCost("steel"), 1e-6, "the site prices at the live market, with no daily rise" ); this.assertGreater(site.budget, 0, "the money needed for the rest moved into the site"); this.assertLess(state.getBudget(0), treasury, "and came from the treasury"); } test_a_site_buys_at_the_live_market_not_a_stale_price() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 0, needed: { steel: 100, hightech: 0, energy: 0 }, }); // A stale, cheap price does not stop the site buying at today's market. site.prices.steel = state._marketUnitCost("steel") * 0.001; for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9; state.budgets.set(0, 1e15); state._gatherSiteMaterials(site); this.assertFalse(site.stalled, "the site did not hold out for the stale price"); this.assertEqual(site.phase, "construction", "it bought at the live market"); this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered"); } test_a_site_reports_the_live_market_price() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const site = state.constructionSiteAt(coords); const before = site.prices.steel; state.setResourcePrice("steel", state.getResourcePrice("steel") * 5); state._gatherSiteMaterials(site); this.assertApprox( site.prices.steel, state._marketUnitCost("steel"), 1e-6, "the site tracks the market" ); this.assertGreater(site.prices.steel, before, "and follows it up"); } test_a_reachable_supplier_is_bought_from_at_any_price() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 0, needed: { steel: 100, hightech: 0, energy: 0 }, }); site.prices.steel = state._marketUnitCost("steel") * 0.2; for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9; const nodes = state._resourceNodes(); const suppliers = state._nearbySuppliers(city, "steel", nodes); this.assertGreater(suppliers.length, 0, "a supplier is reachable"); for (const other of state.cities) state.getCityResourceStock(other).steel = 0; suppliers[0].stock.steel = 1e9; state.budgets.set(0, 1e15); state._gatherSiteMaterials(site); this.assertEqual(site.phase, "construction", "the site bought at the market"); this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered"); } test_an_unreachable_site_does_not_waste_money_on_a_rise() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 0, needed: { steel: 100, hightech: 0, energy: 0 }, stalled: true, }); const treasury = 1e9; state.budgets.set(0, treasury); const locked = site.prices.steel; state._raiseSiteBudgetIfStalled(site); this.assertEqual(site.stalled, true, "the work stays stalled"); this.assertApprox(site.budget, 0, 1e-6, "no money was moved"); this.assertApprox(site.prices.steel, locked, 1e-9, "and no price was raised"); this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury is untouched"); } test_cancelling_a_site_refunds_nothing() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const site = state.constructionSiteAt(coords); this.assertGreater(site.budget, 0, "the site was funded"); const treasury = state.getBudget(0); this.assertTrue(state.requestCancelSite(0, coords)); this.assertNull(state.constructionSiteAt(coords), "the site is gone"); this.assertFalse(state.roads.has(key(coords.x, coords.y)), "no improvement was raised"); this.assertApprox(state.getBudget(0), treasury, 1e-6, "the budget is not returned"); } test_a_site_can_be_cancelled_once_construction_begins() { const state = smallState(); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { budget: 1e6, needed: { steel: 10, hightech: 0, energy: 0 }, bought: { steel: 10, hightech: 0, energy: 0 }, phase: "construction", elapsedHours: 5, totalHours: 10, }); this.assertEqual(site.phase, "construction", "the materials are in and building has begun"); this.assertTrue(state.requestCancelSite(0, coords), "the work can still be called off"); this.assertNull(state.constructionSiteAt(coords), "the site is gone"); } test_only_the_owner_may_cancel_a_site() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); this.assertFalse(state.requestCancelSite(1, coords), "a foreign nation cannot cancel it"); this.assertNotNull(state.constructionSiteAt(coords)); } test_a_private_site_borrows_instead_of_touching_the_treasury() { const state = smallState(); const city = cityOf(state, 0); state.getCityResourceStock(city).steel = 1000; const coords = state.landCells.find((cell) => { const k = key(cell.x, cell.y); return state.civAt(cell) === 0 && !state.tileImprovements.has(k) && !state.constructionSiteAt(cell); }); const k = key(coords.x, coords.y); const agent = state._ensureBuildingAgent(k); agent.cash = 0; const treasury = 5e8; state.budgets.set(0, treasury); const site = makeSite(state, coords, { id: "steel_mill", cityId: city.id, budget: 0, private: true, needed: { steel: 100, hightech: 0, energy: 0 }, }); state._gatherSiteMaterials(site); this.assertApprox(site.bought.steel, 100, 1e-6, "the agent bought the steel"); this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury was not touched"); this.assertGreater(agent.debt, 0, "the agent borrowed the money"); const report = state.budgetMonthReports(0).at(-1); this.assertEqual( report.expenses.find((entry) => entry.id === "tilebuild"), undefined, "a private work files no public tile-improvement expense" ); } test_ordering_a_producer_spends_no_treasury_and_creates_its_agent() { const state = smallState(); state.tileImprovements.clear(); state.tileImprovementOwner.clear(); state.tileImprovementHp.clear(); if (state.buildingAgents) state.buildingAgents.clear(); for (const city of state.cities) { if (city.civ !== 0) continue; const stock = state.getCityResourceStock(city); stock.steel = 1e9; stock.hightech = 1e9; stock.energy = 1e9; } const treasury = 1e9; state.budgets.set(0, treasury); const coords = state.landCells.find( (cell) => state.civAt(cell) === 0 && !state.cityAt(cell) ); const k = key(coords.x, coords.y); this.assertTrue(state.requestBuildTileImprovement(0, coords, "steel_mill")); const agent = state.buildingAgents.get(k); this.assertNotNull(agent, "the producer's agent opens with the order"); this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury pays nothing"); this.assertGreater(agent.debt, 0, "the work is funded by a bank loan"); const report = state.budgetMonthReports(0).at(-1); this.assertEqual( report.expenses.find((entry) => entry.id === "tilebuild"), undefined, "nothing is filed as a public tile-improvement expense" ); } test_a_finished_site_keeps_its_leftover_as_improvement_cash() { const state = smallState(); const city = cityOf(state, 0); state.getCityResourceStock(city).steel = 1000; const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e6, needed: { steel: 10, hightech: 0, energy: 0 }, phase: "construction", totalHours: 0, }); // Buy the small material bill first, then let the work finish. state._gatherSiteMaterials(site); site.phase = "construction"; site.totalHours = 0; state._tickConstructionSites(); this.assertNull(state.constructionSiteAt(coords), "the work completed"); this.assertEqual(state.tileImprovements.get(key(coords.x, coords.y)), "fortifications"); this.assertGreater(state.improvementCashAt(coords), 0, "the leftover stays with the tile"); } test_a_works_reserve_pays_toward_its_own_upkeep() { const state = smallState(); const coords = bareOwnedLand(state); const k = soleWork(state, coords, "fortifications"); state.improvementCash.set(k, 1e9); const proto = tileImprovementById("fortifications"); const costs = protoUpkeep(proto, proto.buildCost || 0); const categories = { works: { steel: costs.steel, hightech: costs.hightech }, transport: { steel: 0, hightech: 0 }, }; state._applyWorksMaintenance(0, categories); this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent"); this.assertApprox(categories.works.steel, 0, 1e-6, "the work covered its own steel"); this.assertEqual(categories.works.hightech, 0, "and its high-tech"); } test_a_works_reserve_only_covers_what_its_money_reaches() { const state = smallState(); const coords = bareOwnedLand(state); const k = soleWork(state, coords, "fortifications"); const proto = tileImprovementById("fortifications"); const costs = protoUpkeep(proto, proto.buildCost || 0); const unit = state._marketUnitCost("steel"); // Half the day's steel bill, so only half the wear is covered. state.improvementCash.set(k, costs.steel * unit * 0.5); const categories = { works: { steel: costs.steel, hightech: costs.hightech }, transport: { steel: 0, hightech: 0 }, }; state._applyWorksMaintenance(0, categories); this.assertApprox(state.improvementCashAt(coords), 0, 1e-6, "the reserve ran dry"); this.assertApprox(categories.works.steel, costs.steel * 0.5, 1e-6, "the rest is still owed"); } test_a_transport_reserve_pays_its_own_steel() { const state = smallState(); const coords = bareOwnedLand(state); const k = key(coords.x, coords.y); state.roads.add(k); state.improvementCash.set(k, 1e9); const categories = { works: { steel: 0, hightech: 0 }, transport: { steel: RESOURCE_RULES.transportUpkeepSteel.road, hightech: 0 }, }; state._applyWorksMaintenance(0, categories); this.assertApprox(categories.transport.steel, 0, 1e-6, "the road covered its own wear"); this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent"); } test_a_stalled_site_knows_what_its_remaining_materials_cost() { const state = smallState(); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1000, needed: { steel: 100, hightech: 0, energy: 0 }, stalled: true, }); // The shortfall between the remaining materials at the site's own budgeted // prices and the money it still holds is what a raise must move in. const expected = 100 * site.prices.steel - 1000; this.assertApprox(state._siteTopUp(site), expected, 1e-6, "the site's shortfall"); this.assertApprox( state._siteTopUp(site, { ...site.prices, steel: site.prices.steel * 2 }), expected + 100 * site.prices.steel, 1e-6, "a higher price asks for more" ); } test_a_site_holding_enough_reports_no_top_up() { const state = smallState(); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { budget: 1e12, needed: { steel: 100, hightech: 0, energy: 0 }, stalled: true, }); this.assertEqual(state._siteTopUp(site), 0, "a funded site owes nothing"); } test_a_site_with_no_reachable_supplier_reports_it() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e9, needed: { steel: 100, hightech: 40, energy: 0 }, stalled: true, }); // Nothing within reach holds either material, so no budget can unstall it. const unreachable = state._siteUnreachableMaterials(site).slice().sort(); this.assertEqual(unreachable.join(","), "hightech,steel", "both materials are unreachable"); const snapshot = state.snapshot(0); const entry = (snapshot.constructionSites[0] || []).find( (row) => row.coords[0] === coords.x && row.coords[1] === coords.y ); this.assertEqual( entry.unreachable.slice().sort().join(","), "hightech,steel", "the panel is told no supplier is in reach" ); } test_a_reachable_supplier_clears_the_unreachable_flag() { const state = smallState(); clearMaterials(state); const city = cityOf(state, 0); const coords = bareOwnedLand(state); const site = makeSite(state, coords, { cityId: city.id, budget: 1e9, needed: { steel: 100, hightech: 0, energy: 0 }, stalled: true, }); this.assertEqual( state._siteUnreachableMaterials(site).join(","), "steel", "the empty market leaves steel unreachable" ); state.getCityResourceStock(city).steel = 1000; this.assertEqual( state._siteUnreachableMaterials(site).length, 0, "the hub city's own store counts as reachable" ); } test_the_snapshot_ships_the_sites_with_their_phase_and_budget() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = bareOwnedLand(state); this.assertTrue(state.requestBuildImprovement(0, coords, "road")); const snapshot = state.snapshot(0); const entry = (snapshot.constructionSites[0] || []).find( (site) => site.coords[0] === coords.x && site.coords[1] === coords.y ); this.assertNotNull(entry, "the site is in the snapshot"); this.assertEqual(entry.id, "road"); this.assertTrue(entry.phase === "materials" || entry.phase === "construction"); this.assertNotNull(entry.prices.steel, "the locked prices travel too"); this.assertNotNull(entry.cityId, "the haul city travels too"); this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier"); this.assertEqual("topUp" in entry, false, "no manual top-up is shipped any more"); } test_a_unit_order_gathers_its_materials_before_training() { const state = smallState(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); city.buildings[barracks] = 1; state.budgets.set(0, BIG_BUDGET); const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length; // Nothing to buy from anywhere, so the order can only gather, never finish. clearMaterials(state); this.assertTrue(state.requestTrain(city.id, infantry), "the order is accepted"); const entry = state.training.get(city.id)[0]; this.assertEqual(entry.kind, "unit"); this.assertEqual(entry.phase, "materials", "a unit starts by gathering, not training"); this.assertGreater(entry.needed.steel, 0, "it names the steel it must gather"); this.assertEqual(entry.bought.steel, 0, "no steel could be bought"); this.assertGreater(entry.budget, 0, "a material budget is reserved"); // A day passes and it is still gathering, with no new unit to show for it. for (let i = 0; i < 24; i++) state.advanceHour(); this.assertEqual(state.training.get(city.id)[0].phase, "materials", "still gathering"); this.assertEqual( state.units.filter((u) => u.civ === 0 && u.proto === infantry).length, before, "no unit was trained while the materials are missing" ); } test_a_supplied_unit_order_gathers_then_trains() { const state = smallState(); const city = cityOf(state, 0); const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); city.buildings[barracks] = 1; state.budgets.set(0, BIG_BUDGET); for (const other of state.cities) { if (other.civ !== 0) continue; const stock = state.getCityResourceStock(other); stock.steel = 1e9; stock.hightech = 1e6; } 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)); for (let i = 0; i < 200000 && state.training.size > 0; i++) 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 once its materials were in" ); } }