446 lines
17 KiB
JavaScript
446 lines
17 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_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.1,
|
|
1e-6,
|
|
"the budgeted price rises a tenth"
|
|
);
|
|
this.assertGreater(site.budget, 0, "the matching money moved into the site");
|
|
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
|
|
}
|
|
|
|
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.1,
|
|
1e-6,
|
|
"the budgeted price rose a tenth"
|
|
);
|
|
// 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");
|
|
}
|
|
}
|