Files
Battle-for-Tismo/tests/simple_construction_test.js
T
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
2026-09-25 14:58:49 +02:00

327 lines
14 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,
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"
);
}
test_more_regional_energy_strictly_shortens_the_clock() {
const state = simpleState();
const city = cityOf(state, 0);
const cost = 100_000_000;
state.regionAvailableEnergy = () => 100_000_000;
const low = state._simpleConstructionHours(city, cost, "building");
state.regionAvailableEnergy = () => 1_000_000_000;
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_builds_slowly_but_not_at_once() {
const state = simpleState();
const city = cityOf(state, 0);
const cost = 100_000_000;
state.regionAvailableEnergy = () => 0;
const dead = state._simpleConstructionHours(city, cost, "building");
state.regionAvailableEnergy = () => 100_000_000;
const lit = state._simpleConstructionHours(city, cost, "building");
this.assertGreater(dead, lit, "a power-less region builds clearly slower");
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");
}
}