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.
415 lines
19 KiB
JavaScript
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);
|
|
}
|
|
}
|