Files
Battle-for-Tismo/tests/game_state_buildings_test.js
T
adrien bd9b206708 Gave the new buildings real effects
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.
2026-09-21 11:38:01 +02:00

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);
}
}