Files
Battle-for-Tismo/tests/game_state_buildings_test.js
T
adrien aa726ff431 Made queued buildings gather their materials and rise ten times faster
City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.

Buildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes.
2026-09-22 21:43:31 +02:00

415 lines
19 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 { EFFECT_RESEARCH } from "../shared/data.js";
import { tileFoodOutput } from "../shared/resources.js";
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.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 portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
// A naval city already starts with one port level.
this.assertEqual(city.buildings[portIndex], 1, "the seeded port is there");
city.buildings[portIndex] = 0;
state._gdpPerCapitaCache.clear();
const sample = state.regionTiles(city).find((coords) => !state.cityAt(coords)) || state.regionTiles(city)[0];
// Isolate the port's GDP bonus: the port also lifts food output, which
// feeds back into the production-based base. That is tested separately.
const foodMultiplier = state._regionFoodMultiplier;
state._regionFoodMultiplier = () => 1;
const before = state.getTileGdpPerCapita(sample);
city.buildings[portIndex] = 2;
state._gdpPerCapitaCache.clear();
const after = state.getTileGdpPerCapita(sample);
state._regionFoodMultiplier = foodMultiplier;
// +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_port_lifts_its_regions_food_output() {
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 portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
city.buildings[portIndex] = 0;
const before = state._regionProductionValue(city);
city.buildings[portIndex] = 1;
const after = state._regionProductionValue(city);
// The port adds 5% of the region's food output for each adjacent sea tile.
const foodValue = state.regionTiles(city).reduce((sum, coords) => {
const k = key(coords.x, coords.y);
const tile = state.tiles[k];
const population = state.tilePopulation.get(k) || 0;
if (population <= 0) return sum;
return sum + tileFoodOutput(tile) * state.getResourcePrice("food");
}, 0) * DAYS_PER_YEAR;
this.assertApprox(after - before, foodValue * 0.05 * sea, foodValue * 1e-6, "the port grows more food");
}
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_a_building_order_gathers_its_materials_before_building() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const city = cityOf(state, 0);
// Drain every own store and cut the network, so nothing can be gathered.
for (const own of state.cities) {
if (own.civ !== 0) continue;
const store = state.getCityResourceStock(own);
store.steel = 0;
store.hightech = 0;
}
state.roads.clear();
state.railways.clear();
this.assertTrue(state.requestBuild(city.id, index));
const queue = state.getTraining(city.id);
this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds");
this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started");
// Supply the steel; the next day the order can buy and start building.
state.getCityResourceStock(city).steel = 1.0e9;
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
this.assertEqual(queue[0].phase, "construction", "the bill is gathered");
this.advanceUntilIdle(state);
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
}
test_a_stalled_building_order_waits_for_materials() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const city = cityOf(state, 0);
// Nothing anywhere to buy: the order can only wait.
for (const own of state.cities) {
const store = state.getCityResourceStock(own);
store.steel = 0;
store.hightech = 0;
}
this.assertTrue(state.requestBuild(city.id, index));
for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour();
const entry = state.getTraining(city.id)[0];
this.assertEqual(entry.phase, "materials", "it is still gathering");
this.assertTrue(entry.stalled, "the entry reports being stalled");
this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built");
}
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);
// Stock the steel so the whole bill is drawn from the stores and the two
// orders can be compared cleanly.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e9;
}
const before = state.getBudget(0);
this.assertTrue(state.requestBuild(city.id, index));
const first = before - state.getBudget(0);
const mid = state.getBudget(0);
this.assertTrue(state.requestBuild(city.id, index));
// The second level (money and steel) is priced off the level the first
// leaves behind, so it costs twice the first.
this.assertApprox(mid - state.getBudget(0), first * 2, 1e-6);
this.advanceUntilIdle(state);
this.assertEqual(state.getCityBuildingLevel(city, index), 2);
}
test_request_build_goes_ahead_on_an_empty_treasury() {
const state = smallState();
state.budgets.set(0, 0);
const index = this.addBuilding(state, gdpBuilding());
// The government can always pay, running an overdraft with the central bank.
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
this.assertLess(state.getBudget(0), 0, "the treasury went into debt");
}
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);
// Stock the steel so the material bill is never the limit being tested.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e12;
}
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_removes_the_building_without_a_refund() {
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.assertApprox(state.getBudget(0), before, 1e-6, "downgrading refunds nothing");
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_material_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.getBuildingUpkeep(0);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
this.advanceUntilIdle(state);
// 5e-7 t of steel and 1e-3 kWh of energy per euro of the level's cost.
this.assertApprox(state.getBuildingUpkeep(0).steel, before.steel + 0.005, 1e-9);
this.assertApprox(state.getBuildingUpkeep(0).energy, before.energy + 10, 1e-9);
}
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");
// Upkeep lines are the market value of the materials the nation must buy.
const expected = state._upkeepCost(state.getBuildingUpkeep(0));
this.assertApprox(breakdown.buildingUpkeep.total, expected, 1e-6);
// The only direct money upkeep left is propaganda.
this.assertApprox(state.getPlayerUpkeep(0), breakdown.campaignUpkeep.total, 1e-9);
const expected_net = breakdown.income.total - breakdown.buildingUpkeep.total -
breakdown.unitUpkeep.total - breakdown.campaignUpkeep.total -
breakdown.transportUpkeep.total - breakdown.tileUpkeep.total;
this.assertApprox(breakdown.net, expected_net, 1e-6);
// Income is the trade tax collected over the day, as an hourly average.
this.assertApprox(breakdown.income.total, state.getCivHourlyIncome(0), 1e-6);
}
test_budget_breakdown_names_tax_sources() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = cityOf(state, 0);
state._recordTax(0, "sales", "steel", 240, city);
const breakdown = state.viewerStats(0).breakdown;
const source = breakdown.income.sources.find((s) => s.label === "National sales tax");
this.assertTrue(source, "the sales tax appears as an income source");
this.assertEqual(source.kind, "tax");
this.assertApprox(source.amount, 10);
this.assertApprox(breakdown.income.total, 10);
}
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);
}
}