- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red. - Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region. - A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything. - The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it. - Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice. - The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
907 lines
37 KiB
JavaScript
907 lines
37 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf, grantBuilding } 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();
|
|
const city = cityOf(state, 0);
|
|
const store = state.getCityResourceStock(city);
|
|
store.food = 1000;
|
|
store.steel = 2000;
|
|
store.hightech = 50;
|
|
store.luxury = 10;
|
|
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");
|
|
}
|
|
|
|
// ------------------------------------------------------- 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, "market", 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, "market", 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);
|
|
const city = cityOf(state, 0);
|
|
// 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();
|
|
const unit = state.units[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
|
|
);
|
|
}
|
|
}
|