Gave regions their own economies and removed the global market

- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.
- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.
- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.
- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.
- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.
- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
This commit is contained in:
2026-09-22 06:41:58 +02:00
parent e7ee5dbf0e
commit 91dfedf7ec
24 changed files with 1726 additions and 395 deletions
+23 -3
View File
@@ -111,13 +111,33 @@ export class BudgetTest extends TestCase {
this.assertApprox(steel[1], 300);
}
test_expense_rows_name_the_buildings_that_consumed_a_resource() {
const state = smallState();
state._recordBudgetCash(0, "buildings", -500);
state._recordBudgetCategoryResource(0, "buildings", "steel", 300, "Barracks");
state._recordBudgetCategoryResource(0, "buildings", "steel", 100, "Factory");
const report = state.budgetMonthReports(0).at(-1);
const buildings = report.expenses.find((entry) => entry.id === "buildings");
const steel = buildings.buys.find((entry) => entry[0] === "steel");
this.assertApprox(steel[1], 400);
const sources = new Map(steel[2]);
this.assertApprox(sources.get("Barracks"), 300);
this.assertApprox(sources.get("Factory"), 100);
}
test_upkeep_records_the_resources_it_bought() {
const state = smallState();
// Empty the stores so the day's upkeep has to be bought on the market.
// Empty the viewer's stores and stock a foreign region, so the day's upkeep
// has to be bought across the border.
for (const city of state.cities) {
const store = state.getCityResourceStock(city);
store.steel = 0;
store.hightech = 0;
if (city.civ === 0) {
store.steel = 0;
store.hightech = 0;
} else {
store.steel = 1.0e9;
store.hightech = 1.0e9;
}
}
state.budgets.set(0, 1.0e15);
for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour();
+16 -4
View File
@@ -206,11 +206,19 @@ export class GameStateBuildingsTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ baseCost: 100, costAlpha: 2.0 }));
const city = cityOf(state, 0);
// Stock the steel so the whole bill is drawn from the stores and the two
// orders can be compared cleanly.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e9;
}
const before = state.getBudget(0);
this.assertTrue(state.requestBuild(city.id, index));
const first = before - state.getBudget(0);
const mid = state.getBudget(0);
this.assertTrue(state.requestBuild(city.id, index));
// 100 for the first level, 200 for the second.
this.assertApprox(before - state.getBudget(0), 300);
// The second level (money and steel) is priced off the level the first
// leaves behind, so it costs twice the first.
this.assertApprox(mid - state.getBudget(0), first * 2, 1e-6);
this.advanceUntilIdle(state);
this.assertEqual(state.getCityBuildingLevel(city, index), 2);
}
@@ -229,6 +237,10 @@ export class GameStateBuildingsTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding());
const city = cityOf(state, 0);
// Stock the steel so the material bill is never the limit being tested.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e12;
}
for (let i = 0; i < 20; i++) this.assertTrue(state.requestBuild(city.id, index));
this.advanceUntilIdle(state);
this.assertEqual(state.getCityBuildingLevel(city, index), 20);
@@ -242,7 +254,7 @@ export class GameStateBuildingsTest extends TestCase {
this.assertFalse(state.requestBuild(cityOf(state, 0).id, 9999));
}
test_request_demolish_refunds_and_removes_the_building() {
test_request_demolish_removes_the_building_without_a_refund() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding());
@@ -253,7 +265,7 @@ export class GameStateBuildingsTest extends TestCase {
const before = state.getBudget(0);
this.assertTrue(state.requestDemolish(city.id, index));
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
this.assertGreater(state.getBudget(0), before, "refunded");
this.assertApprox(state.getBudget(0), before, 1e-6, "downgrading refunds nothing");
this.assertTrue(state.requestDemolish(city.id, index));
this.assertEqual(state.getCityBuildingLevel(city, index), 0);
this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish");
+20 -1
View File
@@ -243,10 +243,22 @@ export class GameStateTest extends TestCase {
const state = smallState();
const city = state.cities[0];
grantBuilding(state, city, "barracks");
// Stock the materials so the bill is the training cost plus the material
// value the state buys from its regions.
for (const own of state.cities) {
if (own.civ !== city.civ) continue;
const stock = state.getCityResourceStock(own);
stock.steel = 1.0e9;
stock.hightech = 1.0e9;
}
const proto = state.protoUnits[0];
const materials = state.constructionResourceCost(proto, 0, proto.cost);
const materialValue =
materials.steel * state.getResourcePrice("steel") +
materials.hightech * state.getResourcePrice("hightech");
const before = state.getBudget(city.civ);
this.assertTrue(state.requestTrain(city.id, 0));
this.assertApprox(state.getBudget(city.civ), before - proto.cost);
this.assertApprox(before - state.getBudget(city.civ), proto.cost + materialValue, 1e-3);
const queue = state.getTraining(city.id);
this.assertSize(queue, 1);
this.assertEqual(queue[0].kind, "unit");
@@ -257,6 +269,13 @@ export class GameStateTest extends TestCase {
const city = state.cities[0];
grantBuilding(state, city, "barracks");
state.budgets.set(city.civ, 1.0e15);
// Stock the materials so the queue limit, not the stores, is what caps it.
for (const own of state.cities) {
if (own.civ !== city.civ) continue;
const stock = state.getCityResourceStock(own);
stock.steel = 1.0e12;
stock.hightech = 1.0e12;
}
for (let i = 0; i < TRAINING_QUEUE_LIMIT; i++) {
this.assertTrue(state.requestTrain(city.id, 0), `order ${i} accepted`);
}
+14 -11
View File
@@ -19,8 +19,9 @@ export class ImprovementsTest extends TestCase {
test_building_a_road_costs_money_and_resources_and_lays_the_tile() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
// Stock enough steel that only the electricity is bought on the market, so
// the money bill is the build cost plus the construction energy.
// Stock enough steel that the whole material bill is drawn from the stores;
// the treasury pays the build cost plus the steel it buys from the region.
// Construction electricity is a grid draw, not a purchase, so it is free.
for (const city of state.cities) {
if (city.civ === 0) state.getCityResourceStock(city).steel = 1e9;
}
@@ -32,12 +33,12 @@ export class ImprovementsTest extends TestCase {
this.assertTrue(state.roads.has(k), "the road is on the tile");
this.assertFalse(state.railways.has(k), "it is not a railway");
const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road);
const energyCost = materials.energy * state.getResourcePrice("energy");
const steelValue = materials.steel * state.getResourcePrice("steel");
this.assertApprox(
before - state.getBudget(0),
TRANSPORT_BY_ID.road.buildCost + energyCost,
TRANSPORT_BY_ID.road.buildCost + steelValue,
1e-3,
"money plus the construction electricity"
"the build cost plus the steel the region supplied"
);
}
@@ -196,8 +197,9 @@ export class ImprovementsTest extends TestCase {
test_a_tile_work_costs_steel_energy_and_high_tech() {
const state = smallState();
state.budgets.set(0, 1.0e15);
// Enough stores that only the electricity is bought, so the money bill is
// the build cost plus the construction energy.
// Enough stores that the whole material bill is drawn from them; the
// treasury pays the build cost plus the steel and high-tech it buys from the
// region. Construction electricity comes off the grid, so it is not bought.
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
@@ -213,16 +215,17 @@ export class ImprovementsTest extends TestCase {
const radar = TILE_IMPROVEMENT_BY_ID.radar_tower;
const materials = tileImprovementResourceCost(radar);
this.assertGreater(materials.steel, 0, "steel is spent");
this.assertGreater(materials.energy, 0, "energy is spent");
this.assertGreater(materials.energy, 0, "energy is drawn");
this.assertGreater(materials.hightech, 0, "high-tech is spent");
const before = state.getBudget(0);
this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
const energyCost = materials.energy * state.getResourcePrice("energy");
const steelValue = materials.steel * state.getResourcePrice("steel");
const techValue = materials.hightech * state.getResourcePrice("hightech");
this.assertApprox(
before - state.getBudget(0),
radar.buildCost + energyCost,
radar.buildCost + steelValue + techValue,
1e-3,
"money plus the construction electricity"
"the build cost plus the materials the region supplied"
);
}
}
+115 -5
View File
@@ -277,6 +277,64 @@ export class CityModalTest extends TestCase {
teardownDom(env);
}
}
async test_resources_tab_shows_reserves_value_and_consumption() {
const env = await setupDom();
try {
const modal = new CityModal();
modal.show(
{ id: 1, name: "Lyon" },
{
budget: 1.0e9,
gdp: 1.0e9,
resources: {
stock: { food: 1000, steel: 2000, luxury: 0, hightech: 50 },
consumption: { food: 100, steel: 200, luxury: 10, hightech: 2, energy: 2500 },
value: 123456,
},
}
);
env.$("#modal-city .tab[data-tab=resources]").click();
this.assertTrue(env.$("#city-tab-resources").hasClass("active"));
this.assertTrue(
env.$("#city-resources-sub").text().includes("123 456"),
"the reserve value shows"
);
const text = env.$("#city-resources-list").text();
this.assertTrue(text.includes("Food"), "food is listed");
this.assertTrue(text.includes("Steel"), "steel is listed");
this.assertTrue(text.includes("Energy"), "energy is listed");
this.assertTrue(text.includes("2.5 MWh"), "the daily power draw is shown");
} finally {
teardownDom(env);
}
}
async test_people_tab_reports_resource_shortages() {
const env = await setupDom();
try {
const modal = new CityModal();
modal.show(
{ id: 1, name: "Lyon" },
{
budget: 1.0e9,
gdp: 1.0e9,
ethnicMakeup: [],
approval: 0.5,
shortage: { steel: { fraction: 0.4, amount: 1200 } },
}
);
env.$("#modal-city .tab[data-tab=people]").click();
this.assertTrue(env.$("#city-tab-people").hasClass("active"));
const text = env.$("#city-people-list").text();
this.assertTrue(text.includes("Resource shortages"), "the shortage section shows");
this.assertTrue(text.includes("Steel"), "the short resource is named");
this.assertTrue(text.includes("1200"), "the amount it is short by is shown");
this.assertTrue(text.includes("40%"), "the share of need is shown");
} finally {
teardownDom(env);
}
}
}
export class NationModalTest extends TestCase {
@@ -550,7 +608,10 @@ export class NationModalTest extends TestCase {
totalEconomic: 900,
net: 545,
expenses: [
["buildings", 400, 700, [["steel", 250], ["hightech", 150]]],
["buildings", 400, 700, [
["steel", 250, [["Barracks", 250]]],
["hightech", 150, [["Laboratory", 150]]],
]],
["units", 150, 200, []],
],
resourceTrade: [["steel", 220, 0], ["food", 0, 90]],
@@ -567,8 +628,8 @@ export class NationModalTest extends TestCase {
this.assertTrue(text.includes("+¤1 035"), "the month's tax income is shown");
this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
this.assertTrue(text.includes("−¤400"), "the building cash is listed");
this.assertTrue(text.includes("materials valued at ¤700"), "the market value is noted");
this.assertTrue(text.includes("−¤700"), "the rightmost column is the market value");
this.assertTrue(text.includes("cash −¤400"), "the cash the treasury paid is noted");
this.assertTrue(text.includes("Unit upkeep"), "a unit category is listed");
this.assertTrue(text.includes("Bought Steel"), "a bought resource is listed");
this.assertTrue(text.includes("Sold Food"), "a sold resource is listed");
@@ -577,11 +638,13 @@ export class NationModalTest extends TestCase {
this.assertTrue(env.$("#budget-sub").text().includes("Treasury"));
this.assertTrue(env.$("#budget-sub").text().includes("January 2000"), "the month is named");
// Expanding an upkeep row names the resources it bought.
// Expanding an upkeep row names the resources it bought and the buildings
// that consumed them.
env.$("#budget-list tr .budget-name.clickable").first().click();
const expanded = env.$("#budget-list").text();
this.assertTrue(expanded.includes("Steel"), "the expanded row names steel");
this.assertTrue(expanded.includes("−¤250"), "and what it paid for it");
this.assertTrue(expanded.includes("Barracks"), "and the building that used it");
// The pager steps back to the previous month's digest.
env.$("#budget-months .budget-month-nav").first().click();
@@ -755,10 +818,39 @@ export class NationModalTest extends TestCase {
},
privateCash: 5000,
cities: [{ id: 1, civ: 0, name: "Paris", cashValue: 5000 }],
currencyHoldings: {
code: "FRF",
symbol: "₣",
name: "French Franc",
value: 1,
treasury: 1000,
treasuryDebt: 0,
own: [{ cityId: 1, name: "Paris", civ: 0, civName: "France", amount: 5000 }],
ownTotal: 5000,
foreign: [{ cityId: 2, name: "London", civ: 1, civName: "Britain", amount: 700 }],
foreignTotal: 700,
banks: [{ civ: 1, name: "Britain", amount: 1234 }],
bankTotal: 1234,
total: 7934,
},
};
const resourcesInfo = {
summary: {
stock: {},
need: {},
production: {},
consumed: { steel: 1200, food: 10, luxury: 0, hightech: 0, energy: 0 },
external: {
buy: { steel: { qty: 300, value: 180_000 } },
sell: { food: { qty: 40, value: 12_000 } },
},
prices: {},
},
market: [],
};
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
{ months: [], cities: [] }, null, [], null, null, null, money
{ months: [], cities: [] }, null, [], null, null, resourcesInfo, money
);
env.$("#modal-nation .tab[data-tab=economy]").click();
this.assertTrue(env.$("#tab-economy").hasClass("active"));
@@ -775,6 +867,24 @@ export class NationModalTest extends TestCase {
env.$("#economy-subtabs .subtab[data-view=rates]").click();
this.assertTrue(env.$("#economy-rates").text().includes("British Pound"), "the rate table shows");
this.assertTrue(env.$("#economy-rates").text().includes("0.8500"), "the rate is shown");
env.$("#economy-subtabs .subtab[data-view=consumption]").click();
const consumption = env.$("#economy-consumption").text();
this.assertTrue(consumption.includes("Steel"), "the consumed resource is listed");
this.assertTrue(consumption.includes("1200"), "its daily consumption is shown");
this.assertTrue(consumption.includes("180 000"), "what was spent abroad is shown");
this.assertTrue(consumption.includes("40"), "what was sold abroad is shown");
env.$("#economy-subtabs .subtab[data-view=currency]").click();
const currency = env.$("#economy-currency").text();
this.assertTrue(
env.$("#economy-sub").text().includes("French Franc"),
"the currency is named"
);
this.assertTrue(currency.includes("Paris"), "a home region holding it is listed");
this.assertTrue(currency.includes("London"), "a foreign region holding it is listed");
this.assertTrue(currency.includes("Central bank of Britain"), "a foreign bank is listed");
this.assertTrue(currency.includes("7 934"), "the total in circulation is shown");
} finally {
teardownDom(env);
}
+67 -11
View File
@@ -39,6 +39,30 @@ export class MoneyTest extends TestCase {
this.assertApprox(state.getBudget(0), budgetBefore - 1_000_000, 1e-6);
}
test_a_government_purchase_pays_the_foreign_seller() {
const state = smallState();
const buyer = cityOf(state, 0);
const seller = cityOf(state, 1);
const sellerCode = state.currencyOf(1).code;
// Empty every home store so only the foreign region can supply.
for (const city of state.cities) {
if (city.civ === 0) state.getCityResourceStock(city).food = 0;
}
state.getCityResourceStock(seller).food = 1.0e9;
const before = state.getRegionCash(seller).get(sellerCode) || 0;
const payer = state._treasuryPayer(0);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, { food: 0 }, state._resourceNodes(), { payer }
);
this.assertGreater(bought, 0, "the foreign region sold food");
this.assertGreater(
state.getRegionCash(seller).get(sellerCode) || 0,
before,
"the foreign seller was paid"
);
this.assertGreater(payer.total, 0, "the treasury paid for it");
}
test_taxes_are_taken_from_the_regions() {
const state = smallState();
const code = state.currencyOf(0).code;
@@ -60,14 +84,45 @@ export class MoneyTest extends TestCase {
this.assertApprox(this.totalMoney(state), before, 1, "the spend moved money, it did not mint it");
}
test_a_market_purchase_neither_creates_nor_destroys_money() {
test_a_cross_border_purchase_neither_creates_nor_destroys_money() {
const state = smallState();
state.setCurrencyValue = () => {};
const city = cityOf(state, 0);
const buyer = cityOf(state, 0);
const seller = cityOf(state, 1);
for (const city of state.cities) {
if (city.civ === 0) state.getCityResourceStock(city).food = 0;
}
state.getCityResourceStock(seller).food = 1.0e9;
const before = this.totalMoney(state);
state._chargeRegionCash(city, state.currencyOf(0).code, 2_000_000);
state._payWorld(0, 2_000_000, state.currencyOf(0).code);
this.assertApprox(this.totalMoney(state), before, 1, "the import stayed in the world");
state._procureFromNeighbours(
buyer, "food", 1000, { food: 0 }, state._resourceNodes()
);
this.assertApprox(this.totalMoney(state), before, 1, "the trade moved money, it did not mint it");
}
test_currency_holdings_add_up_to_the_total_in_circulation() {
const state = smallState();
const code = state.currencyOf(0).code;
const city = cityOf(state, 0);
state._creditRegionCash(city, code, 1_000);
const holdings = state.getCurrencyHoldings(0);
const own = holdings.own.find((entry) => entry.cityId === city.id);
this.assertNotNull(own, "the region is listed");
this.assertApprox(own.amount, state.getRegionCash(city).get(code));
const expected =
holdings.treasury + holdings.ownTotal + holdings.foreignTotal + holdings.bankTotal;
this.assertApprox(holdings.total, expected, 1e-6);
}
test_currency_holdings_list_foreign_central_bank_reserves() {
const state = smallState();
const code = state.currencyOf(0).code;
state._addReserve(1, code, 5_000);
const holdings = state.getCurrencyHoldings(0);
const bank = holdings.banks.find((entry) => entry.civ === 1);
this.assertNotNull(bank, "the foreign central bank is listed");
this.assertApprox(bank.amount, 5_000);
this.assertApprox(holdings.bankTotal, 5_000);
}
test_every_civilisation_has_a_currency() {
@@ -211,14 +266,15 @@ export class MoneyTest extends TestCase {
test_a_broke_region_buys_by_borrowing() {
const state = smallState();
const city = cityOf(state, 0);
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
if (!coastal) return;
coastal.buildings[portIndex] = 1;
this.assertTrue(state._civMarketAccess(0), "a port opens the market");
const nodes = state._resourceNodes();
const supplier = state._nearbySuppliers(city, "food", nodes)[0];
this.assertNotNull(supplier, "a supplier is reachable");
// Leave one supplier with a surplus and empty the buyer's purse.
for (const [, stock] of state.resourceStock) stock.food = 0;
supplier.stock.food = 1.0e9;
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
const store = state.getCityResourceStock(city);
const bought = state._buyMarketMaterial(city, "food", 100, store);
const bought = state._procureFromNeighbours(city, "food", 100, store, nodes);
this.assertGreater(bought, 0, "the purchase still goes through");
this.assertGreater(state.getRegionDebt(city), 0, "it was funded by borrowing");
}
+7
View File
@@ -372,6 +372,13 @@ export class PoliticsModelTest extends TestCase {
state.budgets.set(1, 1e15);
for (const city of state.cities) {
state.getRegionCash(city).set(state.currencyOf(city.civ).code, 1e15);
// With no global market, money alone does not feed anyone: stock the
// stores so no shortage can drown out the museum.
const stock = state.getCityResourceStock(city);
stock.food = 1e15;
stock.steel = 1e15;
stock.luxury = 1e15;
stock.hightech = 1e15;
}
const museum = BUILDINGS.findIndex((b) => b.id === "museum");
const city = state.cities.find((c) => c.civ === 0);
+2 -2
View File
@@ -104,7 +104,7 @@ export class EconomyOrderEdgesTest extends TestCase {
this.assertTrue(state.requestBuild(city.id, 0), "levels keep rising without a cap");
}
test_demolishing_removes_one_level_and_refunds() {
test_demolishing_removes_one_level_without_refunding() {
const state = smallState();
const city = cityOf(state, 0);
state.budgets.set(0, 1e15);
@@ -113,7 +113,7 @@ export class EconomyOrderEdgesTest extends TestCase {
const budget = state.getBudget(0);
this.assertTrue(state.requestDemolish(city.id, 0));
this.assertEqual(state.getCityBuildingLevel(city, 0), 0);
this.assertGreater(state.getBudget(0), budget, "the refund raises the budget");
this.assertApprox(state.getBudget(0), budget, 1e-6, "downgrading refunds nothing");
}
test_cancelling_an_out_of_range_queue_entry_fails() {
+241 -16
View File
@@ -8,7 +8,7 @@ import {
EFFECT_RESEARCH,
technologyCost,
} from "../shared/data.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
import {
formatEnergy,
formatResourceAmount,
@@ -128,6 +128,135 @@ export class ResourceModelTest extends TestCase {
this.assertApprox(steel.sold, 400);
}
test_external_trade_ledger_counts_buying_and_selling() {
const state = smallState();
state._recordExternalBuy(0, "steel", 100, 1_000);
state._recordExternalSell(0, "food", 40, 500);
const summary = state.getCivResourceSummary(0);
this.assertApprox(summary.external.buy.steel.qty, 100);
this.assertApprox(summary.external.buy.steel.value, 1_000);
this.assertApprox(summary.external.sell.food.qty, 40);
this.assertApprox(summary.external.sell.food.value, 500);
}
test_the_day_s_shortfalls_are_bought_abroad() {
const state = smallState();
state.budgets.set(0, 1.0e15);
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.food = 0;
stock.steel = 0;
stock.luxury = 0;
}
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
const summary = state.getCivResourceSummary(0);
const bought = Object.values(summary.external.buy)
.reduce((sum, entry) => sum + entry.qty, 0);
this.assertGreater(bought, 0, "the day's shortfalls were bought across the border");
const spent = Object.values(summary.external.buy)
.reduce((sum, entry) => sum + entry.value, 0);
this.assertGreater(spent, 0, "and the money it cost is recorded");
}
test_a_region_reports_what_it_ran_short_of() {
const state = smallState();
const city = cityOf(state, 0);
state._resourceShortages.set(city.id, { steel: 0.5 });
const shortages = state.getRegionShortages(city);
this.assertTrue(shortages.steel, "steel is reported");
this.assertApprox(shortages.steel.fraction, 0.5);
const population = state.getCityEconomy(city).population;
this.assertApprox(shortages.steel.amount, 0.5 * steelNeedPerDay(population));
}
test_the_snapshot_ships_each_region_s_shortages() {
const state = smallState();
const city = cityOf(state, 0);
state._resourceShortages.set(city.id, { food: 0.25 });
const snapshot = state.snapshot(0);
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
this.assertTrue(stats.shortage.food, "the shortage is shipped");
this.assertApprox(stats.shortage.food.fraction, 0.25);
}
test_a_research_building_without_high_tech_raises_a_notice() {
const state = smallState();
const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab");
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
}
state._tickBuildings();
const alert = state._resourceAlertsFor(0)
.find((entry) => entry.resource === "hightech" && entry.region === city.name);
this.assertNotNull(alert, "the lab's missing high-tech is reported");
this.assertTrue(alert.building.includes("Research lab"), "the building is named");
}
test_a_power_starved_work_raises_a_notice() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const tile = freeLandTile(state, 0);
const k = placeResourceBuilding(state, tile, "steel_mill", 0);
const components = new Map([
[k, 0],
[key(city.coords.x, city.coords.y), 0],
]);
const ledger = new Map([[0, { civ: 0, industrialScale: 0.5 }]]);
state._noteGridPowerShortages(ledger, components);
const alert = state._resourceAlertsFor(0)
.find((entry) => entry.building === "Steel mill" && entry.resource === "energy");
this.assertNotNull(alert, "the idle work is reported");
this.assertEqual(alert.region, city.name);
}
test_region_resource_info_reports_reserves_value_and_consumption() {
const state = smallState();
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
store.food = 1000;
store.steel = 2000;
store.hightech = 50;
store.luxury = 10;
const info = state.getRegionResourceInfo(city);
this.assertEqual(info.stock.food, 1000);
this.assertEqual(info.stock.steel, 2000);
const expected = 1000 * state.getResourcePrice("food") +
2000 * state.getResourcePrice("steel") +
50 * state.getResourcePrice("hightech") +
10 * state.getResourcePrice("luxury");
this.assertApprox(info.value, expected);
const population = state.getCityEconomy(city).population;
this.assertApprox(info.consumption.food, foodNeedPerDay(population));
this.assertApprox(info.consumption.steel, steelNeedPerDay(population));
this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population));
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
}
test_city_stats_ship_the_region_resources() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 42;
const stats = state.snapshot(0).cityStats.find((entry) => entry.id === city.id);
this.assertNotNull(stats.resources, "the region's reserves travel in the snapshot");
this.assertEqual(stats.resources.stock.steel, 42);
this.assertTrue(stats.resources.value > 0, "the reserve carries a value");
}
test_shortage_notices_are_private_to_the_owner() {
const state = smallState();
state._noteResourceShortage(0, "University", "Paris", "hightech");
const mine = state.snapshot(0).viewerStats.resourceAlerts;
const theirs = state.snapshot(1).viewerStats.resourceAlerts;
this.assertSize(mine, 1, "the owner sees the notice");
this.assertSize(theirs, 0, "another nation does not");
this.assertEqual(mine[0].building, "University");
this.assertEqual(mine[0].resource, "hightech");
this.assertGreaterOrEqual(mine[0].day, 0);
}
test_national_gdp_per_capita_matches_the_regions() {
const state = smallState();
const snapshot = state.snapshot(0);
@@ -145,29 +274,64 @@ export class ResourceModelTest extends TestCase {
test_construction_draws_steel_from_the_city_before_the_market() {
const state = smallState();
const city = cityOf(state, 0);
const code = state.currencyOf(0).code;
const stock = state.getCityResourceStock(city);
stock.steel = 1000;
const cashBefore = state.getRegionCash(city).get(code) || 0;
const before = state.getBudget(0);
const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 });
this.assertTrue(ok);
this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store");
this.assertApprox(state.getBudget(0), before, 1e-6, "no market bill was needed");
// The region is not charged: the state buys the steel from it.
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
this.assertApprox(
(state.getRegionCash(city).get(code) || 0) - cashBefore,
value,
1e-3,
"the region received the money"
);
}
test_construction_buys_the_shortfall_on_the_market() {
test_drawing_a_national_store_pays_the_region() {
const state = smallState();
const city = cityOf(state, 0);
const code = state.currencyOf(0).code;
state.getCityResourceStock(city).steel = 1000;
const cashBefore = state.getRegionCash(city).get(code) || 0;
const before = state.getBudget(0);
const payer = state._treasuryPayer(0);
const drawn = state._drawFromNearest(
0, city.coords, { steel: 400, hightech: 0 }, payer
);
this.assertApprox(drawn.steel, 400);
const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0);
this.assertApprox(payer.total, value, 1e-3, "the treasury carried the bill");
this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it");
this.assertApprox(
(state.getRegionCash(city).get(code) || 0) - cashBefore,
value,
1e-3,
"the region's private sector received it"
);
}
test_construction_buys_the_shortfall_from_a_reachable_region() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
// Every city of the nation must be empty, or the draw would find another
// store before it reaches the market.
const seller = cityOf(state, 1);
// Every city of the nation must be empty, or the draw would find their
// stores before it reaches a foreign region.
for (const own of state.cities) {
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
}
const price = state.getResourcePrice("steel");
state.getCityResourceStock(seller).steel = 1.0e9;
const before = state.getBudget(0);
const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 });
this.assertTrue(ok);
this.assertApprox(before - state.getBudget(0), 1000 * price, 1e-3);
this.assertTrue(ok, "the steel was found across the border");
this.assertLess(state.getBudget(0), before, "the treasury paid for it");
this.assertLess(state.getCityResourceStock(seller).steel, 1.0e9, "the seller's store shrank");
}
test_research_spends_high_tech() {
@@ -253,6 +417,65 @@ export class ResourceModelTest extends TestCase {
);
}
test_materials_come_from_home_regions_before_foreign_ones() {
const state = smallState();
const buyer = cityOf(state, 0);
const land = state.topology.neighbours(buyer.coords.x, buyer.coords.y)
.filter((n) => state._isLand(n));
this.assertGreaterOrEqual(land.length, 2, "the city has land neighbours");
for (const n of land) state.roads.add(key(n.x, n.y));
// Two reachable suppliers, home and foreign, both with a surplus. Home wins.
const homeNode = { coords: land[0], civ: 0, stock: { food: 1.0e9 }, kind: "building" };
const foreignNode = { coords: land[1], civ: 1, stock: { food: 1.0e9 }, kind: "building" };
const store = state.getCityResourceStock(buyer);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, store, [homeNode, foreignNode]
);
this.assertGreater(bought, 0, "the order was filled");
this.assertApprox(homeNode.stock.food, 1.0e9 - 1000, 1e-6, "the home region supplied it");
this.assertApprox(foreignNode.stock.food, 1.0e9, 1e-6, "the foreign region was left alone");
}
test_without_a_market_unreachable_goods_are_not_bought() {
const state = smallState();
const buyer = cityOf(state, 0);
for (const [, stock] of state.resourceStock) stock.food = 0;
state.roads.clear();
state.railways.clear();
const store = state.getCityResourceStock(buyer);
const bought = state._procureFromNeighbours(
buyer, "food", 1000, store, state._resourceNodes(), { allowSea: false }
);
this.assertEqual(bought, 0, "no trade route, no goods");
this.assertEqual(store.food, 0, "and nothing arrives");
}
test_power_flows_between_reachable_grids() {
const state = smallState();
const city = cityOf(state, 0);
const neighbour = state.topology.neighbours(city.coords.x, city.coords.y)
.find((n) => state._isLand(n) && state.civAt(n) === 0);
this.assertNotNull(neighbour, "the city has an owned land neighbour");
const nk = key(neighbour.x, neighbour.y);
state.roads.add(nk);
const components = new Map([
[key(city.coords.x, city.coords.y), 0],
[nk, 1],
]);
const ledger = new Map([
[0, {
civ: 0, supply: 0, demand: 1000, imports: 0,
cityEnergy: 1000, converterEnergy: 0, foodEnergy: 0,
}],
[1, {
civ: 0, supply: 5000, demand: 0, imports: 0,
cityEnergy: 0, converterEnergy: 0, foodEnergy: 0,
}],
]);
state._tradeGridPower(ledger, components);
this.assertApprox(ledger.get(0).imports, 1000, 1e-6, "the deficit grid imported the power");
}
test_foreign_trade_runs_on_the_transport_network() {
const state = smallState();
state.budgets.set(0, 1e15);
@@ -480,6 +703,12 @@ export class ResourceModelTest extends TestCase {
test_fusion_needs_its_technology() {
const state = smallState();
state.budgets.set(0, 1e15);
for (const own of state.cities) {
if (own.civ !== 0) continue;
const stock = state.getCityResourceStock(own);
stock.steel = 1.0e12;
stock.hightech = 1.0e12;
}
const tile = freeLandTile(state, 0);
this.assertFalse(
state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
@@ -613,22 +842,18 @@ export class ResourceModelTest extends TestCase {
this.assertFalse(inland && state.hasCityBuilding(inland, "port"), "an inland city does not");
}
test_a_city_without_a_port_has_no_foreign_market() {
test_a_city_without_a_port_has_no_sea_lane() {
const state = smallState();
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
for (const own of state.cities) {
if (own.civ === 0) delete own.buildings[portIndex];
}
this.assertFalse(state._civMarketAccess(0), "no working port, no market");
this.assertEqual(state._buyMarketMaterial(city, "food", 100, store), 0, "nothing can be bought");
this.assertFalse(state._civMarketAccess(0), "no working port, no sea trade");
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
this.assertNotNull(coastal, "the nation has a coastal city");
coastal.buildings[portIndex] = 1;
this.assertTrue(state._civMarketAccess(0), "the port opens the market");
this.assertGreater(state._buyMarketMaterial(city, "food", 100, store), 0, "goods now arrive");
this.assertTrue(state._civMarketAccess(0), "the port opens the sea lane");
}
test_air_defense_spends_ammunition_when_it_fires() {