Made producers pay their own way and settled the world to steady prices

- 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.
This commit is contained in:
2026-09-23 21:45:28 +02:00
parent 31683be27d
commit 80429918c6
22 changed files with 584 additions and 101 deletions
+53
View File
@@ -2,6 +2,21 @@ 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() {
@@ -50,6 +65,7 @@ export class BudgetTest extends TestCase {
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);
@@ -111,6 +127,19 @@ export class BudgetTest extends TestCase {
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);
@@ -145,6 +174,29 @@ export class BudgetTest extends TestCase {
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() {
@@ -155,5 +207,6 @@ export class BudgetTest extends TestCase {
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");
}
}
+100 -6
View File
@@ -17,6 +17,19 @@ function bareOwnedLand(state, civ = 0) {
});
}
// Makes `id` the only tile improvement in the world, so the works-upkeep math
// is not disturbed by the producers the world opens with.
function soleWork(state, coords, id) {
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, id);
state.tileImprovementOwner.set(k, 0);
return k;
}
// Registers a construction site literal, so the site tick can be exercised
// without a full build order.
function makeSite(state, coords, overrides = {}) {
@@ -121,6 +134,26 @@ export class ConstructionSiteTest extends TestCase {
this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials");
}
test_a_site_files_what_it_bought_and_what_it_paid() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
const report = state.budgetMonthReports(0).at(-1);
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
this.assertNotNull(tilebuild, "the tile-improvement expense is recorded");
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
this.assertNotNull(steel, "the steel it bought is broken out");
this.assertApprox(steel[3], 100, 1e-6, "the quantity bought travels with the money");
this.assertGreater(steel[1], 0, "and so does what it paid for it");
}
test_a_day_that_buys_nothing_stalls_the_site() {
const state = smallState();
clearMaterials(state);
@@ -300,6 +333,71 @@ export class ConstructionSiteTest extends TestCase {
this.assertNotNull(state.constructionSiteAt(coords));
}
test_a_private_site_borrows_instead_of_touching_the_treasury() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = state.landCells.find((cell) => {
const k = key(cell.x, cell.y);
return state.civAt(cell) === 0 && !state.tileImprovements.has(k)
&& !state.constructionSiteAt(cell);
});
const k = key(coords.x, coords.y);
const agent = state._ensureBuildingAgent(k);
agent.cash = 0;
const treasury = 5e8;
state.budgets.set(0, treasury);
const site = makeSite(state, coords, {
id: "steel_mill",
cityId: city.id,
budget: 0,
private: true,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
this.assertApprox(site.bought.steel, 100, 1e-6, "the agent bought the steel");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury was not touched");
this.assertGreater(agent.debt, 0, "the agent borrowed the money");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"a private work files no public tile-improvement expense"
);
}
test_ordering_a_producer_spends_no_treasury_and_creates_its_agent() {
const state = smallState();
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.steel = 1e9;
stock.hightech = 1e9;
stock.energy = 1e9;
}
const treasury = 1e9;
state.budgets.set(0, treasury);
const coords = state.landCells.find(
(cell) => state.civAt(cell) === 0 && !state.cityAt(cell)
);
const k = key(coords.x, coords.y);
this.assertTrue(state.requestBuildTileImprovement(0, coords, "steel_mill"));
const agent = state.buildingAgents.get(k);
this.assertNotNull(agent, "the producer's agent opens with the order");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury pays nothing");
this.assertGreater(agent.debt, 0, "the work is funded by a bank loan");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"nothing is filed as a public tile-improvement expense"
);
}
test_a_finished_site_keeps_its_leftover_as_improvement_cash() {
const state = smallState();
const city = cityOf(state, 0);
@@ -325,9 +423,7 @@ export class ConstructionSiteTest extends TestCase {
test_a_works_reserve_pays_toward_its_own_upkeep() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
const k = soleWork(state, coords, "fortifications");
state.improvementCash.set(k, 1e9);
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
@@ -344,9 +440,7 @@ export class ConstructionSiteTest extends TestCase {
test_a_works_reserve_only_covers_what_its_money_reaches() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
const k = soleWork(state, coords, "fortifications");
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const unit = state._marketUnitCost("steel");
+40 -2
View File
@@ -8,6 +8,8 @@ import {
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
} from "../shared/data.js";
import { protoUpkeep } from "../shared/resources.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
// A production building of `id` on the first land tile of `civ`'s that is not a
// city, wired into the agent maps and with an empty stock. The tile may already
@@ -89,6 +91,37 @@ export class ProductionBuildingAgentTest extends TestCase {
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
}
test_a_producer_borrows_what_its_own_wear_cannot_cover() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const value = costs.steel * state._marketUnitCost("steel") +
costs.hightech * state._marketUnitCost("hightech");
// Half a day's wear in the account: the rest becomes a central-bank loan.
agent.cash = value / 2;
const categories = {
works: { steel: costs.steel, hightech: costs.hightech, sources: [] },
};
state._applyWorksMaintenance(0, categories);
this.assertApprox(agent.cash, 0, 1e-6, "the cash was spent first");
this.assertApprox(agent.debt, value / 2, 1e-6, "the rest is a loan");
this.assertApprox(categories.works.steel, 0, 1e-6, "the state is not billed for a producer");
this.assertApprox(categories.works.hightech, 0, 1e-6, "nor for its high-tech");
}
test_a_producers_loan_compounds_and_is_repaid_from_its_cash() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.debt = 1_000;
const rate = state.getCentralBankRate(0) / DAYS_PER_YEAR;
// Exactly one day's interest plus the principal, so repayment clears it.
agent.cash = 1_000 * (1 + rate);
state._tickMoney();
this.assertApprox(agent.debt, 0, 1e-6, "the loan is repaid");
this.assertApprox(agent.cash, 0, 1e-6, "the cash it held paid it down");
}
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
@@ -174,14 +207,17 @@ export class ProductionBuildingAgentTest extends TestCase {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 4_000_000;
agent.debt = 250_000;
const stats = state.snapshot(0).viewerStats;
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
this.assertApprox(stats.privateBuildingDebt, 250_000, 1e-6, "the loan is shipped too");
const entry = stats.productionBuildings.find(
(item) => item.coords[0] === coords.x && item.coords[1] === coords.y
);
this.assertNotNull(entry, "the building is listed on its own row");
this.assertEqual(entry.level, 0);
this.assertApprox(entry.cash, 4_000_000, 1e-6);
this.assertApprox(entry.debt, 250_000, 1e-6);
}
}
@@ -192,7 +228,7 @@ export class PowerMarketTest extends TestCase {
const converter = placeProducer(state, "steel_mill");
const city = cityOf(state, 0);
const plantBefore = plant.agent.cash;
const converterBefore = converter.agent.cash;
const converterDebtBefore = converter.agent.debt;
const regionBefore = state.getRegionCashValue(city);
const price = state.getResourcePrice("energy");
const ledger = new Map([[0, {
@@ -213,7 +249,9 @@ export class PowerMarketTest extends TestCase {
}]]);
state._settleGridPower(ledger);
this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid");
this.assertApprox(converter.agent.cash - converterBefore, -200 * price, 1e-6, "the converter pays");
this.assertApprox(converter.agent.debt - converterDebtBefore, 200 * price, 1e-6,
"the converter borrows to pay");
this.assertApprox(converter.agent.cash, 0, 1e-6, "and its cash stays out of the red");
this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6,
"the region pays");
this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend");
+39
View File
@@ -0,0 +1,39 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
// The opening settle: the server runs the world forward until commodity prices
// hold steady, so a fresh game is not served mid-shakeout.
export class WarmupTest extends TestCase {
test_prices_are_not_steady_before_the_window_is_full() {
const state = smallState();
this.assertFalse(state.pricesAreSteady(7, 0.1), "a fresh market has no history to judge");
}
test_settling_stops_once_prices_hold_steady() {
const state = smallState();
const days = state.warmUpToStability({ maxDays: 90 });
this.assertLess(days, 90, "it stopped early rather than at the cap");
this.assertGreaterOrEqual(days, 7, "it ran at least the seven-day window");
this.assertTrue(state.pricesAreSteady(7, 0.1), "prices are steady when it decides to stop");
}
test_settling_stops_at_the_cap_when_it_never_steadies() {
const state = smallState();
const days = state.warmUpToStability({ maxDays: 3 });
this.assertEqual(days, 3, "the cap bounds a world that never settles");
this.assertEqual(state.totalHours, 3 * HOURS_PER_DAY, "three days were actually run");
}
test_a_zero_cap_skips_the_settle() {
const state = smallState();
this.assertEqual(state.warmUpToStability({ maxDays: 0 }), 0, "nothing is run");
this.assertEqual(state.totalHours, 0, "the clock did not move");
}
test_a_fixed_warm_up_still_runs_the_exact_days() {
const state = smallState();
state.warmUp(5);
this.assertEqual(state.totalHours, 5 * HOURS_PER_DAY, "a fixed settle runs every day asked for");
}
}