- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too. - Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative. - Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense. - The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it). - The shipped month report carries its label again, so the budget header and pager name the month.
213 lines
9.7 KiB
JavaScript
213 lines
9.7 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState } from "./framework/helpers.js";
|
|
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
import { BUDGET_HISTORY_MONTHS } from "../shared/game_state/budget.js";
|
|
import { key } from "../shared/hex.js";
|
|
|
|
// Plants a state-owned work on a free land tile, so the day's works upkeep is
|
|
// billed to the treasury rather than settled by a private producer's cash.
|
|
function placeFortification(state, civ = 0) {
|
|
const coords = state.landCells.find((c) => {
|
|
if (state.civAt(c) !== civ || state.cityAt(c)) return false;
|
|
const k = key(c.x, c.y);
|
|
return !state.tileImprovements.has(k) && !state.roads.has(k) && !state.railways.has(k);
|
|
});
|
|
const k = key(coords.x, coords.y);
|
|
state.tileImprovements.set(k, "fortifications");
|
|
state.tileImprovementOwner.set(k, civ);
|
|
state.tileImprovementHp.set(k, 1000);
|
|
}
|
|
|
|
export class BudgetTest extends TestCase {
|
|
test_the_opening_month_is_the_only_report() {
|
|
const state = smallState();
|
|
const months = state.budgetMonthReports(0);
|
|
this.assertSize(months, 1, "only the live month exists at the start");
|
|
this.assertTrue(months[0].isCurrent, "the live month is flagged current");
|
|
this.assertEqual(months[0].label, "January 2000");
|
|
this.assertEqual(months[0].expenses.length, 0, "nothing has been spent yet");
|
|
this.assertEqual(months[0].net, 0, "the month opens balanced");
|
|
}
|
|
|
|
test_spending_is_filed_under_its_category() {
|
|
const state = smallState();
|
|
state._spendBudget(0, 1_000, "training");
|
|
state._spendBudget(0, 4_000, "construction");
|
|
state._recordTax(0, "sales", "steel", 9_000);
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
|
|
this.assertApprox(byId.get("training").cash, 1_000);
|
|
this.assertApprox(byId.get("construction").cash, 4_000);
|
|
this.assertApprox(report.income, 9_000);
|
|
this.assertApprox(report.totalCash, 5_000);
|
|
this.assertApprox(report.net, 4_000);
|
|
}
|
|
|
|
test_a_spend_on_an_empty_treasury_becomes_debt_and_is_recorded() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 0);
|
|
// The government always pays; the overdraft is the central bank's loan.
|
|
this.assertTrue(state._spendBudget(0, 500, "training"));
|
|
this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const training = report.expenses.find((entry) => entry.id === "training");
|
|
this.assertApprox(training.cash, 500);
|
|
}
|
|
|
|
test_a_refund_cancels_the_category() {
|
|
const state = smallState();
|
|
state._spendBudget(0, 1_000, "construction");
|
|
state._refundBudget(0, 1_000, "construction");
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
this.assertEqual(report.expenses.length, 0, "a refunded order does not linger");
|
|
this.assertEqual(report.net, 0);
|
|
}
|
|
|
|
test_daily_upkeep_records_cash_and_market_value() {
|
|
const state = smallState();
|
|
placeFortification(state);
|
|
state.advanceHour();
|
|
for (let i = 0; i < 23; i++) state.advanceHour();
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
|
|
for (const id of ["buildings", "units", "works", "transport"]) {
|
|
const entry = byId.get(id);
|
|
this.assertNotNull(entry, `${id} upkeep is recorded`);
|
|
this.assertTrue(entry.economic >= entry.cash - 1e-6, `${id} market value covers its cash`);
|
|
}
|
|
}
|
|
|
|
test_rolling_a_month_freezes_a_digest_and_resets() {
|
|
const state = smallState();
|
|
state._spendBudget(0, 2_000, "training");
|
|
// January 2000 has 31 days.
|
|
for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
|
|
const months = state.budgetMonthReports(0);
|
|
this.assertSize(months, 2, "January is frozen behind the live February");
|
|
this.assertEqual(months[0].label, "January 2000");
|
|
this.assertTrue(!months[0].isCurrent, "the frozen month is not current");
|
|
this.assertEqual(months[1].label, "February 2000");
|
|
this.assertTrue(months[1].isCurrent, "the live month is February");
|
|
const training = months[0].expenses.find((entry) => entry.id === "training");
|
|
this.assertApprox(training.cash, 2_000, 1e-6);
|
|
this.assertEqual(
|
|
months[1].expenses.find((entry) => entry.id === "training"),
|
|
undefined,
|
|
"the new month starts empty"
|
|
);
|
|
}
|
|
|
|
test_the_history_is_capped() {
|
|
const state = smallState();
|
|
for (let i = 0; i < BUDGET_HISTORY_MONTHS + 5; i++) state._rolloverBudget(24000 + i);
|
|
this.assertSize(state.budgetHistory, BUDGET_HISTORY_MONTHS, "only two years are kept");
|
|
}
|
|
|
|
test_resource_trade_is_mirrored_into_the_month() {
|
|
const state = smallState();
|
|
state._recordResourceSpend(0, "goods", 5_000, "steel");
|
|
state._recordResourceEarn(0, "steel", 2_000);
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const trade = report.resourceTrade.find((entry) => entry.id === "steel");
|
|
this.assertNotNull(trade);
|
|
this.assertApprox(trade.bought, 5_000);
|
|
this.assertApprox(trade.sold, 2_000);
|
|
}
|
|
|
|
test_expense_rows_carry_their_resource_detail() {
|
|
const state = smallState();
|
|
state._recordBudgetCash(0, "buildings", -500);
|
|
state._recordBudgetCategoryResource(0, "buildings", "steel", 300);
|
|
state._recordBudgetCategoryResource(0, "buildings", "hightech", 200);
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const buildings = report.expenses.find((entry) => entry.id === "buildings");
|
|
this.assertApprox(buildings.cash, 500);
|
|
this.assertEqual(buildings.buys.length, 2, "both resources are listed");
|
|
const steel = buildings.buys.find((entry) => entry[0] === "steel");
|
|
this.assertApprox(steel[1], 300);
|
|
}
|
|
|
|
test_expense_rows_carry_the_quantity_that_was_bought() {
|
|
const state = smallState();
|
|
state._recordBudgetCash(0, "tilebuild", -1_000);
|
|
state._recordBudgetCategoryResource(0, "tilebuild", "steel", 1_000, "Road", 50);
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
|
|
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
|
|
this.assertApprox(steel[1], 1_000);
|
|
this.assertApprox(steel[3], 50, 1e-9, "the quantity travels with the money");
|
|
this.assertApprox(steel[1] / steel[3], 20, 1e-9, "so the price per unit can be read");
|
|
this.assertApprox(steel[2][0][2], 50, 1e-9, "the source carries its quantity too");
|
|
}
|
|
|
|
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 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);
|
|
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();
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const buildings = report.expenses.find((entry) => entry.id === "buildings");
|
|
this.assertNotNull(buildings, "building upkeep is recorded");
|
|
this.assertGreater(buildings.buys.length, 0, "the row names the resource it bought");
|
|
const steel = buildings.buys.find((entry) => entry[0] === "steel");
|
|
this.assertGreater(steel[3], 0, "the row knows how much steel it wore");
|
|
const sourceQty = steel[2].reduce((sum, source) => sum + (source[2] || 0), 0);
|
|
this.assertApprox(sourceQty, steel[3], 1e-9, "the sources carry the worn quantity too");
|
|
}
|
|
|
|
test_upkeep_splits_the_worn_quantity_across_its_sources() {
|
|
const state = smallState();
|
|
state._recordBudgetCash(0, "transport", -900);
|
|
state._recordResourceBySource(
|
|
0, "transport", "steel", 900,
|
|
[{ label: "Roads", steel: 2 }, { label: "Railways", steel: 1 }],
|
|
"steel", 300
|
|
);
|
|
const report = state.budgetMonthReports(0).at(-1);
|
|
const transport = report.expenses.find((entry) => entry.id === "transport");
|
|
const steel = transport.buys.find((entry) => entry[0] === "steel");
|
|
this.assertApprox(steel[3], 300, 1e-9, "the resource line carries the total quantity");
|
|
const sources = new Map(steel[2].map((source) => [source[0], source]));
|
|
this.assertApprox(sources.get("Roads")[1], 600, 1e-9);
|
|
this.assertApprox(sources.get("Roads")[2], 200, 1e-9, "two thirds of the wear");
|
|
this.assertApprox(sources.get("Railways")[1], 300, 1e-9);
|
|
this.assertApprox(sources.get("Railways")[2], 100, 1e-9, "one third of the wear");
|
|
}
|
|
|
|
test_the_snapshot_ships_the_month_reports() {
|
|
const state = smallState();
|
|
const snapshot = state.snapshot(0);
|
|
const months = snapshot.viewerStats.budgetMonths;
|
|
this.assertGreaterOrEqual(months.length, 1);
|
|
const current = months[months.length - 1];
|
|
this.assertTrue(current.isCurrent, "the last shipped report is the live month");
|
|
this.assertTrue(Array.isArray(current.expenses), "the report carries its expenses");
|
|
this.assertEqual(current.label, "January 2000", "the shipped month is named");
|
|
}
|
|
}
|