The catalogue had grown a barracks, wartime shelters, an air-defense battery, a warm-water port and a shipyard, but only as data: infantry still trained without a barracks, shelters did nothing, and an airport was just an airbase. Each building now names the rule it grants with a `mechanic` id, so the simulation and the city panel describe the same thing without magic strings. - Barracks and shipyards open one more production line each. A line sticks to one entry at full rate, so two units build at once rather than the head absorbing every line; the base behaviour (one queue, overflow carrying into the next entry) is unchanged. - Wartime shelters keep 10% of civilians per level safe from bombardment and are very hard to knock down, and ranged fire can now destroy a building level outright, with no refund. Each building rolls its own chance per point of damage, from a generator seeded off the world so a bombardment replays identically. - Air defense fires one volley at a hostile aircraft the moment it enters the city tile, scaled by its level; a plane shot down never drops its bombs. - A port adds 1000 GDP per capita per level, per adjacent sea tile, to every tile in its region. - An airport draws a daily trickle of people from every other airport city in the world, scaled by the destination's level. - Wired the new effects into the city panel and taught the repeatable Industrial Automation to satisfy other technologies' prerequisites. Added tests for each mechanic and for the parallel lines.
328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH } from "../shared/data.js";
|
|
|
|
const BIG_BUDGET = 1.0e15;
|
|
|
|
export class GameStateBuildingsTest extends TestCase {
|
|
addBuilding(state, proto) {
|
|
state.protoBuildings.push(proto);
|
|
return state.protoBuildings.length - 1;
|
|
}
|
|
|
|
// Runs hours until every city's production queue has emptied.
|
|
advanceUntilIdle(state, max = 100000) {
|
|
for (let i = 0; i < max && state.training.size > 0; i++) state.advanceHour();
|
|
}
|
|
|
|
// The number of hours `advanceUntilIdle` had to run.
|
|
hoursUntilIdle(state, max = 100000) {
|
|
let hours = 0;
|
|
while (hours < max && state.training.size > 0) {
|
|
state.advanceHour();
|
|
hours += 1;
|
|
}
|
|
return hours;
|
|
}
|
|
|
|
test_barracks_adds_a_parallel_training_line() {
|
|
const radarId = "mobile_radar";
|
|
const oneLine = smallState();
|
|
oneLine.budgets.set(0, BIG_BUDGET);
|
|
const soloCity = cityOf(oneLine, 0);
|
|
const soloRadar = oneLine.protoUnits.findIndex((p) => p.id === radarId);
|
|
oneLine.requestTrain(soloCity.id, soloRadar);
|
|
oneLine.requestTrain(soloCity.id, soloRadar);
|
|
const sequential = this.hoursUntilIdle(oneLine);
|
|
|
|
const twoLines = smallState();
|
|
twoLines.budgets.set(0, BIG_BUDGET);
|
|
const city = cityOf(twoLines, 0);
|
|
grantBuilding(twoLines, city, "barracks");
|
|
const radar = twoLines.protoUnits.findIndex((p) => p.id === radarId);
|
|
twoLines.requestTrain(city.id, radar);
|
|
twoLines.requestTrain(city.id, radar);
|
|
const parallel = this.hoursUntilIdle(twoLines);
|
|
|
|
this.assertLess(parallel, sequential, "the barracks trains both at once");
|
|
}
|
|
|
|
test_barracks_lines_work_different_units_at_once() {
|
|
// Each line sticks to its own unit: a cheap unit queued behind a costly one
|
|
// still finishes first, instead of the head monopolising every line.
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "barracks");
|
|
const artillery = state.protoUnits.findIndex((p) => p.id === "artillery");
|
|
const radar = state.protoUnits.findIndex((p) => p.id === "mobile_radar");
|
|
this.assertTrue(state.requestTrain(city.id, artillery), "queue the costly unit");
|
|
this.assertTrue(state.requestTrain(city.id, radar), "queue the cheap unit behind it");
|
|
let radarHour = -1;
|
|
for (let hour = 1; hour <= 100000 && radarHour < 0; hour++) {
|
|
state.advanceHour();
|
|
if (state.units.some((u) => u.civ === 0 && u.proto === radar)) radarHour = hour;
|
|
if (!state.training.has(city.id)) break;
|
|
}
|
|
const queue = state.training.get(city.id) || [];
|
|
this.assertTrue(radarHour > 0, "the cheap unit is trained");
|
|
this.assertTrue(
|
|
queue.some((e) => e.kind === "unit" && e.protoIndex === artillery),
|
|
"the costly unit is still building when the cheap one lands"
|
|
);
|
|
}
|
|
|
|
test_shipyard_adds_a_parallel_building_queue() {
|
|
const solo = smallState();
|
|
solo.budgets.set(0, BIG_BUDGET);
|
|
const soloIndex = this.addBuilding(solo, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
|
const soloCity = cityOf(solo, 0);
|
|
solo.requestBuild(soloCity.id, soloIndex);
|
|
solo.requestBuild(soloCity.id, soloIndex);
|
|
const sequential = this.hoursUntilIdle(solo);
|
|
|
|
const yard = smallState();
|
|
yard.budgets.set(0, BIG_BUDGET);
|
|
const yardIndex = this.addBuilding(yard, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
|
const yardCity = cityOf(yard, 0);
|
|
grantBuilding(yard, yardCity, "shipyard");
|
|
yard.requestBuild(yardCity.id, yardIndex);
|
|
yard.requestBuild(yardCity.id, yardIndex);
|
|
const parallel = this.hoursUntilIdle(yard);
|
|
|
|
this.assertLess(parallel, sequential, "the shipyard builds both levels at once");
|
|
this.assertEqual(yard.getCityBuildingLevel(yardCity, yardIndex), 2);
|
|
}
|
|
|
|
test_infantry_needs_a_barracks_before_it_can_be_trained() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const city = cityOf(state, 0);
|
|
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
|
|
this.assertFalse(state.requestTrain(city.id, infantry), "no barracks, no infantry");
|
|
grantBuilding(state, city, "barracks");
|
|
this.assertTrue(state.requestTrain(city.id, infantry), "the barracks unlocks infantry");
|
|
}
|
|
|
|
test_port_adds_gdp_per_capita_to_its_whole_region() {
|
|
const state = smallState();
|
|
const city = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
|
|
this.assertNotNull(city, "the nation has a coastal city");
|
|
const sea = state.topology
|
|
.neighbours(city.coords.x, city.coords.y)
|
|
.filter((n) => {
|
|
const tile = state.tiles[key(n.x, n.y)];
|
|
return tile && tile.terrainClass === "Sea";
|
|
}).length;
|
|
this.assertGreater(sea, 0, "the city touches the sea");
|
|
const sample = state.regionTiles(city).find((coords) => !state.cityAt(coords)) || state.regionTiles(city)[0];
|
|
const before = state.getTileGdpPerCapita(sample);
|
|
|
|
grantBuilding(state, city, "port", 2);
|
|
state._gdpPerCapitaCache.clear();
|
|
const after = state.getTileGdpPerCapita(sample);
|
|
|
|
// +1000 per port level for each neighbouring sea tile, on every region tile.
|
|
this.assertApprox(after - before, 1000 * sea * 2, 1e-6, "the port lifts the whole region");
|
|
}
|
|
|
|
test_request_build_charges_budget_and_raises_the_level() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
const city = cityOf(state, 0);
|
|
const beforeBudget = state.getBudget(0);
|
|
const beforeGdp = state.getPlayerGdp(0);
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
this.assertLess(state.getBudget(0), beforeBudget, "cost was paid up front");
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 0, "not built until it is produced");
|
|
this.assertNotEmpty(state.getTraining(city.id), "the level waits in the production queue");
|
|
this.advanceUntilIdle(state);
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
|
|
this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied");
|
|
}
|
|
|
|
test_building_and_unit_share_one_queue_and_are_produced_in_order() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "barracks");
|
|
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
|
|
const beforeUnits = state.units.length;
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
this.assertTrue(state.requestTrain(city.id, infantry));
|
|
const queue = state.getTraining(city.id);
|
|
this.assertSize(queue, 2, "both orders sit in the same queue");
|
|
this.assertEqual(queue[0].kind, "building");
|
|
this.assertEqual(queue[1].kind, "unit");
|
|
this.advanceUntilIdle(state);
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
|
|
this.assertEqual(state.units.length, beforeUnits + 1, "the unit was produced too");
|
|
}
|
|
|
|
test_repeated_build_orders_price_the_queued_levels() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding({ baseCost: 100, costAlpha: 2.0 }));
|
|
const city = cityOf(state, 0);
|
|
const before = state.getBudget(0);
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
// 100 for the first level, 200 for the second.
|
|
this.assertApprox(before - state.getBudget(0), 300);
|
|
this.advanceUntilIdle(state);
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 2);
|
|
}
|
|
|
|
test_request_build_rejects_when_unaffordable() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 0);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
this.assertFalse(state.requestBuild(cityOf(state, 0).id, index));
|
|
}
|
|
|
|
test_request_build_has_no_level_cap() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
const city = cityOf(state, 0);
|
|
for (let i = 0; i < 20; i++) this.assertTrue(state.requestBuild(city.id, index));
|
|
this.advanceUntilIdle(state);
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 20);
|
|
this.assertTrue(state.requestBuild(city.id, index), "still upgradable past the old cap");
|
|
}
|
|
|
|
test_request_build_rejects_unknown_targets() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
this.assertFalse(state.requestBuild(99999, 0));
|
|
this.assertFalse(state.requestBuild(cityOf(state, 0).id, 9999));
|
|
}
|
|
|
|
test_request_demolish_refunds_and_removes_the_building() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
const city = cityOf(state, 0);
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
this.advanceUntilIdle(state);
|
|
const before = state.getBudget(0);
|
|
this.assertTrue(state.requestDemolish(city.id, index));
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
|
|
this.assertGreater(state.getBudget(0), before, "refunded");
|
|
this.assertTrue(state.requestDemolish(city.id, index));
|
|
this.assertEqual(state.getCityBuildingLevel(city, index), 0);
|
|
this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish");
|
|
}
|
|
|
|
test_building_upkeep_is_added_to_player_upkeep() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(
|
|
state,
|
|
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0 })
|
|
);
|
|
const before = state.getPlayerUpkeep(0);
|
|
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
|
|
this.advanceUntilIdle(state);
|
|
this.assertApprox(state.getPlayerUpkeep(0), before + 100);
|
|
}
|
|
|
|
test_research_buildings_accumulate_points_each_hour() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const building = gdpBuilding({ baseCost: 0, effects: [] });
|
|
building.effects.push({ stat: EFFECT_RESEARCH, baseValue: 2.0, exponent: 1.0 });
|
|
const index = this.addBuilding(state, building);
|
|
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
|
|
// Research only accumulates into a focused technology, so focus one.
|
|
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
|
|
this.assertTrue(state.requestFocusTechnology(0, method));
|
|
this.assertEqual(state.getTechnologyProgress(0), 0);
|
|
state.advanceHour();
|
|
const first = state.getTechnologyProgress(0);
|
|
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
|
|
this.assertApprox(first - governmentRate, 2.0);
|
|
state.advanceHour();
|
|
this.assertApprox(state.getTechnologyProgress(0) - first - governmentRate, 2.0);
|
|
}
|
|
|
|
test_viewer_stats_split_income_and_upkeep() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(
|
|
state,
|
|
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0, effects: [] })
|
|
);
|
|
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
|
|
this.advanceUntilIdle(state);
|
|
const breakdown = state.viewerStats(0).breakdown;
|
|
this.assertHas(breakdown, "income");
|
|
this.assertHas(breakdown, "buildingUpkeep");
|
|
this.assertHas(breakdown, "unitUpkeep");
|
|
this.assertHas(breakdown, "transportUpkeep");
|
|
this.assertApprox(breakdown.buildingUpkeep.total, 100);
|
|
this.assertApprox(
|
|
breakdown.unitUpkeep.total,
|
|
state.getPlayerUpkeep(0) -
|
|
breakdown.buildingUpkeep.total -
|
|
breakdown.campaignUpkeep.total -
|
|
breakdown.transportUpkeep.total
|
|
);
|
|
const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
|
this.assertApprox(breakdown.income.total, expected);
|
|
}
|
|
|
|
test_budget_breakdown_names_income_sources() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const effect = { stat: EFFECT_BUDGET_INCOME, baseValue: 0.05, exponent: 1.0 };
|
|
const index = this.addBuilding(
|
|
state,
|
|
gdpBuilding({ baseCost: 0, effects: [effect] })
|
|
);
|
|
const city = cityOf(state, 0);
|
|
this.assertTrue(state.requestBuild(city.id, index));
|
|
const breakdown = state.viewerStats(0).breakdown;
|
|
const source = breakdown.income.sources.find(
|
|
(s) => s.label === "Test market" && s.cityId === city.id
|
|
);
|
|
this.assertTrue(source, "the building appears as an income source");
|
|
this.assertEqual(source.kind, "building");
|
|
this.assertApprox(source.effect, 0.05);
|
|
// A building's bonus is measured against its own region, not the nation.
|
|
const region = breakdown.income.regions.find((r) => r.cityId === city.id);
|
|
this.assertApprox(source.amount, region.base * 0.05);
|
|
this.assertApprox(
|
|
breakdown.income.modifier,
|
|
(breakdown.income.total - breakdown.income.base) / breakdown.income.base
|
|
);
|
|
this.assertApprox(breakdown.income.total, breakdown.income.base * (1 + breakdown.income.modifier));
|
|
}
|
|
|
|
test_snapshot_exposes_buildings_and_resources() {
|
|
const state = smallState();
|
|
const snapshot = state.snapshot(0);
|
|
this.assertSize(snapshot.buildings, state.protoBuildings.length);
|
|
this.assertHas(snapshot, "focusTechnology");
|
|
this.assertHas(snapshot, "culture");
|
|
for (const city of snapshot.cities) this.assertHas(city, "buildings");
|
|
this.assertHas(snapshot.viewerStats, "researchRate");
|
|
this.assertHas(snapshot.viewerStats, "culture");
|
|
}
|
|
|
|
test_snapshot_serialises_city_building_levels() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const index = this.addBuilding(state, gdpBuilding());
|
|
const cityId = cityOf(state, 0).id;
|
|
this.assertTrue(state.requestBuild(cityId, index));
|
|
this.advanceUntilIdle(state);
|
|
const city = state.snapshot(0).cities.find((c) => c.id === cityId);
|
|
this.assertEqual(city.buildings[index], 1);
|
|
}
|
|
}
|