355 lines
15 KiB
JavaScript
355 lines
15 KiB
JavaScript
// Simple construction introduced by 03-construction.md: an order starts at
|
|
// once, its material bill comes out of the nation's pool, the clock is set by
|
|
// the region's spare power, and every opening region keeps a fuel-free plant.
|
|
// The hard economy's gather-then-build path is exercised by
|
|
// `construction_site_test.js` and must stay untouched.
|
|
|
|
import { TestCase } from "./framework/test_case.js";
|
|
import { simpleState, cityOf, grantBuilding, smallConfig, SEED } from "./framework/helpers.js";
|
|
import { GameServer } from "../server/game_server.js";
|
|
import { key, parseKey } from "../shared/hex.js";
|
|
import {
|
|
SIMPLE_ECONOMY,
|
|
simpleConstructionHours,
|
|
tileImprovementById,
|
|
isResourceTileBuilding,
|
|
} from "../shared/data.js";
|
|
import { buildingBuildCost } from "../shared/rules.js";
|
|
|
|
// The first land tile a nation owns that carries nothing, not even a road, so a
|
|
// transport order cannot be refused for replacing the same improvement.
|
|
function bareOwnedLand(state, civ = 0) {
|
|
return state.landCells.find((coords) => {
|
|
const k = key(coords.x, coords.y);
|
|
return state.civAt(coords) === civ &&
|
|
!state.cityAt(coords) &&
|
|
!state.roads.has(k) &&
|
|
!state.railways.has(k) &&
|
|
!state.tileImprovements.has(k) &&
|
|
!state.constructionSiteAt(coords);
|
|
});
|
|
}
|
|
|
|
// A server that has configured its game but never listened, so the warm-up
|
|
// branch can be observed without opening a socket.
|
|
function configuredServer(setup = {}) {
|
|
const network = { getPeerIds: () => [], send: () => {} };
|
|
const server = new GameServer(network);
|
|
server.configureGame({
|
|
seed: SEED,
|
|
player_civ: 0,
|
|
civilisations: ["france", "britain"],
|
|
mapConfig: smallConfig(),
|
|
...setup,
|
|
});
|
|
return server;
|
|
}
|
|
|
|
// Strips every power plant out of one city's region, so the energy floor can be
|
|
// exercised without the world's opening plants in the way.
|
|
function stripRegionPlants(state, city) {
|
|
for (const coords of state.regionTiles(city)) {
|
|
const k = key(coords.x, coords.y);
|
|
const proto = tileImprovementById(state.tileImprovements.get(k));
|
|
if (isResourceTileBuilding(proto) && proto.resource === "energy") {
|
|
state.tileImprovements.delete(k);
|
|
state.tileImprovementOwner.delete(k);
|
|
state.tileImprovementHp.delete(k);
|
|
// A real removal bumps the improvement versions, which is what drops the
|
|
// memoised region power balances. Mirror that here.
|
|
state._improvementVersion += 1;
|
|
state._tileImprovementVersion += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class SimpleConstructionTest extends TestCase {
|
|
test_a_build_order_starts_in_construction_with_no_materials_phase() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
|
const proto = state.protoBuildings[barracks];
|
|
const levelBefore = state.getCityBuildingLevel(city, barracks);
|
|
const pool = state.getResourcePool(0);
|
|
const steelBefore = pool.steel;
|
|
const cost = buildingBuildCost(proto, levelBefore);
|
|
const bill = state.constructionResourceCost(proto, levelBefore, cost);
|
|
this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted");
|
|
const entry = state.training.get(city.id)[0];
|
|
this.assertNotNull(entry, "the order is queued");
|
|
this.assertEqual(entry.phase, "construction", "it starts building at once");
|
|
this.assertEqual(entry.budget, 0, "no material budget is reserved");
|
|
this.assertEqual(entry.needed.steel, 0, "it names no materials to gather");
|
|
this.assertEqual(entry.bought.steel, 0, "and gathers none");
|
|
this.assertApprox(
|
|
pool.steel,
|
|
steelBefore - bill.steel,
|
|
1e-6,
|
|
"the pool paid the bill up front"
|
|
);
|
|
this.assertEqual(
|
|
state.getCityBuildingLevel(city, barracks),
|
|
levelBefore,
|
|
"the level is not raised until the clock runs out"
|
|
);
|
|
}
|
|
|
|
test_a_building_is_raised_on_the_hourly_tick() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
|
const levelBefore = state.getCityBuildingLevel(city, barracks);
|
|
this.assertTrue(state.requestBuild(city.id, barracks));
|
|
for (let hour = 0; hour < 10_000 && state.training.has(city.id); hour++) {
|
|
state.advanceHour();
|
|
}
|
|
this.assertFalse(state.training.has(city.id), "the order finished");
|
|
this.assertEqual(
|
|
state.getCityBuildingLevel(city, barracks),
|
|
levelBefore + 1,
|
|
"the level arrived"
|
|
);
|
|
}
|
|
|
|
test_a_unit_order_trains_without_a_materials_phase() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "barracks");
|
|
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), "the order is accepted");
|
|
const entry = state.training.get(city.id)[0];
|
|
this.assertEqual(entry.phase, "construction", "training starts at once");
|
|
for (let hour = 0; hour < 2_000 && state.training.has(city.id); hour++) {
|
|
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"
|
|
);
|
|
}
|
|
|
|
// The reference calibration: a modern-infantry unit (cost 100M) trains in 45
|
|
// in-game hours -- 45 real seconds -- in a starter region on any map, however
|
|
// little spare power that region has. The rate and the guaranteed baseline
|
|
// live in the shared data module so the server clock and the browser's quoted
|
|
// times derive them the same way.
|
|
test_the_reference_unit_always_trains_within_45_hours() {
|
|
const cost = 100_000_000;
|
|
const typical = simpleConstructionHours(cost, 3_500_000_000, "unit");
|
|
this.assertApprox(typical, 45, 1e-6, "a typical starter region lands on 45 hours");
|
|
this.assertApprox(
|
|
simpleConstructionHours(cost, 3_500_000_000, "building") * 10,
|
|
typical,
|
|
1e-6,
|
|
"a building of the same cost runs ten times quicker"
|
|
);
|
|
// The guarantee: no level of spare power makes the reference work slower
|
|
// than 45 hours, and none makes it instant.
|
|
const dead = simpleConstructionHours(cost, 0, "unit");
|
|
this.assertApprox(dead, 45, 1e-6, "a blacked-out region still meets the target");
|
|
this.assertApprox(simpleConstructionHours(cost, 1, "unit"), 45, 1e-6, "so does a trickle");
|
|
const huge = simpleConstructionHours(cost, 64_000_000_000, "unit");
|
|
this.assertLess(huge, 45, "a rich grid only builds faster");
|
|
this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "and never instantly");
|
|
}
|
|
|
|
test_more_regional_energy_strictly_shortens_the_clock() {
|
|
const state = simpleState();
|
|
const city = cityOf(state, 0);
|
|
const cost = 100_000_000;
|
|
const floor = SIMPLE_ECONOMY.construction.minEnergy;
|
|
state.regionAvailableEnergy = () => floor;
|
|
const low = state._simpleConstructionHours(city, cost, "building");
|
|
state.regionAvailableEnergy = () => floor * 4;
|
|
const high = state._simpleConstructionHours(city, cost, "building");
|
|
this.assertLess(high, low, "more spare power builds faster");
|
|
this.assertGreater(high, 0, "and never instant");
|
|
}
|
|
|
|
test_a_blacked_out_region_is_capped_at_the_baseline() {
|
|
const state = simpleState();
|
|
const city = cityOf(state, 0);
|
|
const cost = 100_000_000;
|
|
const floor = SIMPLE_ECONOMY.construction.minEnergy;
|
|
state.regionAvailableEnergy = () => 0;
|
|
const dead = state._simpleConstructionHours(city, cost, "building");
|
|
state.regionAvailableEnergy = () => floor * 4;
|
|
const lit = state._simpleConstructionHours(city, cost, "building");
|
|
this.assertGreater(dead, lit, "a power-rich region builds clearly faster");
|
|
this.assertTrue(Number.isFinite(dead), "the clock is finite, not a divide-by-zero");
|
|
this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "the floor holds");
|
|
}
|
|
|
|
test_a_region_with_no_plant_reports_no_spare_power() {
|
|
const state = simpleState();
|
|
const city = cityOf(state, 0);
|
|
this.assertGreater(state.regionAvailableEnergy(city), 0, "the region opens powered");
|
|
stripRegionPlants(state, city);
|
|
this.assertEqual(state.regionAvailableEnergy(city), 0, "no plants, no spare power");
|
|
}
|
|
|
|
test_every_starting_region_has_a_working_power_plant() {
|
|
const state = simpleState();
|
|
for (const city of state.cities) {
|
|
this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} has a plant`);
|
|
this.assertGreater(
|
|
state.regionAvailableEnergy(city),
|
|
0,
|
|
`city ${city.name}'s grid has spare power`
|
|
);
|
|
}
|
|
}
|
|
|
|
test_the_seeder_fills_a_region_that_has_no_plant() {
|
|
const state = simpleState();
|
|
for (const city of state.cities) stripRegionPlants(state, city);
|
|
for (const city of state.cities) {
|
|
this.assertFalse(state._regionHasPowerPlant(city), "the region was stripped bare");
|
|
}
|
|
state._seedSimpleRegionPlants();
|
|
for (const city of state.cities) {
|
|
this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} got a plant`);
|
|
}
|
|
// The guaranteed plant is fuel-free, so the simple economy implies no fuel.
|
|
for (const [k, id] of state.tileImprovements) {
|
|
const proto = tileImprovementById(id);
|
|
if (isResourceTileBuilding(proto) && proto.resource === "energy") {
|
|
this.assertTrue(proto.renewable, `${proto.name} is fuel-free`);
|
|
this.assertFalse(!!proto.fuelEnergyPerDay, `${proto.name} burns no fuel`);
|
|
}
|
|
}
|
|
}
|
|
|
|
test_an_order_the_pool_cannot_pay_is_refused() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
|
const levelBefore = state.getCityBuildingLevel(city, barracks);
|
|
const pool = state.getResourcePool(0);
|
|
pool.steel = 0;
|
|
pool.hightech = 0;
|
|
this.assertFalse(state.requestBuild(city.id, barracks), "an unfunded order is refused");
|
|
this.assertFalse(state.training.has(city.id), "nothing was queued");
|
|
this.assertEqual(
|
|
state.getCityBuildingLevel(city, barracks),
|
|
levelBefore,
|
|
"and nothing was raised"
|
|
);
|
|
}
|
|
|
|
test_simple_construction_spends_no_treasury_money() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
|
const budgetBefore = state.getBudget(0);
|
|
this.assertTrue(state.requestBuild(city.id, barracks));
|
|
this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing");
|
|
const coords = bareOwnedLand(state);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "a road is ordered");
|
|
this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the road paid nothing either");
|
|
}
|
|
|
|
test_a_tile_order_opens_already_building_and_finishes() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const coords = bareOwnedLand(state);
|
|
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
|
const site = state.constructionSiteAt(coords);
|
|
this.assertNotNull(site, "a site opened");
|
|
this.assertEqual(site.phase, "construction", "it is already building");
|
|
this.assertEqual(site.budget, 0, "no budget was moved");
|
|
this.assertNull(site.cityId, "and it hauls from no city");
|
|
for (let hour = 0; hour < 10_000 && state.constructionSiteAt(coords); hour++) {
|
|
state.advanceHour();
|
|
}
|
|
this.assertTrue(state.roads.has(key(coords.x, coords.y)), "the road was raised");
|
|
}
|
|
|
|
test_a_public_upgrade_is_immediate_and_energy_driven() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
let coords = null;
|
|
let k = null;
|
|
let proto = null;
|
|
for (const [tile, id] of state.tileImprovements) {
|
|
if (state.tileImprovementOwner.get(tile) !== 0) continue;
|
|
const candidate = tileImprovementById(id);
|
|
if (isResourceTileBuilding(candidate) && candidate.resource !== "energy") {
|
|
k = tile;
|
|
coords = parseKey(tile);
|
|
proto = candidate;
|
|
break;
|
|
}
|
|
}
|
|
this.assertNotNull(proto, "the fixture has a converter to upgrade");
|
|
const pool = state.getResourcePool(0);
|
|
pool.steel = 1e9;
|
|
pool.hightech = 1e6;
|
|
const agent = state._ensureBuildingAgent(k);
|
|
const levelBefore = agent.level;
|
|
const budgetBefore = state.getBudget(0);
|
|
this.assertTrue(state._startBuildingUpgrade(k, proto, agent), "the upgrade starts");
|
|
const site = state.constructionSiteAt(coords);
|
|
this.assertNotNull(site, "a site opened");
|
|
this.assertEqual(site.kind, "upgrade");
|
|
this.assertEqual(site.phase, "construction", "it is already building");
|
|
this.assertFalse(site.private, "it is a public work");
|
|
this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing");
|
|
for (let hour = 0; hour < 100_000 && state.constructionSiteAt(coords); hour++) {
|
|
state.advanceHour();
|
|
}
|
|
this.assertNull(state.constructionSiteAt(coords), "the upgrade finished");
|
|
this.assertEqual(agent.level, levelBefore + 1, "the level rose");
|
|
}
|
|
|
|
test_ordering_a_build_touches_no_supplier_search() {
|
|
const state = simpleState();
|
|
state.applyStartingScenario();
|
|
const city = cityOf(state, 0);
|
|
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
|
let searched = false;
|
|
state._nearbySuppliers = () => { searched = true; return []; };
|
|
state._resourceNodes = () => { searched = true; return []; };
|
|
this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted");
|
|
this.assertFalse(searched, "the order did no trade-graph lookup");
|
|
this.assertEqual(state.training.get(city.id)[0].phase, "construction", "it started building");
|
|
}
|
|
|
|
// 04-warmup.md: the simple economy opens on its authored scenario, so the
|
|
// server settles no days and the clock never moves before the first player
|
|
// joins. The hard model keeps its settle for comparison.
|
|
test_a_simple_game_skips_the_warmup() {
|
|
const server = configuredServer({ economyModel: "simple", warmupDays: 150 });
|
|
this.assertEqual(server.settledDays, 0, "no days were settled");
|
|
this.assertEqual(server.state.totalHours, 0, "the clock never advanced");
|
|
}
|
|
|
|
test_a_simple_game_ignores_an_explicit_warmup_cap() {
|
|
const capped = configuredServer({ economyModel: "simple", warmupDays: 150 });
|
|
const none = configuredServer({ economyModel: "simple", warmupDays: 0 });
|
|
this.assertEqual(capped.settledDays, 0, "the cap settled nothing");
|
|
this.assertEqual(capped.state.totalHours, none.state.totalHours, "the same opening hour");
|
|
for (const civ of [0, 1]) {
|
|
this.assertEqual(
|
|
capped.state.getResourcePool(civ),
|
|
none.state.getResourcePool(civ),
|
|
`civ ${civ}'s pool is the authored opening pool`
|
|
);
|
|
}
|
|
}
|
|
|
|
test_a_hard_game_still_settles_up_to_its_cap() {
|
|
const server = configuredServer({ economyModel: "hard", warmupDays: 2 });
|
|
this.assertEqual(server.settledDays, 2, "the hard settle ran the cap");
|
|
this.assertGreater(server.state.totalHours, 0, "the clock advanced");
|
|
}
|
|
}
|