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.
322 lines
14 KiB
JavaScript
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");
|
|
}
|
|
}
|