Files
Battle-for-Tismo/tests/game_state_buildings_test.js
T
adrien 7ea5373b60 Moved the economy onto resources and surfaced each building's material bill
Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight.
2026-09-22 23:31:36 +02:00

419 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);
// Construction is settled in materials; stock the city so both queued
// levels can gather without depending on the transport network.
solo.getCityResourceStock(soloCity).steel = 5_000_000;
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);
yard.getCityResourceStock(yardCity).steel = 5_000_000;
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);
}
}