Files
Battle-for-Tismo/shared/game_state/budget.js
T
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
2026-09-25 14:58:49 +02:00

222 lines
9.0 KiB
JavaScript

// The monthly budget ledger. Money moves in and out of a nation's treasury all
// day; this keeps a running total for the calendar month, filed under the
// expense categories the budget screen shows, so the panel can report what the
// current month has cost so far and what each past month cost.
//
// Two figures are kept per category. `cash` is the money that actually left the
// treasury; `economic` is the market value of the materials the category
// consumed, whether they were drawn from the nation's stores or bought -- the
// upkeep rows consume stock for free, so their economic cost is the truer
// measure of what they took. Income and resource sales are the positive side.
//
// On every calendar-month rollover the live ledger is frozen into the history
// and a fresh one begins, so the panel can page back through the last two years.
import { BUDGET_EXPENSE_CATEGORIES } from "../data/economy.js";
import { MONTH_NAMES } from "../game_clock.js";
// How many completed months the history keeps. Two years is enough to compare a
// season with the one before it without growing the snapshot without bound.
export const BUDGET_HISTORY_MONTHS = 24;
function emptyBudgetLedger() {
// Tax income is booked under the kind of trade tax that raised it -- or the
// simple economy's single `taxIncome` category; `income` is kept as the
// running total the report reads.
const cash = { income: 0, sell: 0, taxIncome: 0, taxSales: 0, taxExport: 0, taxImport: 0 };
const economic = {};
for (const category of BUDGET_EXPENSE_CATEGORIES) {
cash[category.id] = 0;
economic[category.id] = 0;
}
// Money spent per category, broken down by the resource it bought, so an
// expense row can expand to show what it consumed and what it paid.
const resourceByCategory = {};
for (const category of BUDGET_EXPENSE_CATEGORIES) resourceByCategory[category.id] = {};
return { cash, economic, resourceBuy: {}, resourceSell: {}, resourceByCategory };
}
export const budgetMethods = {
_initBudgetHistory() {
this.budgetLedger = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.budgetLedger.set(civ, emptyBudgetLedger());
}
// Frozen months, oldest first: [{ monthIndex, records: [ledger, ...] }].
this.budgetHistory = [];
},
_budgetRecord(civ) {
if (!this.budgetLedger) this._initBudgetHistory();
let record = this.budgetLedger.get(civ);
if (!record) {
record = emptyBudgetLedger();
this.budgetLedger.set(civ, record);
}
return record;
},
// A signed cash movement for the month: positive is money into the treasury
// (tax, a resource sale, a refund), negative is money spent. Recorded once per
// category so the report can total them.
_recordBudgetCash(civ, category, amount) {
if (civ < 0 || !(amount !== 0)) return;
const record = this._budgetRecord(civ);
record.cash[category] = (record.cash[category] || 0) + amount;
},
// The market value of the materials one category consumed, whatever their
// source, so an upkeep row can show what it cost even when the stores paid.
_recordBudgetEconomic(civ, category, amount) {
if (civ < 0 || !(amount > 0)) return;
const record = this._budgetRecord(civ);
record.economic[category] = (record.economic[category] || 0) + amount;
},
// The money one expense category spent on one resource, so the row can be
// expanded to show what it bought. `source` names what consumed it -- a
// building, unit, work or transport type -- and is optional. `quantity` is how
// many units of the resource the money bought, so the row can also show the
// price paid per unit.
_recordBudgetCategoryResource(civ, category, resource, amount, source = null, quantity = 0) {
if (civ < 0 || !(amount > 0)) return;
const record = this._budgetRecord(civ);
const byResource = record.resourceByCategory[category]
|| (record.resourceByCategory[category] = {});
let entry = byResource[resource];
if (!entry || typeof entry === "number") {
entry = {
total: typeof entry === "number" ? entry : 0,
quantity: 0,
sources: {},
sourceQuantity: {},
};
byResource[resource] = entry;
}
entry.total += amount;
if (quantity > 0) entry.quantity = (entry.quantity || 0) + quantity;
if (source) {
entry.sources[source] = (entry.sources[source] || 0) + amount;
if (quantity > 0) {
entry.sourceQuantity[source] = (entry.sourceQuantity[source] || 0) + quantity;
}
}
},
// The money a nation spent buying and earned selling one resource to the
// world market or another country, for the month's resource-trade memo.
_recordBudgetResource(civ, resource, bought, sold) {
if (civ < 0) return;
const record = this._budgetRecord(civ);
if (bought > 0) record.resourceBuy[resource] = (record.resourceBuy[resource] || 0) + bought;
if (sold > 0) record.resourceSell[resource] = (record.resourceSell[resource] || 0) + sold;
},
// Freezes the month that just ended and starts a new ledger. Called from the
// monthly rollover with the month index that is being closed.
_rolloverBudget(previousIndex) {
if (!this.budgetLedger) return;
const records = [];
for (let civ = 0; civ < this.civilisations.length; civ++) {
records.push(this.budgetLedger.get(civ) || emptyBudgetLedger());
}
this.budgetHistory.push({ monthIndex: previousIndex, records });
while (this.budgetHistory.length > BUDGET_HISTORY_MONTHS) this.budgetHistory.shift();
this.budgetLedger = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.budgetLedger.set(civ, emptyBudgetLedger());
}
},
// Every month the panel may show, oldest first, with the live month last.
budgetMonthReports(civ) {
const reports = [];
for (const entry of this.budgetHistory || []) {
reports.push(this._budgetReport(civ, entry.monthIndex, entry.records[civ], false));
}
const currentIndex = this._monthIndexOf(this.totalHours);
reports.push(this._budgetReport(
civ,
currentIndex,
this.budgetLedger ? this.budgetLedger.get(civ) : null,
true
));
return reports;
},
_budgetReport(civ, monthIndex, record, isCurrent) {
const cash = (record && record.cash) || {};
const economic = (record && record.economic) || {};
const expenses = [];
let totalCash = 0;
let totalEconomic = 0;
const resourceByCategory = (record && record.resourceByCategory) || {};
for (const category of BUDGET_EXPENSE_CATEGORIES) {
const spent = Math.max(0, -(cash[category.id] || 0));
// Each buy is [resource, money, [[source, money, quantity], ...], quantity]:
// what the category bought, which buildings, units or works consumed it,
// and how many units the money bought (so the panel can show the price).
const buys = Object.entries(resourceByCategory[category.id] || {})
.map(([resource, detail]) => {
if (typeof detail === "number") return [resource, detail, [], 0];
const sourceQuantity = detail.sourceQuantity || {};
const sources = Object.entries(detail.sources || {})
.filter(([, amount]) => amount > 0)
.sort((a, b) => b[1] - a[1])
.map(([name, amount]) => [name, amount, sourceQuantity[name] || 0]);
return [resource, detail.total || 0, sources, detail.quantity || 0];
})
.filter(([, amount]) => amount > 0)
.sort((a, b) => b[1] - a[1]);
// A category that only bought materials -- a private improvement upgrade,
// whose cash never passed through the treasury -- still has a cost.
const bought = buys.reduce((sum, buy) => sum + buy[1], 0);
const cost = Math.max(0, economic[category.id] || 0, spent, bought);
if (spent === 0 && cost === 0) continue;
expenses.push({
id: category.id,
label: category.label,
group: category.group,
cash: spent,
economic: cost,
buys,
});
totalCash += spent;
totalEconomic += cost;
}
const resourceBuy = (record && record.resourceBuy) || {};
const resourceSell = (record && record.resourceSell) || {};
const resourceIds = new Set([...Object.keys(resourceBuy), ...Object.keys(resourceSell)]);
const resourceTrade = Array.from(resourceIds)
.map((id) => ({
id,
bought: Math.max(0, resourceBuy[id] || 0),
sold: Math.max(0, resourceSell[id] || 0),
}))
.filter((entry) => entry.bought > 0 || entry.sold > 0);
const taxes = {
income: cash.taxIncome || 0,
sales: cash.taxSales || 0,
export: cash.taxExport || 0,
import: cash.taxImport || 0,
};
const income = taxes.income + taxes.sales + taxes.export + taxes.import;
const sales = cash.sell || 0;
const year = Math.floor(monthIndex / 12);
const month = monthIndex % 12;
return {
monthIndex,
isCurrent,
label: `${MONTH_NAMES[month]} ${year}`,
income,
taxes,
sales,
expenses,
resourceTrade,
totalCash,
totalEconomic,
net: income + sales - totalCash,
};
},
};