Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve. That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone. Bundles the in-progress chart sizing for the central bank panel.
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import { TRANSPORT_BY_ID, TILE_IMPROVEMENT_BY_ID } from "../shared/data/improvements.js";
|
|
import { RESOURCE_RULES } from "../shared/data/resources.js";
|
|
import { tileImprovementResourceCost } from "../shared/resources.js";
|
|
|
|
const BIG_BUDGET = 10_000_000_000;
|
|
|
|
// A land tile owned by civ 0 that carries no transport improvement yet and is
|
|
// not already the site of a work.
|
|
function bareOwnedLand(state, civ = 0) {
|
|
return state.landCells.find((coords) => {
|
|
const k = key(coords.x, coords.y);
|
|
return state.civAt(coords) === civ &&
|
|
!state.roads.has(k) &&
|
|
!state.railways.has(k) &&
|
|
!state.constructionSiteAt(coords);
|
|
});
|
|
}
|
|
|
|
// Stocks every owned city so the material bill can be drawn from the stores.
|
|
function stockMaterials(state, civ = 0) {
|
|
for (const city of state.cities) {
|
|
if (city.civ !== civ) continue;
|
|
const stock = state.getCityResourceStock(city);
|
|
stock.steel = 1e9;
|
|
stock.hightech = 1e6;
|
|
stock.energy = 1e9;
|
|
}
|
|
}
|
|
|
|
// Runs the clock until every construction site has resolved (or the bound is
|
|
// hit, so a stalled site fails the caller's assertion rather than hanging).
|
|
function completeSites(state, maxHours = 50000) {
|
|
for (let i = 0; i < maxHours && state.constructionSites.size > 0; i++) {
|
|
state.advanceHour();
|
|
}
|
|
}
|
|
|
|
export class ImprovementsTest extends TestCase {
|
|
test_building_a_road_opens_a_site_then_lays_the_tile() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
// Stock enough steel that the whole material bill is drawn from the stores;
|
|
// the treasury pays the build cost plus the steel it buys from the region.
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
this.assertNotNull(coords, "the map has a bare owned land tile");
|
|
const k = key(coords.x, coords.y);
|
|
const before = state.getBudget(0);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
this.assertNotNull(state.constructionSiteAt(coords), "a site is opened");
|
|
this.assertFalse(state.roads.has(k), "nothing is raised until the work is done");
|
|
// The forecast is handed to the site at once: the build cost plus the steel.
|
|
const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road);
|
|
const steelValue = materials.steel * state.getResourcePrice("steel");
|
|
this.assertApprox(
|
|
before - state.getBudget(0),
|
|
TRANSPORT_BY_ID.road.buildCost + steelValue,
|
|
1e-3,
|
|
"the forecast covers the build cost plus the steel the region supplied"
|
|
);
|
|
completeSites(state);
|
|
this.assertNull(state.constructionSiteAt(coords), "the work completed");
|
|
this.assertTrue(state.roads.has(k), "the road is on the tile");
|
|
this.assertFalse(state.railways.has(k), "it is not a railway");
|
|
}
|
|
|
|
test_a_railway_replaces_a_road_and_moves_twice_as_fast() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
this.assertNotNull(coords);
|
|
const k = key(coords.x, coords.y);
|
|
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
|
|
|
state.roads.add(k);
|
|
const roadHours = state._tileTravelHours(unit, coords);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
|
completeSites(state);
|
|
this.assertTrue(state.railways.has(k), "the railway is on the tile");
|
|
this.assertFalse(state.roads.has(k), "the road was replaced");
|
|
const railHours = state._tileTravelHours(unit, coords);
|
|
this.assertApprox(railHours * 2, roadHours, 1e-9, "a railway is 50% faster than a road");
|
|
}
|
|
|
|
test_a_railway_moves_four_times_as_fast_as_open_grass() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = state.landCells.find(
|
|
(c) => state.tiles[key(c.x, c.y)].terrainType === "Land" && state.civAt(c) === 0
|
|
);
|
|
this.assertNotNull(coords, "the map has owned grass");
|
|
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
|
state.roads.delete(key(coords.x, coords.y));
|
|
state.railways.delete(key(coords.x, coords.y));
|
|
const open = state._tileTravelHours(unit, coords);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
|
completeSites(state);
|
|
const rail = state._tileTravelHours(unit, coords);
|
|
this.assertApprox(rail * 4, open, 1e-9, "a railway is four times grass speed");
|
|
}
|
|
|
|
test_only_a_controlled_land_tile_may_be_improved() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const sea = Object.keys(state.tiles)
|
|
.map((k) => state.tiles[k])
|
|
.find((tile) => tile.terrainClass === "Sea");
|
|
this.assertNotNull(sea, "the map has a sea tile");
|
|
// The exact coords of the sea tile.
|
|
const seaEntry = Object.entries(state.tiles).find(([, tile]) => tile === sea);
|
|
const [sx, sy] = seaEntry[0].split(",").map(Number);
|
|
this.assertFalse(state.requestBuildImprovement(0, { x: sx, y: sy }, "road"), "no road at sea");
|
|
|
|
// A tile owned by another civilisation is off limits.
|
|
const foreign = state.landCells.find((c) => state.civAt(c) === 1);
|
|
if (foreign) {
|
|
this.assertFalse(
|
|
state.requestBuildImprovement(0, foreign, "road"),
|
|
"cannot build on a foreign tile"
|
|
);
|
|
}
|
|
// An unknown improvement id is refused.
|
|
const owned = bareOwnedLand(state);
|
|
this.assertFalse(state.requestBuildImprovement(0, owned, "monorail"));
|
|
}
|
|
|
|
test_the_same_improvement_cannot_be_built_twice() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
this.assertFalse(
|
|
state.requestBuildImprovement(0, coords, "road"),
|
|
"a work is already under way"
|
|
);
|
|
completeSites(state);
|
|
const after = state.getBudget(0);
|
|
this.assertFalse(state.requestBuildImprovement(0, coords, "road"), "already a road");
|
|
this.assertApprox(state.getBudget(0), after, 1e-9, "nothing was charged");
|
|
}
|
|
|
|
test_a_road_can_be_built_on_an_empty_treasury() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 0);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
// The government can always pay, so the work goes ahead on borrowed money.
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
this.assertLess(state.getBudget(0), 0, "the forecast became central-bank debt");
|
|
completeSites(state);
|
|
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
|
|
}
|
|
|
|
test_maintenance_counts_roads_and_railways_the_nation_holds() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
const before = state.getTransportUpkeep(0);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
completeSites(state);
|
|
this.assertApprox(
|
|
state.getTransportUpkeep(0) - before,
|
|
RESOURCE_RULES.transportUpkeepSteel.road,
|
|
1e-9,
|
|
"a road adds its daily steel wear"
|
|
);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
|
completeSites(state);
|
|
this.assertApprox(
|
|
state.getTransportUpkeep(0) - before,
|
|
RESOURCE_RULES.transportUpkeepSteel.railway,
|
|
1e-9,
|
|
"the railway replaces the road's wear"
|
|
);
|
|
}
|
|
|
|
test_the_snapshot_ships_railways() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
|
completeSites(state);
|
|
const snapshot = state.snapshot(0);
|
|
this.assertTrue(
|
|
snapshot.railways.some(([x, y]) => x === coords.x && y === coords.y),
|
|
"the built railway is in the snapshot"
|
|
);
|
|
this.assertEqual(
|
|
snapshot.versions.improvements,
|
|
state._improvementVersion,
|
|
"the improvement version is shipped"
|
|
);
|
|
}
|
|
|
|
test_an_improvement_can_be_removed_for_free() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
const k = key(coords.x, coords.y);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
|
completeSites(state);
|
|
const before = state.getBudget(0);
|
|
this.assertTrue(state.requestRemoveImprovement(0, coords), "the railway is removed");
|
|
this.assertFalse(state.railways.has(k), "the tile is bare again");
|
|
this.assertApprox(state.getBudget(0), before, 1e-9, "removal is free");
|
|
this.assertFalse(state.requestRemoveImprovement(0, coords), "nothing left to remove");
|
|
}
|
|
|
|
test_only_the_controller_may_remove_an_improvement() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
stockMaterials(state);
|
|
const coords = bareOwnedLand(state);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
completeSites(state);
|
|
this.assertFalse(
|
|
state.requestRemoveImprovement(1, coords),
|
|
"another nation cannot tear up the road"
|
|
);
|
|
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
|
|
}
|
|
|
|
test_a_city_is_automatically_improved_with_a_railway() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const city = cityOf(state, 0);
|
|
const coords = { x: city.coords.x, y: city.coords.y };
|
|
const k = key(coords.x, coords.y);
|
|
this.assertTrue(state.railways.has(k), "a city carries a railway");
|
|
this.assertFalse(state.roads.has(k), "not a road");
|
|
}
|
|
|
|
test_a_tile_work_costs_steel_energy_and_high_tech() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1.0e15);
|
|
// Enough stores that the whole material bill is drawn from them; the
|
|
// treasury pays the build cost plus the steel and high-tech it buys from the
|
|
// region. Construction electricity is drawn from the grid as it runs.
|
|
stockMaterials(state);
|
|
const coords = state.landCells.find((cell) => {
|
|
const k = key(cell.x, cell.y);
|
|
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
|
});
|
|
this.assertNotNull(coords, "the player owns a bare land tile");
|
|
// A radar tower is advanced, so it also spends high-tech.
|
|
const radar = TILE_IMPROVEMENT_BY_ID.radar_tower;
|
|
const materials = tileImprovementResourceCost(radar);
|
|
this.assertGreater(materials.steel, 0, "steel is spent");
|
|
this.assertGreater(materials.energy, 0, "energy is drawn");
|
|
this.assertGreater(materials.hightech, 0, "high-tech is spent");
|
|
const before = state.getBudget(0);
|
|
this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
|
|
const steelValue = materials.steel * state.getResourcePrice("steel");
|
|
const techValue = materials.hightech * state.getResourcePrice("hightech");
|
|
this.assertApprox(
|
|
before - state.getBudget(0),
|
|
radar.buildCost + steelValue + techValue,
|
|
1e-3,
|
|
"the build cost plus the materials the region supplied"
|
|
);
|
|
}
|
|
}
|