Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve. That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone. Bundles the in-progress chart sizing for the central bank panel.
975 lines
40 KiB
JavaScript
975 lines
40 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_BUILDINGS,
|
|
EFFECT_RESEARCH,
|
|
technologyCost,
|
|
} from "../shared/data.js";
|
|
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
import {
|
|
formatEnergy,
|
|
formatResourceAmount,
|
|
foodNeedPerDay,
|
|
steelNeedPerDay,
|
|
luxuryNeedPerDay,
|
|
foodMonthlyTarget,
|
|
cityEnergyPerDay,
|
|
deliveryEnergyKwh,
|
|
deliveryEnergyForResource,
|
|
nextMarketPrice,
|
|
} from "../shared/resources.js";
|
|
|
|
function freeLandTile(state, civ = 0) {
|
|
for (const coords of state._territoryByCiv.get(civ) || []) {
|
|
if (state.cityAt(coords)) continue;
|
|
if (state.tileImprovements.has(key(coords.x, coords.y))) continue;
|
|
if (state._isLand(coords)) return coords;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function placeResourceBuilding(state, coords, id, civ = 0) {
|
|
const k = key(coords.x, coords.y);
|
|
state.tileImprovements.set(k, id);
|
|
state.tileImprovementOwner.set(k, civ);
|
|
state.tileImprovementHp.set(k, 1000);
|
|
state._ensureResourceStock(k);
|
|
return k;
|
|
}
|
|
|
|
export class ResourceRulesTest extends TestCase {
|
|
test_energy_formats_in_friendly_units() {
|
|
this.assertEqual(formatEnergy(500), "500 kWh");
|
|
this.assertTrue(formatEnergy(1_500).includes("MWh"));
|
|
this.assertTrue(formatEnergy(2_500_000).includes("GWh"));
|
|
this.assertTrue(formatEnergy(3_000_000_000).includes("TWh"));
|
|
}
|
|
|
|
test_material_units_step_up() {
|
|
this.assertEqual(formatResourceAmount("steel", 250), "250 t");
|
|
this.assertTrue(formatResourceAmount("steel", 2_500_000).includes("kt"));
|
|
this.assertTrue(formatResourceAmount("steel", 2_500_000_000).includes("Mt"));
|
|
this.assertTrue(formatResourceAmount("luxury", 2500).includes("ct"));
|
|
}
|
|
|
|
test_per_person_needs_follow_the_rules() {
|
|
// 1 kg a day of food and steel, 0.01 ct of luxury, for 2 million people.
|
|
this.assertApprox(foodNeedPerDay(2_000_000), 2000);
|
|
this.assertApprox(steelNeedPerDay(2_000_000), 2000);
|
|
this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000);
|
|
}
|
|
|
|
test_food_target_is_a_month_with_a_cushion() {
|
|
const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer;
|
|
this.assertApprox(foodMonthlyTarget(2_000_000), need);
|
|
}
|
|
|
|
test_city_energy_scales_with_gdp() {
|
|
this.assertApprox(cityEnergyPerDay(0), 0);
|
|
this.assertGreater(cityEnergyPerDay(1e11), 0);
|
|
}
|
|
|
|
test_delivery_energy_matches_the_roadmap_figures() {
|
|
// 1 tonne, 1 tile: 10 MWh by road, 1 MWh by train, 50 kWh by ship.
|
|
this.assertApprox(deliveryEnergyKwh(1, 1, "road"), 10_000);
|
|
this.assertApprox(deliveryEnergyKwh(1, 1, "train"), 1_000);
|
|
this.assertApprox(deliveryEnergyKwh(1, 1, "ship"), 50);
|
|
// Off-road trucks burn 50 MWh a tile, times the tile's movement cost.
|
|
this.assertApprox(deliveryEnergyKwh(1, 1, "truck", 4), 200_000);
|
|
}
|
|
|
|
test_energy_delivery_counts_ten_gwh_per_tonne() {
|
|
// Delivering 10 GWh of energy is one tonne, so its transport is the same
|
|
// as moving one tonne of steel the same distance.
|
|
const asEnergy = deliveryEnergyForResource("energy", 10_000_000, 1, "road");
|
|
const asSteel = deliveryEnergyForResource("steel", 1, 1, "road");
|
|
this.assertApprox(asEnergy, asSteel);
|
|
}
|
|
|
|
test_market_price_rises_on_shortage_and_falls_on_glut() {
|
|
const base = RESOURCE_MARKET_BASE.steel;
|
|
const scarce = nextMarketPrice(base, base, 10, 20);
|
|
const glut = nextMarketPrice(base, base, 20, 10);
|
|
this.assertGreater(scarce, base);
|
|
this.assertLess(glut, base);
|
|
}
|
|
}
|
|
|
|
export class ResourceModelTest extends TestCase {
|
|
test_cities_start_with_a_food_store() {
|
|
const state = smallState();
|
|
for (const city of state.cities) {
|
|
const stock = state.getCityResourceStock(city);
|
|
this.assertGreater(stock.food, 0, "the city opens with food");
|
|
this.assertGreater(stock.steel, 0, "the city opens with steel");
|
|
}
|
|
}
|
|
|
|
test_construction_resource_cost_follows_the_money_cost() {
|
|
const state = smallState();
|
|
const cost = state.constructionResourceCost({ buildCost: 1_000_000, effects: [] });
|
|
this.assertApprox(cost.steel, 1_000_000 * RESOURCE_RULES.steelPerBudgetEuro);
|
|
this.assertEqual(cost.hightech, 0);
|
|
const lab = state.protoBuildings.find((b) => b.id === "research_lab");
|
|
this.assertGreater(state.constructionResourceCost(lab, 0, lab.baseCost).hightech, 0);
|
|
}
|
|
|
|
test_resource_money_tracks_buying_and_selling() {
|
|
const state = smallState();
|
|
state._recordResourceSpend(0, "goods", 1000, "steel");
|
|
state._recordResourceEarn(0, "steel", 400);
|
|
const breakdown = state.budgetBreakdown(0);
|
|
const steel = breakdown.resourceMoney.find((entry) => entry.id === "steel");
|
|
this.assertNotNull(steel, "steel appears in the trade breakdown");
|
|
this.assertApprox(steel.bought, 1000);
|
|
this.assertApprox(steel.sold, 400);
|
|
}
|
|
|
|
test_external_trade_ledger_counts_buying_and_selling() {
|
|
const state = smallState();
|
|
state._recordExternalBuy(0, "steel", 100, 1_000);
|
|
state._recordExternalSell(0, "food", 40, 500);
|
|
const summary = state.getCivResourceSummary(0);
|
|
this.assertApprox(summary.external.buy.steel.qty, 100);
|
|
this.assertApprox(summary.external.buy.steel.value, 1_000);
|
|
this.assertApprox(summary.external.sell.food.qty, 40);
|
|
this.assertApprox(summary.external.sell.food.value, 500);
|
|
}
|
|
|
|
test_the_day_s_shortfalls_are_bought_abroad() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1.0e15);
|
|
for (const city of state.cities) {
|
|
if (city.civ !== 0) continue;
|
|
const stock = state.getCityResourceStock(city);
|
|
stock.food = 0;
|
|
stock.steel = 0;
|
|
stock.luxury = 0;
|
|
}
|
|
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
|
|
const summary = state.getCivResourceSummary(0);
|
|
const bought = Object.values(summary.external.buy)
|
|
.reduce((sum, entry) => sum + entry.qty, 0);
|
|
this.assertGreater(bought, 0, "the day's shortfalls were bought across the border");
|
|
const spent = Object.values(summary.external.buy)
|
|
.reduce((sum, entry) => sum + entry.value, 0);
|
|
this.assertGreater(spent, 0, "and the money it cost is recorded");
|
|
}
|
|
|
|
test_a_region_reports_what_it_ran_short_of() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
state._resourceShortages.set(city.id, { steel: 0.5 });
|
|
const shortages = state.getRegionShortages(city);
|
|
this.assertTrue(shortages.steel, "steel is reported");
|
|
this.assertApprox(shortages.steel.fraction, 0.5);
|
|
const population = state.getCityEconomy(city).population;
|
|
this.assertApprox(shortages.steel.amount, 0.5 * steelNeedPerDay(population));
|
|
}
|
|
|
|
test_the_snapshot_ships_each_region_s_shortages() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
state._resourceShortages.set(city.id, { food: 0.25 });
|
|
const snapshot = state.snapshot(0);
|
|
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
|
|
this.assertTrue(stats.shortage.food, "the shortage is shipped");
|
|
this.assertApprox(stats.shortage.food.fraction, 0.25);
|
|
}
|
|
|
|
test_a_research_building_without_high_tech_raises_a_notice() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "research_lab");
|
|
for (const own of state.cities) {
|
|
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
|
|
}
|
|
state._tickBuildings();
|
|
const alert = state._resourceAlertsFor(0)
|
|
.find((entry) => entry.resource === "hightech" && entry.region === city.name);
|
|
this.assertNotNull(alert, "the lab's missing high-tech is reported");
|
|
this.assertTrue(alert.building.includes("Research lab"), "the building is named");
|
|
}
|
|
|
|
test_a_power_starved_work_raises_a_notice() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const city = cityOf(state, 0);
|
|
const tile = freeLandTile(state, 0);
|
|
const k = placeResourceBuilding(state, tile, "steel_mill", 0);
|
|
const components = new Map([
|
|
[k, 0],
|
|
[key(city.coords.x, city.coords.y), 0],
|
|
]);
|
|
const ledger = new Map([[0, { civ: 0, industrialScale: 0.5 }]]);
|
|
state._noteGridPowerShortages(ledger, components);
|
|
const alert = state._resourceAlertsFor(0)
|
|
.find((entry) => entry.building === "Steel mill" && entry.resource === "energy");
|
|
this.assertNotNull(alert, "the idle work is reported");
|
|
this.assertEqual(alert.region, city.name);
|
|
}
|
|
|
|
test_region_resource_info_reports_reserves_value_and_consumption() {
|
|
const state = smallState();
|
|
// 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.steel, steelNeedPerDay(population));
|
|
this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population));
|
|
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
|
|
}
|
|
|
|
test_city_stats_ship_the_region_resources() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
state.getCityResourceStock(city).steel = 42;
|
|
const stats = state.snapshot(0).cityStats.find((entry) => entry.id === city.id);
|
|
this.assertNotNull(stats.resources, "the region's reserves travel in the snapshot");
|
|
this.assertEqual(stats.resources.stock.steel, 42);
|
|
this.assertTrue(stats.resources.value > 0, "the reserve carries a value");
|
|
}
|
|
|
|
test_shortage_notices_are_private_to_the_owner() {
|
|
const state = smallState();
|
|
state._noteResourceShortage(0, "University", "Paris", "hightech");
|
|
const mine = state.snapshot(0).viewerStats.resourceAlerts;
|
|
const theirs = state.snapshot(1).viewerStats.resourceAlerts;
|
|
this.assertSize(mine, 1, "the owner sees the notice");
|
|
this.assertSize(theirs, 0, "another nation does not");
|
|
this.assertEqual(mine[0].building, "University");
|
|
this.assertEqual(mine[0].resource, "hightech");
|
|
this.assertGreaterOrEqual(mine[0].day, 0);
|
|
}
|
|
|
|
test_national_gdp_per_capita_matches_the_regions() {
|
|
const state = smallState();
|
|
const snapshot = state.snapshot(0);
|
|
let gdp = 0;
|
|
let population = 0;
|
|
for (const city of state.cities) {
|
|
if (city.civ !== 0) continue;
|
|
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
|
|
gdp += stats.gdp;
|
|
population += stats.population;
|
|
}
|
|
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
|
|
}
|
|
|
|
test_construction_draws_steel_from_the_city_before_the_market() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const code = state.currencyOf(0).code;
|
|
const stock = state.getCityResourceStock(city);
|
|
stock.steel = 1000;
|
|
const cashBefore = state.getRegionCash(city).get(code) || 0;
|
|
const before = state.getBudget(0);
|
|
const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 });
|
|
this.assertTrue(ok);
|
|
this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store");
|
|
// The region is not charged: the state buys the steel from it.
|
|
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
|
|
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
|
|
this.assertApprox(
|
|
(state.getRegionCash(city).get(code) || 0) - cashBefore,
|
|
value,
|
|
1e-3,
|
|
"the region received the money"
|
|
);
|
|
}
|
|
|
|
test_drawing_a_national_store_pays_the_region() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const code = state.currencyOf(0).code;
|
|
state.getCityResourceStock(city).steel = 1000;
|
|
const cashBefore = state.getRegionCash(city).get(code) || 0;
|
|
const before = state.getBudget(0);
|
|
const payer = state._treasuryPayer(0);
|
|
const drawn = state._drawFromNearest(
|
|
0, city.coords, { steel: 400, hightech: 0 }, payer
|
|
);
|
|
this.assertApprox(drawn.steel, 400);
|
|
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
|
|
this.assertApprox(payer.total, value, 1e-3, "the treasury carried the bill");
|
|
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
|
|
this.assertApprox(
|
|
(state.getRegionCash(city).get(code) || 0) - cashBefore,
|
|
value,
|
|
1e-3,
|
|
"the region's private sector received it"
|
|
);
|
|
}
|
|
|
|
test_construction_buys_the_shortfall_from_a_reachable_region() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const city = cityOf(state, 0);
|
|
const seller = cityOf(state, 1);
|
|
// Every city of the nation must be empty, or the draw would find their
|
|
// stores before it reaches a foreign region.
|
|
for (const own of state.cities) {
|
|
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
|
|
}
|
|
state.getCityResourceStock(seller).steel = 1.0e9;
|
|
const before = state.getBudget(0);
|
|
const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 });
|
|
this.assertTrue(ok, "the steel was found across the border");
|
|
this.assertLess(state.getBudget(0), before, "the treasury paid for it");
|
|
this.assertLess(state.getCityResourceStock(seller).steel, 1.0e9, "the seller's store shrank");
|
|
}
|
|
|
|
test_research_spends_high_tech() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "research_lab");
|
|
const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
|
|
this.assertTrue(state.requestFocusTechnology(0, tech));
|
|
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
|
|
// With a store of high-tech the lab's one point an hour comes through.
|
|
for (const own of state.cities) {
|
|
if (own.civ === 0) state.getCityResourceStock(own).hightech = 1000;
|
|
}
|
|
const before = state.getTechnologyProgress(0, tech);
|
|
state.advanceHour();
|
|
const withTech = state.getTechnologyProgress(0, tech) - before;
|
|
this.assertApprox(withTech, governmentRate + 1.0, 1e-6);
|
|
// With no store, the lab's point is simply not produced.
|
|
for (const own of state.cities) {
|
|
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
|
|
}
|
|
const before2 = state.getTechnologyProgress(0, tech);
|
|
state.advanceHour();
|
|
const withoutTech = state.getTechnologyProgress(0, tech) - before2;
|
|
this.assertApprox(withoutTech, governmentRate, 1e-6);
|
|
}
|
|
|
|
test_a_power_plant_supplies_its_grid() {
|
|
const withPlant = smallState();
|
|
const tile = freeLandTile(withPlant, 0);
|
|
placeResourceBuilding(withPlant, tile, "coal_power_plant", 0);
|
|
withPlant._tickResources();
|
|
const stats = withPlant.resourceMarketStats.get("energy");
|
|
this.assertGreater(stats.supply, 0, "the plant fed the grid");
|
|
}
|
|
|
|
test_gdp_is_the_market_value_of_what_a_region_produces() {
|
|
const state = smallState();
|
|
const tile = freeLandTile(state, 0);
|
|
const region = state.regionCityAt(tile);
|
|
this.assertNotNull(region, "the tile belongs to a region");
|
|
const before = state._regionProductionValue(region);
|
|
placeResourceBuilding(state, tile, "steel_mill", 0);
|
|
const after = state._regionProductionValue(region);
|
|
const proto = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
|
|
const outputValue = proto.outputPerDay * state.getResourcePrice("steel") * DAYS_PER_YEAR;
|
|
this.assertApprox(after - before, outputValue, outputValue * 1e-6, "the mill's output is GDP");
|
|
}
|
|
|
|
test_a_steel_mill_consumes_energy_and_makes_steel() {
|
|
const state = smallState();
|
|
const plantTile = freeLandTile(state, 0);
|
|
placeResourceBuilding(state, plantTile, "coal_power_plant", 0);
|
|
// A second owned tile for the mill, away from the city.
|
|
const millTile = state._territoryByCiv.get(0).find(
|
|
(coords) => state._isLand(coords) && !state.cityAt(coords) &&
|
|
key(coords.x, coords.y) !== key(plantTile.x, plantTile.y)
|
|
);
|
|
const k = placeResourceBuilding(state, millTile, "steel_mill", 0);
|
|
state._tickResources();
|
|
const stock = state._ensureResourceStock(k);
|
|
this.assertGreater(stock.steel, 0, "the mill produced steel");
|
|
}
|
|
|
|
test_a_city_buys_from_a_neighbour_before_the_market() {
|
|
const state = smallState();
|
|
const cities = state.cities.filter((c) => c.civ === 0);
|
|
this.assertGreaterOrEqual(cities.length, 2, "the fixture has two cities");
|
|
const buyer = cities[0];
|
|
const seller = cities[1];
|
|
state.getCityResourceStock(seller).food = 1e9;
|
|
state.getCityResourceStock(buyer).food = 0;
|
|
const store = state.getCityResourceStock(buyer);
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, state._resourceNodes()
|
|
);
|
|
this.assertGreater(bought, 0, "the neighbour sold food");
|
|
this.assertGreater(store.food, 0, "the buyer received it");
|
|
this.assertTrue(
|
|
state.resourceLinks.some((link) => link.resource === "food"),
|
|
"the delivery is on the graph"
|
|
);
|
|
}
|
|
|
|
test_materials_come_from_home_regions_before_foreign_ones() {
|
|
const state = smallState();
|
|
const buyer = cityOf(state, 0);
|
|
const land = state.topology.neighbours(buyer.coords.x, buyer.coords.y)
|
|
.filter((n) => state._isLand(n));
|
|
this.assertGreaterOrEqual(land.length, 2, "the city has land neighbours");
|
|
for (const n of land) state.roads.add(key(n.x, n.y));
|
|
// Two reachable suppliers, home and foreign, both with a surplus. Home wins.
|
|
const homeNode = { coords: land[0], civ: 0, stock: { food: 1.0e9 }, kind: "building" };
|
|
const foreignNode = { coords: land[1], civ: 1, stock: { food: 1.0e9 }, kind: "building" };
|
|
const store = state.getCityResourceStock(buyer);
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, [homeNode, foreignNode]
|
|
);
|
|
this.assertGreater(bought, 0, "the order was filled");
|
|
this.assertApprox(homeNode.stock.food, 1.0e9 - 1000, 1e-6, "the home region supplied it");
|
|
this.assertApprox(foreignNode.stock.food, 1.0e9, 1e-6, "the foreign region was left alone");
|
|
}
|
|
|
|
test_without_a_market_unreachable_goods_are_not_bought() {
|
|
const state = smallState();
|
|
const buyer = cityOf(state, 0);
|
|
for (const [, stock] of state.resourceStock) stock.food = 0;
|
|
state.roads.clear();
|
|
state.railways.clear();
|
|
const store = state.getCityResourceStock(buyer);
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, state._resourceNodes(), { allowSea: false }
|
|
);
|
|
this.assertEqual(bought, 0, "no trade route, no goods");
|
|
this.assertEqual(store.food, 0, "and nothing arrives");
|
|
}
|
|
|
|
test_power_flows_between_reachable_grids() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const neighbour = state.topology.neighbours(city.coords.x, city.coords.y)
|
|
.find((n) => state._isLand(n) && state.civAt(n) === 0);
|
|
this.assertNotNull(neighbour, "the city has an owned land neighbour");
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
state.roads.add(nk);
|
|
const components = new Map([
|
|
[key(city.coords.x, city.coords.y), 0],
|
|
[nk, 1],
|
|
]);
|
|
const ledger = new Map([
|
|
[0, {
|
|
civ: 0, supply: 0, demand: 1000, imports: 0,
|
|
cityEnergy: 1000, converterEnergy: 0, foodEnergy: 0,
|
|
}],
|
|
[1, {
|
|
civ: 0, supply: 5000, demand: 0, imports: 0,
|
|
cityEnergy: 0, converterEnergy: 0, foodEnergy: 0,
|
|
}],
|
|
]);
|
|
state._tradeGridPower(ledger, components);
|
|
this.assertApprox(ledger.get(0).imports, 1000, 1e-6, "the deficit grid imported the power");
|
|
}
|
|
|
|
test_foreign_trade_runs_on_the_transport_network() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const buyer = cityOf(state, 0);
|
|
const seller = cityOf(state, 1);
|
|
state.getCityResourceStock(seller).food = 1e9;
|
|
state.getCityResourceStock(buyer).food = 0;
|
|
const store = state.getCityResourceStock(buyer);
|
|
const boughtOnNetwork = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, state._resourceNodes()
|
|
);
|
|
this.assertGreater(boughtOnNetwork, 0, "a foreign market on the network is reachable");
|
|
|
|
// Tear up every road and railway: the foreign market is now unreachable.
|
|
state.roads.clear();
|
|
state.railways.clear();
|
|
store.food = 0;
|
|
state.resourceLinks = [];
|
|
const boughtOffNetwork = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, state._resourceNodes()
|
|
);
|
|
this.assertEqual(boughtOffNetwork, 0, "no road, no foreign market");
|
|
}
|
|
|
|
test_cross_nation_deliveries_are_booked_as_imports_and_exports() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const buyer = cityOf(state, 0);
|
|
const seller = cityOf(state, 1);
|
|
state.getCityResourceStock(seller).food = 1e9;
|
|
state.getCityResourceStock(buyer).food = 0;
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
|
|
);
|
|
this.assertGreater(bought, 0, "the neighbour sold food");
|
|
this.assertApprox(state._civResourceTrade(0).imports.food, bought, 1e-6, "the buyer imported it");
|
|
this.assertApprox(state._civResourceTrade(1).exports.food, bought, 1e-6, "the seller exported it");
|
|
// The snapshot carries every nation's trade ledger, so the panel can rank
|
|
// the world's major exporters and importers.
|
|
const snapshot = state.snapshot(0);
|
|
this.assertSize(snapshot.resourceTrade, 2);
|
|
this.assertApprox(snapshot.resourceTrade[1].exports.food, bought, 1e-3);
|
|
}
|
|
|
|
test_a_shortage_costs_popularity() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const before = state.getCivPopularity(0);
|
|
state._resourceShortages.set(city.id, { steel: 1 });
|
|
state._applyResourceShortages(new Map());
|
|
this.assertLess(state.getCivPopularity(0), before, "a shortage hurt the government");
|
|
}
|
|
|
|
test_a_resource_building_cannot_stand_inside_a_city() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1e15);
|
|
const city = cityOf(state, 0);
|
|
this.assertFalse(
|
|
state.requestBuildTileImprovement(0, city.coords, "coal_power_plant"),
|
|
"a power plant belongs on open land"
|
|
);
|
|
const tile = freeLandTile(state, 0);
|
|
state.budgets.set(0, 1e15);
|
|
this.assertTrue(
|
|
state.requestBuildTileImprovement(0, tile, "coal_power_plant"),
|
|
"and is accepted on a controlled land tile"
|
|
);
|
|
}
|
|
|
|
test_snapshot_ships_the_market_and_the_delivery_graph() {
|
|
const state = smallState();
|
|
const snapshot = state.snapshot(0);
|
|
this.assertSize(snapshot.resourceMarket, 5);
|
|
for (const entry of snapshot.resourceMarket) {
|
|
this.assertHas(entry, "price");
|
|
this.assertGreater(entry.price, 0);
|
|
}
|
|
this.assertHas(snapshot.viewerStats, "resources");
|
|
this.assertHas(snapshot.resourceGraph, "nodes");
|
|
this.assertHas(snapshot.resourceGraph, "links");
|
|
}
|
|
|
|
// ------------------------------------------------- starting industry ----
|
|
|
|
test_the_opening_economy_is_seeded_with_covering_industry() {
|
|
const state = smallState();
|
|
this.assertGreater(state.tileImprovements.size, 0, "the world opens with resource works");
|
|
const components = state._resourceLandComponents();
|
|
const foodEnergy = state._foodEnergyByComponent(components);
|
|
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
|
const deficits = state._resourceSeedDeficits(civ);
|
|
this.assertTrue(deficits.steel <= 0, "steel production covers the people and the works");
|
|
this.assertTrue(deficits.luxury <= 0, "luxury production covers the people");
|
|
const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
|
|
this.assertEmpty(energy, "every grid has the power its cities and industry draw");
|
|
}
|
|
}
|
|
|
|
test_seeded_industry_is_connected_to_the_transport_network() {
|
|
const state = smallState();
|
|
this.assertGreater(state.tileImprovements.size, 0, "there are seeded works");
|
|
for (const k of state.tileImprovements.keys()) {
|
|
this.assertTrue(
|
|
state.roads.has(k) || state.railways.has(k),
|
|
"a seeded work sits on the transport network"
|
|
);
|
|
}
|
|
}
|
|
|
|
test_seeding_is_deterministic_from_the_world_seed() {
|
|
const first = smallState(["france", "britain"], 12345);
|
|
const again = smallState(["france", "britain"], 12345);
|
|
const other = smallState(["france", "britain"], 999);
|
|
const describe = (state) =>
|
|
Array.from(state.tileImprovements.entries())
|
|
.map(([k, id]) => `${k}:${id}`)
|
|
.sort()
|
|
.join("|");
|
|
this.assertEqual(describe(first), describe(again), "the same seed lays out the same works");
|
|
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
|
|
}
|
|
|
|
test_two_nations_open_with_a_chip_foundry_apart() {
|
|
const state = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
|
|
const fabs = [];
|
|
for (const [k, id] of state.tileImprovements) {
|
|
if (id !== "chip_foundry") continue;
|
|
fabs.push({ k, owner: state.tileImprovementOwner.get(k) });
|
|
}
|
|
this.assertSize(fabs, 2, "the world opens with exactly two chip foundries");
|
|
this.assertNotEqual(fabs[0].owner, fabs[1].owner, "the two foundries are in different nations");
|
|
for (const fab of fabs) {
|
|
this.assertTrue(
|
|
state.roads.has(fab.k) || state.railways.has(fab.k),
|
|
"a seeded foundry sits on the transport network"
|
|
);
|
|
}
|
|
// The pair is drawn from the world seed, so a replay lays them out again.
|
|
const again = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
|
|
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: with only two nations the tiny starting islands can leave
|
|
// one nation without the land a foundry's grid needs.
|
|
const state = defaultState(["france", "britain", "germany", "poland"]);
|
|
const fabs = [];
|
|
for (const [k, id] of state.tileImprovements) {
|
|
if (id === "chip_foundry") fabs.push(state.tileImprovementOwner.get(k));
|
|
}
|
|
this.assertSize(fabs, 2, "a full map opens with two chip foundries");
|
|
this.assertNotEqual(fabs[0], fabs[1], "the two foundries are in different nations");
|
|
// 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
|
|
);
|
|
}
|
|
}
|