Files
Battle-for-Tismo/tests/money_test.js
T
adrien 91dfedf7ec 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.
2026-09-22 06:41:58 +02:00

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");
}
}