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:
+23
-3
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user