- 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.
326 lines
14 KiB
JavaScript
326 lines
14 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf } from "./framework/helpers.js";
|
|
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
import { MONEY } from "../shared/data/economy.js";
|
|
|
|
export class MoneyTest extends TestCase {
|
|
// The money in the world: treasuries, region baskets and bank reserves, less
|
|
// every debt owed to a bank. Rates are frozen by the caller so revaluation
|
|
// does not move the measure.
|
|
totalMoney(state) {
|
|
let total = 0;
|
|
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
|
total += state.getBudget(civ);
|
|
for (const city of state.cities) {
|
|
if (city.civ !== civ) continue;
|
|
total -= state.getRegionDebt(city);
|
|
}
|
|
for (const [, amount] of state.getCentralBankDebt(civ)) total -= amount;
|
|
}
|
|
for (const city of state.cities) {
|
|
for (const [, amount] of state.getRegionCash(city)) total += amount;
|
|
}
|
|
for (const reserves of state.centralBankReserves.values()) {
|
|
for (const [, amount] of reserves) total += amount;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
test_government_spending_is_paid_into_the_regions() {
|
|
const state = smallState();
|
|
const code = state.currencyOf(0).code;
|
|
const cities = state.cities.filter((city) => city.civ === 0);
|
|
const before = cities.map((city) => state.getRegionCash(city).get(code) || 0);
|
|
const budgetBefore = state.getBudget(0);
|
|
state._spendBudget(0, 1_000_000);
|
|
const after = cities.map((city) => state.getRegionCash(city).get(code) || 0);
|
|
const reached = after.reduce((sum, value, index) => sum + value - before[index], 0);
|
|
this.assertApprox(reached, 1_000_000, 1, "the money reached the private sector");
|
|
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;
|
|
const cities = state.cities.filter((city) => city.civ === 0);
|
|
const holding = () => cities.reduce(
|
|
(sum, city) => sum + (state.getRegionCash(city).get(code) || 0), 0
|
|
);
|
|
const before = holding();
|
|
state._chargeRegions(0, 500_000);
|
|
this.assertApprox(before - holding(), 500_000, 1, "the tax came out of the regions");
|
|
}
|
|
|
|
test_a_spend_neither_creates_nor_destroys_money() {
|
|
const state = smallState();
|
|
// Hold the exchange rates still so the measure reads units, not revaluation.
|
|
state.setCurrencyValue = () => {};
|
|
const before = this.totalMoney(state);
|
|
state._spendBudget(0, 5_000_000, "training");
|
|
this.assertApprox(this.totalMoney(state), before, 1, "the spend moved money, it did not mint it");
|
|
}
|
|
|
|
test_a_cross_border_purchase_neither_creates_nor_destroys_money() {
|
|
const state = smallState();
|
|
state.setCurrencyValue = () => {};
|
|
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._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() {
|
|
const state = smallState();
|
|
const codes = new Set();
|
|
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
|
const currency = state.currencyOf(civ);
|
|
this.assertTrue(currency.name, "the currency is named");
|
|
this.assertTrue(currency.code, "the currency has a code");
|
|
this.assertTrue(currency.symbol, "the currency has a symbol");
|
|
codes.add(currency.code);
|
|
}
|
|
this.assertEqual(codes.size, state.civilisations.length, "codes are unique");
|
|
}
|
|
|
|
test_regions_open_with_private_cash_in_their_own_currency() {
|
|
const state = smallState();
|
|
for (const city of state.cities) {
|
|
const basket = state.getRegionCash(city);
|
|
this.assertSize(basket, 1, "a region opens holding one currency");
|
|
const code = state.currencyOf(city.civ).code;
|
|
this.assertTrue((basket.get(code) || 0) > 0, "its own national currency");
|
|
this.assertGreater(state.getRegionCashValue(city), 0, "the reserve has value");
|
|
}
|
|
}
|
|
|
|
test_a_short_region_borrows_from_the_central_bank() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const code = state.currencyOf(0).code;
|
|
state.getRegionCash(city).set(code, 0);
|
|
state._chargeRegionCash(city, code, 500);
|
|
this.assertApprox(state.getRegionDebt(city), 500, 1e-6, "the shortfall became debt");
|
|
}
|
|
|
|
test_region_debt_accrues_interest_daily() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
// No cash to repay with, so the debt compounds.
|
|
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
|
|
state.regionDebt.set(city.id, 1000);
|
|
state._tickMoney();
|
|
this.assertApprox(
|
|
state.getRegionDebt(city), 1000 * (1 + MONEY.defaultInterestRate / 365), 1e-6
|
|
);
|
|
}
|
|
|
|
test_setting_the_rate_changes_the_debt_interest() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
this.assertTrue(state.requestSetInterestRate(0, 0.1));
|
|
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
|
|
state.regionDebt.set(city.id, 1000);
|
|
state._tickMoney();
|
|
this.assertApprox(state.getRegionDebt(city), 1000 * (1 + 0.1 / 365), 1e-6);
|
|
}
|
|
|
|
test_the_government_can_run_an_overdraft() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 100);
|
|
this.assertTrue(state._spendBudget(0, 1000, "training"), "a spend is never refused");
|
|
this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
|
|
this.assertApprox(state.getGovernmentDebt(0), -state.getBudget(0), 1e-6);
|
|
}
|
|
|
|
test_the_rate_is_clamped_and_rejects_nonsense() {
|
|
const state = smallState();
|
|
state.requestSetInterestRate(0, 5);
|
|
this.assertEqual(state.getCentralBankRate(0), MONEY.maxInterestRate, "clamped to the ceiling");
|
|
this.assertFalse(state.requestSetInterestRate(0, -1), "a negative rate is refused");
|
|
this.assertFalse(state.requestSetInterestRate(99, 0.02), "an unknown nation is refused");
|
|
}
|
|
|
|
test_the_private_sector_aggregates_its_regions() {
|
|
const state = smallState();
|
|
let sum = 0;
|
|
for (const city of state.cities) {
|
|
if (city.civ !== 0) continue;
|
|
sum += state.getRegionCashValue(city);
|
|
}
|
|
this.assertApprox(state.getPrivateSectorCash(0), sum, 1e-6);
|
|
}
|
|
|
|
test_exchange_rates_follow_the_currency_values() {
|
|
const state = smallState();
|
|
this.assertApprox(state.exchangeRate(0, 1), 1, 1e-9, "currencies start at par");
|
|
state.setCurrencyValue(1, 0.5);
|
|
this.assertApprox(state.exchangeRate(0, 1), 2, 1e-9, "a cheaper currency buys fewer");
|
|
}
|
|
|
|
test_cross_border_trade_settles_through_the_central_bank() {
|
|
const state = smallState();
|
|
const buyer = cityOf(state, 0);
|
|
const seller = cityOf(state, 1);
|
|
state.getCityResourceStock(seller).food = 1e9;
|
|
state.getCityResourceStock(buyer).food = 0;
|
|
const buyerCode = state.currencyOf(0).code;
|
|
const sellerCode = state.currencyOf(1).code;
|
|
const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0;
|
|
const sellerBefore = state.getRegionCash(seller).get(sellerCode) || 0;
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
|
|
);
|
|
this.assertGreater(bought, 0, "the foreign neighbour sold food");
|
|
this.assertLess(
|
|
state.getRegionCash(buyer).get(buyerCode) || 0, buyerBefore, "the buyer spent its own currency"
|
|
);
|
|
this.assertGreater(
|
|
state.getRegionCash(seller).get(sellerCode) || 0,
|
|
sellerBefore,
|
|
"the seller was paid in its own currency"
|
|
);
|
|
this.assertGreater(
|
|
state.getCentralBankReserves(1).get(buyerCode) || 0,
|
|
0,
|
|
"the seller's central bank kept the buyer's currency as a reserve"
|
|
);
|
|
}
|
|
|
|
test_a_currency_in_demand_strengthens() {
|
|
const state = smallState();
|
|
const before = state.currencyValue(0);
|
|
state._recordCurrencyDemand(0, 1e12);
|
|
state._tickMoney();
|
|
this.assertGreater(state.currencyValue(0), before, "net demand lifts the rate");
|
|
}
|
|
|
|
test_regions_repatriate_foreign_cash() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const national = state.currencyOf(0).code;
|
|
const foreign = state.currencyOf(1).code;
|
|
const basket = state.getRegionCash(city);
|
|
basket.set(foreign, 1000);
|
|
const nationalBefore = basket.get(national) || 0;
|
|
state._tickMoney();
|
|
this.assertLess(basket.get(foreign) || 0, 1000, "the foreign holding shrank");
|
|
this.assertGreater(basket.get(national) || 0, nationalBefore, "national cash grew");
|
|
}
|
|
|
|
test_a_broke_region_buys_by_borrowing() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
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._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");
|
|
}
|
|
|
|
test_a_contested_surplus_is_bid_up() {
|
|
const state = smallState();
|
|
const buyer = cityOf(state, 0);
|
|
const nodes = state._resourceNodes();
|
|
const supplier = state._nearbySuppliers(buyer, "food", nodes)[0];
|
|
this.assertNotNull(supplier, "a supplier is reachable");
|
|
// Give that one node a huge surplus and drain every other store of food.
|
|
for (const [, stock] of state.resourceStock) stock.food = 0;
|
|
supplier.stock.food = 1e9;
|
|
state.getCityResourceStock(buyer).food = 0;
|
|
const receiverCity = supplier.city
|
|
|| state._regionCityNear(supplier.civ, supplier.coords);
|
|
const code = state.currencyOf(supplier.civ).code;
|
|
const vSeller = state.currencyValue(supplier.civ);
|
|
// Pretend another buyer already claimed most of the surplus.
|
|
const surplus = state._nodeSurplus(supplier, "food");
|
|
state._nodeClaims = new Map([
|
|
[`${supplier.coords.x},${supplier.coords.y}`, surplus * 0.9],
|
|
]);
|
|
const before = state.getRegionCash(receiverCity).get(code) || 0;
|
|
const store = state.getCityResourceStock(buyer);
|
|
const bought = state._procureFromNeighbours(
|
|
buyer, "food", 1000, store, state._resourceNodes()
|
|
);
|
|
this.assertGreater(bought, 0, "food still changes hands");
|
|
const paid = (state.getRegionCash(receiverCity).get(code) || 0) - before;
|
|
const barePrice = (bought * state.getResourcePrice("food")) / vSeller;
|
|
this.assertGreater(paid, barePrice, "the contested surplus fetched a premium");
|
|
}
|
|
|
|
test_the_snapshot_ships_the_money_view() {
|
|
const state = smallState();
|
|
const stats = state.snapshot(0).viewerStats;
|
|
this.assertTrue(stats.currency && stats.currency.code, "the viewer's currency travels");
|
|
this.assertSize(stats.currencies, state.civilisations.length, "every currency travels");
|
|
this.assertSize(stats.exchangeRates, state.civilisations.length, "every rate travels");
|
|
this.assertTrue(stats.centralBank, "the central bank travels");
|
|
this.assertApprox(stats.centralBank.rate, MONEY.defaultInterestRate, 1e-9);
|
|
this.assertGreater(stats.privateCash, 0, "the private-sector total travels");
|
|
const city = state.snapshot(0).cityStats[0];
|
|
this.assertNotNull(city.cash, "a region's cash basket travels");
|
|
this.assertGreater(city.cashValue, 0, "and its value");
|
|
}
|
|
}
|