Files
Battle-for-Tismo/tests/construction_site_test.js
T
adrien 49613f2a1c Made training gather materials, fixed the trade graph, and banked money gifts
Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.

The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.

Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.

Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
2026-09-24 21:20:47 +02:00

618 lines
24 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);
});
}
// Makes `id` the only tile improvement in the world, so the works-upkeep math
// is not disturbed by the producers the world opens with.
function soleWork(state, coords, id) {
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, id);
state.tileImprovementOwner.set(k, 0);
return k;
}
// 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_does_not_freeze_a_price_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 before = state._marketUnitCost("steel");
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
this.assertTrue(state._marketUnitCost("steel") > before, "the market moved");
// The stored figure is only a running quote; the next gather refreshes it.
state._gatherSiteMaterials(site);
this.assertApprox(
site.prices.steel,
state._marketUnitCost("steel"),
1e-6,
"the site follows the market"
);
}
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_site_files_what_it_bought_and_what_it_paid() {
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: 1e9,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
const report = state.budgetMonthReports(0).at(-1);
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
this.assertNotNull(tilebuild, "the tile-improvement expense is recorded");
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
this.assertNotNull(steel, "the steel it bought is broken out");
this.assertApprox(steel[3], 100, 1e-6, "the quantity bought travels with the money");
this.assertGreater(steel[1], 0, "and so does what it paid for it");
}
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_site_tops_up_to_the_current_market() {
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
// top-up applies; it is nowhere near enough to finish the work.
state.getCityResourceStock(city).steel = 1;
const treasury = 1e9;
state.budgets.set(0, treasury);
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
state._marketUnitCost("steel"),
1e-6,
"the site prices at the live market, with no daily rise"
);
this.assertGreater(site.budget, 0, "the money needed for the rest moved into the site");
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
}
test_a_site_buys_at_the_live_market_not_a_stale_price() {
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 stale, cheap price does not stop the site buying at today's market.
site.prices.steel = state._marketUnitCost("steel") * 0.001;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertFalse(site.stalled, "the site did not hold out for the stale price");
this.assertEqual(site.phase, "construction", "it bought at the live market");
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
}
test_a_site_reports_the_live_market_price() {
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 before = site.prices.steel;
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
state._gatherSiteMaterials(site);
this.assertApprox(
site.prices.steel,
state._marketUnitCost("steel"),
1e-6,
"the site tracks the market"
);
this.assertGreater(site.prices.steel, before, "and follows it up");
}
test_a_reachable_supplier_is_bought_from_at_any_price() {
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 },
});
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.assertEqual(site.phase, "construction", "the site 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_private_site_borrows_instead_of_touching_the_treasury() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = state.landCells.find((cell) => {
const k = key(cell.x, cell.y);
return state.civAt(cell) === 0 && !state.tileImprovements.has(k)
&& !state.constructionSiteAt(cell);
});
const k = key(coords.x, coords.y);
const agent = state._ensureBuildingAgent(k);
agent.cash = 0;
const treasury = 5e8;
state.budgets.set(0, treasury);
const site = makeSite(state, coords, {
id: "steel_mill",
cityId: city.id,
budget: 0,
private: true,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
this.assertApprox(site.bought.steel, 100, 1e-6, "the agent bought the steel");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury was not touched");
this.assertGreater(agent.debt, 0, "the agent borrowed the money");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"a private work files no public tile-improvement expense"
);
}
test_ordering_a_producer_spends_no_treasury_and_creates_its_agent() {
const state = smallState();
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.steel = 1e9;
stock.hightech = 1e9;
stock.energy = 1e9;
}
const treasury = 1e9;
state.budgets.set(0, treasury);
const coords = state.landCells.find(
(cell) => state.civAt(cell) === 0 && !state.cityAt(cell)
);
const k = key(coords.x, coords.y);
this.assertTrue(state.requestBuildTileImprovement(0, coords, "steel_mill"));
const agent = state.buildingAgents.get(k);
this.assertNotNull(agent, "the producer's agent opens with the order");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury pays nothing");
this.assertGreater(agent.debt, 0, "the work is funded by a bank loan");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"nothing is filed as a public tile-improvement expense"
);
}
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 = soleWork(state, coords, "fortifications");
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 = soleWork(state, coords, "fortifications");
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");
}
test_a_unit_order_gathers_its_materials_before_training() {
const state = smallState();
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
city.buildings[barracks] = 1;
state.budgets.set(0, BIG_BUDGET);
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length;
// Nothing to buy from anywhere, so the order can only gather, never finish.
clearMaterials(state);
this.assertTrue(state.requestTrain(city.id, infantry), "the order is accepted");
const entry = state.training.get(city.id)[0];
this.assertEqual(entry.kind, "unit");
this.assertEqual(entry.phase, "materials", "a unit starts by gathering, not training");
this.assertGreater(entry.needed.steel, 0, "it names the steel it must gather");
this.assertEqual(entry.bought.steel, 0, "no steel could be bought");
this.assertGreater(entry.budget, 0, "a material budget is reserved");
// A day passes and it is still gathering, with no new unit to show for it.
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertEqual(state.training.get(city.id)[0].phase, "materials", "still gathering");
this.assertEqual(
state.units.filter((u) => u.civ === 0 && u.proto === infantry).length,
before,
"no unit was trained while the materials are missing"
);
}
test_a_supplied_unit_order_gathers_then_trains() {
const state = smallState();
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
city.buildings[barracks] = 1;
state.budgets.set(0, BIG_BUDGET);
for (const other of state.cities) {
if (other.civ !== 0) continue;
const stock = state.getCityResourceStock(other);
stock.steel = 1e9;
stock.hightech = 1e6;
}
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length;
this.assertTrue(state.requestTrain(city.id, infantry));
for (let i = 0; i < 200000 && state.training.size > 0; i++) state.advanceHour();
this.assertFalse(state.training.has(city.id), "the order finished");
this.assertEqual(
state.units.filter((u) => u.civ === 0 && u.proto === infantry).length,
before + 1,
"the unit was trained once its materials were in"
);
}
}