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