Files
Battle-for-Tismo/tests/construction_site_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

479 lines
18 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,
budgetRaises: 0,
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_a_stalled_budget_climbs_on_its_own() {
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 },
stalled: true,
});
// A token of stock in the hub city keeps the material reachable, so the
// automatic rise applies; it is nowhere near enough to finish the work.
state.getCityResourceStock(city).steel = 1;
const treasury = 1e9;
state.budgets.set(0, treasury);
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.01,
1e-6,
"the first day's budgeted price rises 1%"
);
this.assertGreater(site.budget, 0, "the matching money moved into the site");
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
}
test_the_budget_rise_doubles_every_day() {
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 },
});
// A supplier far out of reach in price keeps the site stalling as it climbs.
site.prices.steel = state._marketUnitCost("steel") * 0.001;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
const nodes = state._resourceNodes();
const suppliers = state._nearbySuppliers(city, "steel", nodes);
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
suppliers[0].stock.steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "the locked price is too low to buy at");
// The rises compound: 1%, then 2%, then 4%, ...
let prev = site.prices.steel;
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
const expected = prev * (1 + 0.01 * 2 ** day);
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
prev = site.prices.steel;
}
}
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
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 },
});
// A supplier sits within reach, but its delivered price is far above the
// site's locked price, so it takes several days of rises to afford it.
site.prices.steel = state._marketUnitCost("steel") * 0.2;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
const nodes = state._resourceNodes();
const suppliers = state._nearbySuppliers(city, "steel", nodes);
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
suppliers[0].stock.steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "the locked price is too low to buy at");
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.01,
1e-6,
"the first day's budgeted price rises 1%"
);
// Keep letting the budget climb; it must eventually reach the supplier.
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
}
this.assertEqual(site.phase, "construction", "the site eventually bought at the market");
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
}
test_an_unreachable_site_does_not_waste_money_on_a_rise() {
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 },
stalled: true,
});
const treasury = 1e9;
state.budgets.set(0, treasury);
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertEqual(site.stalled, true, "the work stays stalled");
this.assertApprox(site.budget, 0, 1e-6, "no money was moved");
this.assertApprox(site.prices.steel, locked, 1e-9, "and no price was raised");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury is untouched");
}
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_a_site_can_be_cancelled_once_construction_begins() {
const state = smallState();
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
budget: 1e6,
needed: { steel: 10, hightech: 0, energy: 0 },
bought: { steel: 10, hightech: 0, energy: 0 },
phase: "construction",
elapsedHours: 5,
totalHours: 10,
});
this.assertEqual(site.phase, "construction", "the materials are in and building has begun");
this.assertTrue(state.requestCancelSite(0, coords), "the work can still be called off");
this.assertNull(state.constructionSiteAt(coords), "the site is gone");
}
test_only_the_owner_may_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.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_a_stalled_site_knows_what_its_remaining_materials_cost() {
const state = smallState();
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1000,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
// The shortfall between the remaining materials at the site's own budgeted
// prices and the money it still holds is what a raise must move in.
const expected = 100 * site.prices.steel - 1000;
this.assertApprox(state._siteTopUp(site), expected, 1e-6, "the site's shortfall");
this.assertApprox(
state._siteTopUp(site, { ...site.prices, steel: site.prices.steel * 2 }),
expected + 100 * site.prices.steel,
1e-6,
"a higher price asks for more"
);
}
test_a_site_holding_enough_reports_no_top_up() {
const state = smallState();
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
budget: 1e12,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
this.assertEqual(state._siteTopUp(site), 0, "a funded site owes nothing");
}
test_a_site_with_no_reachable_supplier_reports_it() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 40, energy: 0 },
stalled: true,
});
// Nothing within reach holds either material, so no budget can unstall it.
const unreachable = state._siteUnreachableMaterials(site).slice().sort();
this.assertEqual(unreachable.join(","), "hightech,steel", "both materials are unreachable");
const snapshot = state.snapshot(0);
const entry = (snapshot.constructionSites[0] || []).find(
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
);
this.assertEqual(
entry.unreachable.slice().sort().join(","),
"hightech,steel",
"the panel is told no supplier is in reach"
);
}
test_a_reachable_supplier_clears_the_unreachable_flag() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
this.assertEqual(
state._siteUnreachableMaterials(site).join(","),
"steel",
"the empty market leaves steel unreachable"
);
state.getCityResourceStock(city).steel = 1000;
this.assertEqual(
state._siteUnreachableMaterials(site).length,
0,
"the hub city's own store counts as reachable"
);
}
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");
this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier");
this.assertEqual("topUp" in entry, false, "no manual top-up is shipped any more");
}
}