Files
Battle-for-Tismo/tests/construction_site_test.js
T
adrien c2f131da5c Turned tile improvements into construction sites with their own budgets
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.
2026-09-22 18:08:31 +02:00

283 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 { tileImprovementById, RESOURCE_RULES } from "../shared/data.js";
import { protoUpkeep } from "../shared/resources.js";
const BIG_BUDGET = 1.0e15;
// A bare owned land tile with no improvement and no work already on it.
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);
});
}
// Registers a construction site literal, so the site tick can be exercised
// without a full build order.
function makeSite(state, coords, overrides = {}) {
const k = key(coords.x, coords.y);
const site = {
coords: { x: coords.x, y: coords.y },
id: "fortifications",
civ: 0,
kind: "build",
targetLevel: 0,
phase: "materials",
budget: 0,
prices: state._lockedUnitPrices(),
needed: { steel: 0, hightech: 0, energy: 0 },
bought: { steel: 0, hightech: 0, energy: 0 },
elapsedHours: 0,
totalHours: 10,
cityId: null,
path: [],
stalled: false,
lastBuyDay: -1,
...overrides,
};
state.constructionSites.set(k, site);
return site;
}
// Empties every reachable store, so a site with no stock of its own buys
// nothing from the market either.
function clearMaterials(state) {
for (const city of state.cities) {
const stock = state.getCityResourceStock(city);
stock.steel = 0;
stock.hightech = 0;
stock.energy = 0;
}
if (state.resourceStock) {
for (const stock of state.resourceStock.values()) {
stock.steel = 0;
stock.hightech = 0;
stock.energy = 0;
}
}
}
export class ConstructionSiteTest extends TestCase {
test_an_order_opens_a_site_without_raising_the_improvement() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.steel = 1e9;
stock.hightech = 1e6;
stock.energy = 1e9;
}
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const site = state.constructionSiteAt(coords);
this.assertNotNull(site, "a site is opened on the tile");
this.assertEqual(site.civ, 0);
this.assertEqual(site.id, "road");
this.assertFalse(state.roads.has(key(coords.x, coords.y)), "nothing raised yet");
// The locked prices are recorded from the market when the work was decided.
this.assertApprox(site.prices.steel, state._marketUnitCost("steel"), 1e-9);
this.assertNotNull(site.cityId, "an allied city was found to haul to");
}
test_a_site_locks_its_prices_when_decided() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const site = state.constructionSiteAt(coords);
const locked = site.prices.steel;
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
this.assertApprox(site.prices.steel, locked, 1e-9, "the site keeps its locked price");
this.assertTrue(
state._marketUnitCost("steel") > locked,
"the market moved after the work was decided"
);
}
test_a_site_buys_from_its_hub_city_then_reaches_the_market() {
const state = smallState();
const city = cityOf(state, 0);
const stock = state.getCityResourceStock(city);
stock.steel = 800;
stock.hightech = 200;
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 1000, hightech: 100, energy: 0 },
});
state._gatherSiteMaterials(site);
this.assertApprox(site.bought.steel, 1000, 1e-6, "all the steel was gathered");
this.assertApprox(site.bought.hightech, 100, 1e-6, "all the high-tech was gathered");
this.assertEqual(site.phase, "construction", "gathering is done, building begins");
this.assertFalse(site.stalled);
this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials");
}
test_a_day_that_buys_nothing_stalls_the_site() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100000, hightech: 0, energy: 0 },
});
// The first day buys what the store held.
state._gatherSiteMaterials(site);
this.assertGreater(site.bought.steel, 0, "some steel was bought");
this.assertFalse(site.stalled, "a purchase was made");
// The next day the store is empty, so nothing is bought and the site stalls.
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "a day without a purchase stalls the work");
this.assertEqual(site.phase, "materials", "it never reached construction");
}
test_increasing_a_stalled_budget_reopens_the_work() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state.totalHours = 1;
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "no money and no purchase");
// Refill the store, then raise the budget to the current prices.
state.getCityResourceStock(city).steel = 1000;
const treasury = 1e9;
state.budgets.set(0, treasury);
this.assertTrue(state.requestIncreaseSiteBudget(0, coords));
this.assertFalse(site.stalled, "the work is unlocked");
this.assertApprox(site.bought.steel, 100, 1e-6, "the materials were bought");
this.assertLess(state.getBudget(0), treasury, "the treasury funded the increase");
}
test_cancelling_a_site_refunds_nothing() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const site = state.constructionSiteAt(coords);
this.assertGreater(site.budget, 0, "the site was funded");
const treasury = state.getBudget(0);
this.assertTrue(state.requestCancelSite(0, coords));
this.assertNull(state.constructionSiteAt(coords), "the site is gone");
this.assertFalse(state.roads.has(key(coords.x, coords.y)), "no improvement was raised");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the budget is not returned");
}
test_only_the_owner_may_increase_or_cancel_a_site() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
this.assertFalse(state.requestCancelSite(1, coords), "a foreign nation cannot cancel it");
this.assertFalse(state.requestIncreaseSiteBudget(1, coords), "nor raise its budget");
this.assertNotNull(state.constructionSiteAt(coords));
}
test_a_finished_site_keeps_its_leftover_as_improvement_cash() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e6,
needed: { steel: 10, hightech: 0, energy: 0 },
phase: "construction",
totalHours: 0,
});
// Buy the small material bill first, then let the work finish.
state._gatherSiteMaterials(site);
site.phase = "construction";
site.totalHours = 0;
state._tickConstructionSites();
this.assertNull(state.constructionSiteAt(coords), "the work completed");
this.assertEqual(state.tileImprovements.get(key(coords.x, coords.y)), "fortifications");
this.assertGreater(state.improvementCashAt(coords), 0, "the leftover stays with the tile");
}
test_a_works_reserve_pays_toward_its_own_upkeep() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
state.improvementCash.set(k, 1e9);
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const categories = {
works: { steel: costs.steel, hightech: costs.hightech },
transport: { steel: 0, hightech: 0 },
};
state._applyWorksMaintenance(0, categories);
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
this.assertApprox(categories.works.steel, 0, 1e-6, "the work covered its own steel");
this.assertEqual(categories.works.hightech, 0, "and its high-tech");
}
test_a_works_reserve_only_covers_what_its_money_reaches() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const unit = state._marketUnitCost("steel");
// Half the day's steel bill, so only half the wear is covered.
state.improvementCash.set(k, costs.steel * unit * 0.5);
const categories = {
works: { steel: costs.steel, hightech: costs.hightech },
transport: { steel: 0, hightech: 0 },
};
state._applyWorksMaintenance(0, categories);
this.assertApprox(state.improvementCashAt(coords), 0, 1e-6, "the reserve ran dry");
this.assertApprox(categories.works.steel, costs.steel * 0.5, 1e-6, "the rest is still owed");
}
test_a_transport_reserve_pays_its_own_steel() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.roads.add(k);
state.improvementCash.set(k, 1e9);
const categories = {
works: { steel: 0, hightech: 0 },
transport: { steel: RESOURCE_RULES.transportUpkeepSteel.road, hightech: 0 },
};
state._applyWorksMaintenance(0, categories);
this.assertApprox(categories.transport.steel, 0, 1e-6, "the road covered its own wear");
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
}
test_the_snapshot_ships_the_sites_with_their_phase_and_budget() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const snapshot = state.snapshot(0);
const entry = (snapshot.constructionSites[0] || []).find(
(site) => site.coords[0] === coords.x && site.coords[1] === coords.y
);
this.assertNotNull(entry, "the site is in the snapshot");
this.assertEqual(entry.id, "road");
this.assertTrue(entry.phase === "materials" || entry.phase === "construction");
this.assertNotNull(entry.prices.steel, "the locked prices travel too");
this.assertNotNull(entry.cityId, "the haul city travels too");
}
}