Files
Battle-for-Tismo/tests/simple_procurement_test.js
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

322 lines
14 KiB
JavaScript

// Procurement in the simple economy (07-procurement.md): every consumer draws
// from the owner's single resource pool in one subtraction. There is no
// supplier search, no trade graph, no distance, no freight and no bid -- a
// shortfall leaves the need unmet rather than raising a price. The hard
// economy's bid-and-haul path must stay untouched.
import { TestCase } from "./framework/test_case.js";
import { simpleState, smallState, cityOf } from "./framework/helpers.js";
import { RESOURCE_IDS, RESOURCE_RULES, STORABLE_RESOURCE_IDS } from "../shared/data.js";
import { foodNeedPerDay, steelNeedPerDay, luxuryNeedPerDay } from "../shared/resources.js";
// The storable goods whose daily movement is production minus consumption
// exactly. Food is left out of the exact-balance check because a marching army
// eats from the same pool after the civilian pass.
const BALANCED = ["steel", "luxury", "hightech"];
// A fresh market ledger, the shape `_consumeCityResources` expects.
function emptyMarket(state) {
const market = new Map();
for (const id of RESOURCE_IDS) {
market.set(id, { supply: 0, demand: 0, price: state.getResourcePrice(id) });
}
return market;
}
export class SimpleProcurementTest extends TestCase {
// 07: a draw is a single subtraction from the pool, and nothing searches.
test_a_draw_is_one_subtraction_from_the_pool() {
const state = simpleState();
const pool = state.getResourcePool(0);
pool.steel = 1000;
pool.hightech = 500;
let traded = false;
state._tradeGraph = () => { traded = true; return []; };
const city = cityOf(state, 0);
const drawn = state._drawFromNearest(0, city.coords, { steel: 400, hightech: 1000 });
this.assertEqual(drawn.steel, 400, "steel came from the pool");
this.assertEqual(drawn.hightech, 500, "the pool gave all the high-tech it had");
this.assertEqual(pool.steel, 600, "steel was debited exactly");
this.assertEqual(pool.hightech, 0, "the pool is empty, not negative");
this.assertFalse(traded, "no trade graph walked");
const missing = state._drawFromCities(0, "steel", 1000);
this.assertEqual(missing, 400, "the unmet amount is reported");
this.assertEqual(pool.steel, 0, "the pool stays empty");
this.assertFalse(traded, "the city draw still walked no graph");
}
// 07: the quote is the plain world price, with no nominal haul folded in.
test_a_simple_quote_carries_no_freight() {
const state = simpleState();
for (const id of STORABLE_RESOURCE_IDS) {
this.assertApprox(
state._marketUnitCost(id),
state.marketPrice(id),
1e-9,
`${id} quotes the market price`
);
}
// The hard model adds the energy of a nominal two-tile road delivery.
const hard = smallState();
this.assertGreater(
hard._marketUnitCost("steel"),
hard.getResourcePrice("steel"),
"the hard quote still folds in freight"
);
}
// 07: a whole day moves the pool by exactly production minus consumption.
test_a_day_moves_the_pool_by_production_minus_consumption() {
const state = simpleState();
state.applyStartingScenario();
for (let day = 0; day < 2; day++) {
const before = { ...state.getResourcePool(0) };
for (let hour = 0; hour < 24; hour++) state.advanceHour();
const after = state.getResourcePool(0);
const summary = state.getCivResourceSummary(0);
for (const id of BALANCED) {
const delta = after[id] - before[id];
const expected = (summary.production[id] || 0) - (summary.consumed[id] || 0);
this.assertApprox(delta, expected, 1e-3, `${id} moved by exactly the day's balance`);
}
}
}
// 07: the city pass draws each good's daily need straight from the pool.
test_city_consumption_draws_exactly_from_the_pool() {
const state = simpleState();
state.applyStartingScenario();
const cities = state.cities.filter((city) => city.civ === 0);
const need = { food: 0, steel: 0, luxury: 0 };
for (const city of cities) {
const population = state.getCityEconomy(city).population;
need.food += foodNeedPerDay(population);
need.steel += steelNeedPerDay(population);
need.luxury += luxuryNeedPerDay(population);
}
const pool = state.getResourcePool(0);
// A pool deep enough that every region is fed and nothing is procured.
for (const id of Object.keys(need)) pool[id] = 1e9;
let traded = false;
state._tradeGraph = () => { traded = true; return []; };
state._consumeCityResources(new Map(), emptyMarket(state));
for (const id of Object.keys(need)) {
this.assertApprox(1e9 - pool[id], need[id], 1e-6, `${id} consumed exactly its need`);
}
this.assertEmpty(state._resourceShortages, "a full pool leaves no shortage");
this.assertFalse(traded, "consumption walked no trade graph");
}
// 07: a shortfall is unmet, not bid up, and the price does not move.
test_a_pool_shortfall_is_unmet_without_a_bid() {
const state = simpleState();
state.applyStartingScenario();
const pool = state.getResourcePool(0);
for (const id of STORABLE_RESOURCE_IDS) pool[id] = 0;
const prices = Object.fromEntries(RESOURCE_IDS.map((id) => [id, state.getResourcePrice(id)]));
let traded = false;
state._tradeGraph = () => { traded = true; return []; };
state._consumeCityResources(new Map(), emptyMarket(state));
this.assertNotEmpty(state._resourceShortages, "the need is recorded as a shortage");
for (const shortage of state._resourceShortages.values()) {
this.assertEqual(shortage.food, 1, "the region cannot buy a bite");
}
this.assertFalse(traded, "a shortfall never searches for a supplier");
for (const id of RESOURCE_IDS) {
this.assertApprox(
state.getResourcePrice(id),
prices[id],
1e-9,
`${id} prices no bid on the shortfall`
);
}
state._tickFamine();
this.assertNotEmpty(state._famine, "an unfed region is in famine");
}
// 07: combat materials are a pool draw, refused whole when the pool is short.
test_combat_materials_come_from_the_pool() {
const state = simpleState();
const civ = 0;
const pool = state.getResourcePool(civ);
const kind = Object.keys(RESOURCE_RULES.combatConsumption)[0];
const consumption = RESOURCE_RULES.combatConsumption[kind];
pool.steel = 1e6;
pool.hightech = 1e6;
let traded = false;
state._tradeGraph = () => { traded = true; return []; };
this.assertTrue(state.payCombatResources(civ, kind, 1), "the shot is paid");
this.assertEqual(pool.steel, 1e6 - (consumption.steel || 0), "steel came out of the pool");
this.assertEqual(
pool.hightech,
1e6 - (consumption.hightech || 0),
"high-tech came out of the pool"
);
this.assertFalse(traded, "no supplier was searched");
// A pool that cannot cover the shot refuses it, with no bid.
pool.steel = 0;
pool.hightech = 0;
this.assertFalse(state.payCombatResources(civ, kind, 1), "an unfunded shot is refused");
this.assertEqual(pool.steel, 0, "the steel stays empty");
this.assertEqual(pool.hightech, 0, "the high-tech stays empty");
this.assertFalse(traded, "the refusal bid nothing");
}
// 07: upkeep draws the whole wear bill from the pool, exactly.
test_upkeep_draws_exactly_from_the_pool() {
const state = simpleState();
state.applyStartingScenario();
const civ = 0;
const categories = state.getCivUpkeepBreakdown(civ);
let steel = 0;
let hightech = 0;
for (const id of ["buildings", "units", "works", "transport"]) {
steel += (categories[id] && categories[id].steel) || 0;
hightech += (categories[id] && categories[id].hightech) || 0;
}
const pool = state.getResourcePool(civ);
pool.steel = 1e9;
pool.hightech = 1e9;
let traded = false;
state._tradeGraph = () => { traded = true; return []; };
const settled = state._settleUpkeep(civ, categories);
this.assertApprox(settled, 1, 1e-9, "a funded pool settles the whole bill");
this.assertApprox(1e9 - pool.steel, steel, 1e-6, "steel wear came from the pool");
this.assertApprox(1e9 - pool.hightech, hightech, 1e-6, "high-tech wear came from the pool");
this.assertFalse(traded, "upkeep searched no supplier");
}
// 07: neither the ordering nor a whole day's tick builds the trade graph.
test_a_simple_game_never_builds_the_trade_graph() {
const state = simpleState();
state.applyStartingScenario();
let graphCalls = 0;
state._tradeGraph = () => { graphCalls += 1; return []; };
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted");
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
state.requestTrain(city.id, infantry);
this.assertEqual(graphCalls, 0, "ordering walked no trade graph");
for (let hour = 0; hour < 24; hour++) state.advanceHour();
this.assertEqual(graphCalls, 0, "a whole day walked no trade graph");
// The supplier search itself is retired: it finds no reachable node at all.
this.assertEmpty(
state._nearbySuppliers(city, "steel", state._resourceNodes(), true),
"no supplier is ever within reach"
);
}
// 07: a simple build/train entry and a tile site never re-enter the gather
// phase, so the overridden gather passes are dead code in a normal game.
test_a_simple_order_never_regathers() {
const state = simpleState();
state.applyStartingScenario();
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
let gathered = 0;
state._gatherBuildEntry = () => { gathered += 1; return true; };
state._gatherSiteMaterials = () => { gathered += 1; };
this.assertTrue(state.requestBuild(city.id, barracks), "the building is ordered");
const entry = state.training.get(city.id)[0];
this.assertEqual(entry.phase, "construction", "it starts building at once");
this.assertEqual(entry.needed.steel, 0, "it names no materials to gather");
for (let hour = 0; hour < 24; hour++) state.advanceHour();
this.assertEqual(gathered, 0, "the gather passes never ran");
}
// 07: the fallback gather pass, if ever reached, is a pool draw too.
test_the_fallback_gather_draws_from_the_pool() {
const state = simpleState();
state.applyStartingScenario();
const pool = state.getResourcePool(0);
pool.steel = 500;
pool.hightech = 100;
const site = {
civ: 0,
phase: "materials",
needed: { steel: 300, hightech: 200 },
bought: { steel: 0, hightech: 0 },
stalled: true,
elapsedHours: 5,
};
state._gatherSiteMaterials(site);
this.assertEqual(site.bought.steel, 300, "the site drew its steel from the pool");
this.assertEqual(site.bought.hightech, 100, "and all the high-tech the pool held");
this.assertEqual(site.phase, "materials", "a shortfall leaves the work stalled");
this.assertTrue(site.stalled, "and marks it stalled");
pool.hightech = 200;
state._gatherSiteMaterials(site);
this.assertEqual(site.bought.hightech, 200, "the rest is drawn once the pool can cover it");
this.assertEqual(site.phase, "construction", "a covered bill starts building");
this.assertFalse(site.stalled, "and is no longer stalled");
}
// 07: the queued-build gather fallback is a pool draw, not a supplier pass.
test_the_fallback_build_gather_draws_from_the_pool() {
const state = simpleState();
state.applyStartingScenario();
const pool = state.getResourcePool(0);
pool.steel = 500;
pool.hightech = 100;
const city = cityOf(state, 0);
const entry = {
phase: "materials",
stalled: true,
needed: { steel: 300, hightech: 200 },
bought: { steel: 0, hightech: 0 },
};
this.assertFalse(state._gatherBuildEntry(city, entry), "a short bill does not start the work");
this.assertEqual(entry.bought.steel, 300, "steel came from the pool");
this.assertEqual(entry.bought.hightech, 100, "and all the high-tech the pool held");
this.assertEqual(entry.phase, "materials", "the entry stays gathering");
this.assertTrue(entry.stalled, "and is marked stalled");
pool.hightech = 200;
this.assertTrue(state._gatherBuildEntry(city, entry), "a covered bill starts the work");
this.assertEqual(entry.phase, "construction", "the entry flips to construction");
}
// 07: a public upgrade's material check and purchase read the owner's pool.
test_upgrade_materials_come_from_the_pool() {
const state = simpleState();
const city = cityOf(state, 0);
const pool = state.getResourcePool(0);
pool.steel = 100;
pool.hightech = 10;
this.assertEqual(
state._buildingMaterialAvailability(0, city.coords, "steel"),
100,
"the pool is the whole availability"
);
this.assertTrue(
state._buyBuildingMaterials(0, city.coords, 0, { steel: 50, hightech: 5 }),
"an affordable bill is paid"
);
this.assertEqual(pool.steel, 50, "the steel was debited");
this.assertEqual(pool.hightech, 5, "the high-tech was debited");
this.assertFalse(
state._buyBuildingMaterials(0, city.coords, 0, { steel: 1000, hightech: 0 }),
"an unaffordable bill is refused whole"
);
this.assertEqual(pool.steel, 50, "and moves nothing");
}
// 07: the hard procurement path reads stores and quotes freight, untouched.
test_the_hard_procurement_path_is_unchanged() {
const state = smallState();
this.assertEqual(typeof state.getResourcePool, "undefined", "the hard model has no pool");
this.assertGreater(
state._marketUnitCost("steel"),
state.getResourcePrice("steel"),
"the hard delivered price folds in freight"
);
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
store.steel = 1234;
const drawn = state._drawFromNearest(0, city.coords, { steel: 1000, hightech: 0 });
this.assertEqual(drawn.steel, 1000, "the hard draw reads the city store");
this.assertEqual(store.steel, 234, "and debits that store");
}
}