From 91dfedf7ecbf266c7912cd3cf1695f2ff00ed9e1 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Tue, 22 Sep 2026 06:41:58 +0200 Subject: [PATCH] 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. --- client/css/style.css | 3 + client/index.html | 7 + client/js/game_screen.js | 3 + client/js/game_screen/panels.js | 34 +- client/js/modals/budget.js | 33 +- client/js/modals/city.js | 127 +++- client/js/modals/nation.js | 222 ++++++- shared/game_state.js | 2 + shared/game_state/budget.js | 22 +- shared/game_state/economy.js | 67 ++- shared/game_state/money.js | 89 ++- shared/game_state/orders.js | 14 +- shared/game_state/resources.js | 924 +++++++++++++++++++++-------- shared/game_state/serialization.js | 14 + shared/game_state/warfare.js | 2 +- tests/budget_test.js | 26 +- tests/game_state_buildings_test.js | 20 +- tests/game_state_test.js | 21 +- tests/improvements_test.js | 25 +- tests/modals_test.js | 120 +++- tests/money_test.js | 78 ++- tests/politics_test.js | 7 + tests/regression_test.js | 4 +- tests/resources_test.js | 257 +++++++- 24 files changed, 1726 insertions(+), 395 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index a338ead..748238d 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1349,6 +1349,9 @@ input:focus, select:focus { border-color: var(--accent); } .budget-table tr.budget-group .budget-name.clickable:hover { color: var(--accent); } .budget-caret { display: inline-block; width: 12px; color: var(--muted); } .budget-note { margin-top: 2px; color: var(--muted); font-size: 11px; } +.budget-table tr.budget-buy .budget-name { padding-left: 26px; } +.budget-table tr.budget-source .budget-name { padding-left: 44px; color: var(--muted); } +.budget-table tr.budget-source .budget-value { color: var(--muted); } .budget-table tfoot .budget-total td { padding-top: 10px; border-top: 2px solid rgba(255, 255, 255, 0.25); diff --git a/client/index.html b/client/index.html index 32c3a10..083806f 100644 --- a/client/index.html +++ b/client/index.html @@ -225,6 +225,7 @@ + @@ -244,6 +245,10 @@ +
+ + +
@@ -351,6 +356,8 @@
+ +
diff --git a/client/js/game_screen.js b/client/js/game_screen.js index edc45ec..b52cd18 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -61,6 +61,7 @@ export class GameScreen { this._conflicts = []; this._news = []; this._seenNews = null; + this._seenAlerts = null; this._entered = false; // Detects buildings and units the local player finishes between snapshots; // technologies are picked up from the news feed instead. @@ -141,6 +142,7 @@ export class GameScreen { this.$screen.removeClass("active"); this.feed.clear(); this._seenNews = null; + this._seenAlerts = null; this.completions.reset(); this.bombing.reset(); this.airBombing.reset(); @@ -229,6 +231,7 @@ export class GameScreen { this._conflicts = state.conflicts || []; this._news = state.news || []; this._updateFeed(this._news); + this._updateResourceAlerts(state.viewerStats); this.map.setCatalogue(this.civilisations, this.protoUnits); // Mirror the viewer's repeatable-research bonuses used by prediction. diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 61e2924..7555664 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -1637,6 +1637,10 @@ export const panelMethods = { debt: cityStats.debt || 0, currencies: stats.currencies || [], approval: cityStats.approval, + // What the region's people went short of over the last day. + shortage: cityStats.shortage || {}, + // The region's reserves, their value and its daily consumption. + resources: cityStats.resources || null, // The region's tax base and approval price an upgrade's expected income. base: cityStats.base || 0, // Construction technology shortens the displayed build times. @@ -1671,8 +1675,10 @@ export const panelMethods = { cityStats.ethnicMakeup || [], cityStats.approval, this._taxMultiplier(cityStats.approval), - this.civilisations + this.civilisations, + cityStats.shortage || {} ); + this.cityModal.renderResources(cityStats.resources || null); } this.cityModal.refreshBuildings( stats.budget || 0, @@ -1773,6 +1779,8 @@ export const panelMethods = { exchangeRates: stats.exchangeRates || [], centralBank: stats.centralBank || { rate: 0, reserves: [] }, privateCash: stats.privateCash || 0, + // Where the viewer's own currency sits: treasury, regions, foreign banks. + currencyHoldings: stats.currencyHoldings || null, cities: (this.snapshot && this.snapshot.cityStats) || [], }; }, @@ -1895,6 +1903,30 @@ export const panelMethods = { this.feed.push(describeNews(entry, this.civilisations)); }, + // Pops the viewer's own daily resource-shortage notices into the corner feed. + // Each building, region and resource is announced once per game day. + _updateResourceAlerts(stats) { + const alerts = (stats && stats.resourceAlerts) || []; + if (this._seenAlerts === null) { + this._seenAlerts = new Set(alerts.map((alert) => this._alertSignature(alert))); + return; + } + for (const alert of alerts) { + const signature = this._alertSignature(alert); + if (this._seenAlerts.has(signature)) continue; + this._seenAlerts.add(signature); + const proto = resourceById(alert.resource); + const name = proto ? proto.name : alert.resource; + this.feed.push( + `${alert.building} in ${alert.region} is missing ${name}`, { alert: true } + ); + } + }, + + _alertSignature(alert) { + return `${alert.day || 0}|${alert.building}|${alert.region}|${alert.resource}`; + }, + _finances(stats) { if (!stats) return null; // The month reports page back through the stored budget digests; the region diff --git a/client/js/modals/budget.js b/client/js/modals/budget.js index 3671c41..b031768 100644 --- a/client/js/modals/budget.js +++ b/client/js/modals/budget.js @@ -160,12 +160,15 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog for (const section of sections) { $body.append(subsectionRow(section.group)); for (const row of section.rows) { - const note = row.economic > row.cash + 1 - ? `materials valued at ${currencySymbol()}${groupDigits(Math.round(row.economic))}` - : null; + // The rightmost column is the month's true material cost. When the stores + // covered part of it only the smaller cash figure left the treasury, so + // that is noted under the name. Expanding names each resource bought and + // the buildings, units or works that consumed it. + const valued = row.economic > row.cash + 1; + const note = valued ? `cash ${money(-row.cash)}` : null; const isOpen = expanded.has(row.id); const expandable = row.buys.length > 0; - const $tr = monthRow(row.label, -row.cash, "expense-detail", note); + const $tr = monthRow(row.label, -row.economic, "expense-detail", note); if (expandable) { const $name = $tr.find(".budget-name"); $name.addClass("clickable"); @@ -178,10 +181,16 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog } $body.append($tr); if (isOpen) { - for (const [resource, amount] of row.buys) { + for (const buy of row.buys) { + const [resource, amount, sources] = buy; const proto = resourceById(resource); const name = proto ? proto.name : resource; - $body.append(monthRow(` ${name}`, -amount, "expense-detail", null)); + $body.append(monthRow(name, -amount, "expense-detail budget-buy", null)); + for (const [source, sourceAmount] of sources || []) { + $body.append( + monthRow(source, -sourceAmount, "expense-detail budget-source", null) + ); + } } } } @@ -198,9 +207,12 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog } } + const totalNote = report.totalEconomic > report.totalCash + 1 + ? `materials valued at ${currencySymbol()}${groupDigits(Math.round(report.totalEconomic))}` + : null; const $table = $("
"); $table.append($body); - $table.append($("").append(monthTotalRow("TOTAL", report.net))); + $table.append($("").append(monthTotalRow("TOTAL", report.net, totalNote))); $list.append($table); } @@ -212,10 +224,11 @@ function monthRow(label, value, cls, note) { return $tr; } -function monthTotalRow(label, value) { +function monthTotalRow(label, value, note) { const $tr = $(""); - $tr.append($("").text(label)); - $tr.append($("").text(money(value))); + const $name = $("").text(label); + if (note) $name.append($("
").text(note)); + $tr.append($name, $("").text(money(value))); return $tr; } diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 59651ba..7848029 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -3,11 +3,10 @@ // city's production queue, so the train tab shows both kinds of order. import { groupDigits, hours } from "../../../shared/text_format.js"; -import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue, resourceById } from "../../../shared/data.js"; +import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue, resourceById, RESOURCE_IDS } from "../../../shared/data.js"; import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js"; import { buildingBuildCost, - buildingRefund, buildingUpkeep, } from "../../../shared/rules.js"; import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js"; @@ -36,6 +35,9 @@ export class CityModal { this.$budgetSub = $("#city-budget-sub"); this.$budgetList = $("#city-budget-list"); + this.$resourcesSub = $("#city-resources-sub"); + this.$resourcesList = $("#city-resources-list"); + this.$economySub = $("#city-economy-sub"); this.$economyList = $("#city-economy-list"); @@ -97,14 +99,22 @@ export class CityModal { }); this.$budgetSub.text(`Region net +${currencySymbol()}0/h`); this.renderBudget(null); - this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []); + this.renderPeople( + view.ethnicMakeup || [], + view.approval, + view.taxMultiplier, + view.civs || [], + view.shortage || {} + ); + this.renderResources(view.resources); this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0); this.$modal.removeClass("hidden"); } // The region's people: their ethnic makeup and their government approval, - // which decides how much tax they pay and how fast they build. - renderPeople(makeup, approval, taxMultiplier, civs) { + // which decides how much tax they pay and how fast they build. `shortage` + // reports the resources they went short of over the last day and by how much. + renderPeople(makeup, approval, taxMultiplier, civs, shortage = {}) { if (!this.$peopleList || !this.$peopleList.length) return; this.$peopleList.empty(); const value = approval === undefined || approval === null ? 0.5 : approval; @@ -116,6 +126,7 @@ export class CityModal { } else { this.$peopleList.append($("
").text("No population data.")); } + this._renderShortages(shortage || {}); if (taxMultiplier !== undefined && taxMultiplier < 0.999) { this.$peopleList.append( $("
").text( @@ -126,6 +137,107 @@ export class CityModal { } } + // What the region's people went without over the last day: each resource they + // could not buy enough of, the amount it left them short, and the share of + // the day's need that was. + _renderShortages(shortage) { + const entries = Object.entries(shortage); + this.$peopleList.append($("
").text("Resource shortages")); + if (entries.length === 0) { + this.$peopleList.append( + $("
").text( + "No shortages: the region met all of its people's and industry's needs." + ) + ); + return; + } + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Resource")) + .append($("").text("Short by")) + .append($("").text("Share of need")) + ); + const $body = $(""); + for (const [id, entry] of entries) { + const proto = resourceById(id); + const name = proto ? proto.name : id; + const amount = typeof entry === "number" ? entry : entry.amount; + const fraction = typeof entry === "number" ? 0 : entry.fraction; + $body.append( + $("") + .append($("").text(name)) + .append( + $("").text(formatResourceAmount(id, amount || 0)) + ) + .append( + $("").text(`${Math.round((fraction || 0) * 100)}%`) + ) + ); + } + $table.append($head, $body); + this.$peopleList.append($table); + this.$peopleList.append( + $("
").text( + "Shortages cut approval and slow the region's economy." + ) + ); + } + + // The region's material reserves: how much of each resource it holds, what + // that reserve is worth at today's market, and how much its private sector + // draws down each day. Energy is a flow, so it has no reserve of its own. + renderResources(resources) { + if (!this.$resourcesList || !this.$resourcesList.length) return; + this.$resourcesList.empty(); + const info = resources || {}; + const stock = info.stock || {}; + const consumption = info.consumption || {}; + const value = info.value || 0; + if (this.$resourcesSub && this.$resourcesSub.length) { + this.$resourcesSub.text( + `Reserves worth ${currencySymbol()}${groupDigits(Math.round(value))}` + ); + } + this.$resourcesList.append( + $("
").text( + "The region's private sector holds these reserves and draws them down as " + + "its people and buildings consume them. Energy is a flow -- it has no reserve." + ) + ); + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Resource")) + .append($("").text("Reserve")) + .append($("").text("Consumption/day")) + .append($("").text("Value")) + ); + const $body = $(""); + for (const id of RESOURCE_IDS) { + const proto = resourceById(id); + const name = proto ? proto.name : id; + const storable = proto ? proto.storable : false; + const held = stock[id] || 0; + const price = this.prices[id] || 0; + const row = $("").append($("").text(name)); + row.append( + $("").text(storable ? formatResourceAmount(id, held) : "—") + ); + row.append( + $("").text(formatResourceAmount(id, consumption[id] || 0)) + ); + row.append( + $("").text( + storable ? `${currencySymbol()}${groupDigits(Math.round(held * price))}` : "—" + ) + ); + $body.append(row); + } + $table.append($head, $body); + this.$resourcesList.append($table); + } + // The region's private-sector cash: a basket of currencies it holds. Regions // are the game's private economic agents, so this is their own money, not the // government treasury. @@ -419,9 +531,8 @@ export class CityModal { this._setBuildTooltip(row.$free, row.proto, level, nextLevel + 1, region); } if (level > 0) { - row.$downgrade - .text(`Downgrade +${currencySymbol()}${groupDigits(Math.round(buildingRefund(row.proto, level)))}`) - .prop("disabled", false); + // Downgrading returns nothing, so the button names no price. + row.$downgrade.text("Downgrade").prop("disabled", false); } else { row.$downgrade.text("Not built").prop("disabled", true); } diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 56171ff..07a0f29 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -5,7 +5,16 @@ import { governmentChangeCost, prerequisitesMet, } from "../../../shared/rules.js"; -import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost, RESOURCES } from "../../../shared/data.js"; +import { + POLICIES, + PROPAGANDA, + policyById, + TECH_THEMES, + technologyCost, + RESOURCES, + RESOURCE_IDS, + resourceById, +} from "../../../shared/data.js"; import { formatResourceAmount } from "../../../shared/resources.js"; import { renderMonthlyBudget } from "./budget.js"; import { effectsText, money, quotedMoney } from "./format.js"; @@ -26,10 +35,12 @@ export class NationModal { this.$economySub = $("#economy-sub"); this.$economySubtabs = $("#economy-subtabs"); this.$economySummary = $("#economy-summary"); + this.$economyConsumption = $("#economy-consumption"); + this.$economyCurrency = $("#economy-currency"); this.$economyCentralBank = $("#economy-central-bank"); this.$economyRates = $("#economy-rates"); - // Which Economy sub-tab is open: the private sector, the central bank or - // the exchange rates. + // Which Economy sub-tab is open: the private sector, the trade table, the + // viewer's own currency, the central bank or the exchange rates. this.economyView = "summary"; // The central-bank interest input is built once and only its value updated, // so typing is not interrupted by the snapshot refresh. @@ -189,13 +200,17 @@ export class NationModal { if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders"); } - // The Economy tab's sub-tabs: the private sector, the central bank and the - // exchange-rate table. + // The Economy tab's sub-tabs: the private sector, the day's consumption and + // trade, the viewer's own currency and where it is held, the central bank and + // the exchange-rate table. _buildEconomySubtabs() { if (!this.$economySubtabs || !this.$economySubtabs.length) return; this.$economySubtabs.empty(); + const currency = (this.money && this.money.currency) || null; const views = [ { id: "summary", label: "Private sector" }, + { id: "consumption", label: "Consumption & trade" }, + { id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" }, { id: "centralbank", label: "Central bank" }, { id: "rates", label: "Exchange rates" }, ]; @@ -217,6 +232,10 @@ export class NationModal { this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active"); } if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary"); + if (this.$economyConsumption) { + this.$economyConsumption.toggleClass("hidden", view !== "consumption"); + } + if (this.$economyCurrency) this.$economyCurrency.toggleClass("hidden", view !== "currency"); if (this.$economyCentralBank) { this.$economyCentralBank.toggleClass("hidden", view !== "centralbank"); } @@ -227,11 +246,190 @@ export class NationModal { _renderEconomy(money) { this.money = money || this.money || {}; - if (this.economyView === "centralbank") this._renderCentralBank(this.money); + if (this.economyView === "consumption") this._renderConsumption(); + else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money); + else if (this.economyView === "centralbank") this._renderCentralBank(this.money); else if (this.economyView === "rates") this._renderExchangeRates(this.money); else this._renderPrivateSector(this.money); } + // The day's resource consumption and the trade that crossed the nation's + // border: what each neighbour and the world market sold us, and what we sold + // them, with the money it moved. + _renderConsumption() { + if (!this.$economyConsumption || !this.$economyConsumption.length) return; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text("Daily consumption and cross-border trade"); + } + this.$economyConsumption.empty(); + const summary = this.resources && this.resources.summary; + if (!summary) { + this.$economyConsumption.append( + $("
").text("No resource data yet.") + ); + return; + } + const consumed = summary.consumed || {}; + const external = summary.external || { buy: {}, sell: {} }; + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Resource")) + .append($("").text("Consumed / day")) + .append($("").text("Bought abroad")) + .append($("").text("Spent")) + .append($("").text("Sold abroad")) + .append($("").text("Earned")) + ); + const $body = $(""); + let totalSpent = 0; + let totalEarned = 0; + for (const id of RESOURCE_IDS) { + const proto = resourceById(id); + const name = proto ? proto.name : id; + const buy = (external.buy && external.buy[id]) || { qty: 0, value: 0 }; + const sell = (external.sell && external.sell[id]) || { qty: 0, value: 0 }; + totalSpent += buy.value || 0; + totalEarned += sell.value || 0; + $body.append( + $("") + .append($("").text(name)) + .append( + $("").text( + formatResourceAmount(id, consumed[id] || 0) + ) + ) + .append($("").text( + buy.qty > 0 ? formatResourceAmount(id, buy.qty) : "—" + )) + .append($("").text( + buy.value > 0 ? this._amount(buy.value) : "—" + )) + .append($("").text( + sell.qty > 0 ? formatResourceAmount(id, sell.qty) : "—" + )) + .append($("").text( + sell.value > 0 ? this._amount(sell.value) : "—" + )) + ); + } + $table.append($head, $body); + $table.append( + $("").append( + $("") + .append($("").text("Trade balance")) + .append($("").text(this._amount(totalSpent))) + .append($("")) + .append($("").text(this._amount(totalEarned))) + ) + ); + this.$economyConsumption.append($table); + this.$economyConsumption.append( + $("
").text( + "Bought and sold counts only trade across the border — foreign " + + "regions reached over the trade network. Neighbours at home are not " + + "a foreign sale." + ) + ); + } + + // Where every unit of the viewer's currency is stored: the treasury, the + // nation's regions, and the foreign regions and central banks that hold it. + _renderCurrencyHoldings(money) { + if (!this.$economyCurrency || !this.$economyCurrency.length) return; + const holdings = money.currencyHoldings; + const currency = money.currency || { name: "Currency", symbol: "¤", code: "" }; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text( + `Holdings of the ${currency.name} (${currency.code})` + ); + } + this.$economyCurrency.empty(); + if (!holdings) { + this.$economyCurrency.append( + $("
").text("No currency data yet.") + ); + return; + } + const $table = $("
"); + const $body = $(""); + $body.append( + $("").append( + $("").text("Treasury") + ) + ); + $body.append( + $("") + .append($("").text("Government")) + .append($("").text(this._amount(holdings.treasury || 0))) + ); + if (holdings.treasuryDebt > 0) { + $body.append( + $("") + .append($("").text("Overdraft (debt)")) + .append($("").text(`−${this._amount(holdings.treasuryDebt)}`)) + ); + } + $body.append( + $("").append( + $("").text(`Your regions (${(holdings.own || []).length})`) + ) + ); + for (const region of holdings.own || []) { + $body.append( + $("") + .append($("").text(region.name)) + .append($("").text(this._amount(region.amount))) + ); + } + if ((holdings.foreign || []).length > 0) { + $body.append( + $("").append( + $("").text("Held by foreign regions") + ) + ); + for (const region of holdings.foreign) { + $body.append( + $("") + .append($("").text(`${region.name} (${region.civName})`)) + .append($("").text(this._amount(region.amount))) + ); + } + } + if ((holdings.banks || []).length > 0) { + $body.append( + $("").append( + $("").text("Central-bank reserves") + ) + ); + for (const bank of holdings.banks) { + $body.append( + $("") + .append($("").text(`Central bank of ${bank.name}`)) + .append($("").text(this._amount(bank.amount))) + ); + } + } + $table.append($body); + const $foot = $("").append( + $("") + .append($("").text("Total in circulation")) + .append($("").text(this._amount(holdings.total || 0))) + ); + $table.append($foot); + this.$economyCurrency.append($table); + this.$economyCurrency.append( + $("
").text( + "Foreign holdings are what exports earned; a foreign central bank " + + "holds your money as a reserve." + ) + ); + } + + _amount(value) { + return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`; + } + // The aggregate private-sector cash the nation's regions hold, and each // region's own reserve. _renderPrivateSector(money) { @@ -498,8 +696,8 @@ export class NationModal { } // The Resources tab: the nation's stores, what it consumes and makes, what it - // imports and exports, beside the global market's price and the world's supply - // and demand. Energy is a daily flow, so it has no store: its row reads the + // imports and exports, beside the reference price and the world's supply and + // demand. Energy is a daily flow, so it has no store: its row reads the // cities' demand against the power plants' output instead. _renderResources(info) { if (!this.$resourcesList || !this.$resourcesList.length) return; @@ -508,7 +706,7 @@ export class NationModal { if (this.$resourcesSub && this.$resourcesSub.length) { this.$resourcesSub.text( "Stores, daily consumption, production and cross-nation trade, with the " + - "global market's price. Energy is delivered as a flow and cannot be stored." + "reference price. Energy is delivered as a flow and cannot be stored." ); } if (!summary) { @@ -594,9 +792,9 @@ export class NationModal { this.$resourcesList.append( $("
").text( "Consumption is the people's needs plus the materials buildings, units and works " + - "wear out. Exports and imports count yesterday's deliveries between nations; the " + - "global market is not a partner. Prices drift with the world's supply and demand, " + - "and a city buys from the cheapest source it can reach, then from the market." + "wear out. Exports and imports count yesterday's deliveries between nations. " + + "Prices drift with the world's supply and demand, and a region buys from its own " + + "stores first, then from reachable home regions, then from foreign ones." ) ); } diff --git a/shared/game_state.js b/shared/game_state.js index 4417edf..27be18a 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -365,6 +365,8 @@ export class GameState { this._visibilityDirty = false; } if (this.totalHours % HOURS_PER_DAY === 0) { + // A fresh day of shortage notices: this day's are published to the owners. + if (this._resourceAlerts) this._resourceAlerts = new Map(); this._tickPopulation(); this._tickPolitics(); this._tickBattleTiles(); diff --git a/shared/game_state/budget.js b/shared/game_state/budget.js index 30ada73..5212066 100644 --- a/shared/game_state/budget.js +++ b/shared/game_state/budget.js @@ -71,13 +71,20 @@ export const budgetMethods = { }, // The money one expense category spent on one resource, so the row can be - // expanded to show what it bought. - _recordBudgetCategoryResource(civ, category, resource, amount) { + // expanded to show what it bought. `source` names what consumed it -- a + // building, unit, work or transport type -- and is optional. + _recordBudgetCategoryResource(civ, category, resource, amount, source = null) { if (civ < 0 || !(amount > 0)) return; const record = this._budgetRecord(civ); const byResource = record.resourceByCategory[category] || (record.resourceByCategory[category] = {}); - byResource[resource] = (byResource[resource] || 0) + amount; + let entry = byResource[resource]; + if (!entry || typeof entry === "number") { + entry = { total: typeof entry === "number" ? entry : 0, sources: {} }; + byResource[resource] = entry; + } + entry.total += amount; + if (source) entry.sources[source] = (entry.sources[source] || 0) + amount; }, // The money a nation spent buying and earned selling one resource to the @@ -132,7 +139,16 @@ export const budgetMethods = { const spent = Math.max(0, -(cash[category.id] || 0)); const cost = Math.max(0, economic[category.id] || 0, spent); if (spent === 0 && cost === 0) continue; + // Each buy is [resource, money, [[source, money], ...]]: what the + // category bought and which buildings, units or works consumed it. const buys = Object.entries(resourceByCategory[category.id] || {}) + .map(([resource, detail]) => { + if (typeof detail === "number") return [resource, detail, []]; + const sources = Object.entries(detail.sources || {}) + .filter(([, amount]) => amount > 0) + .sort((a, b) => b[1] - a[1]); + return [resource, detail.total || 0, sources]; + }) .filter(([, amount]) => amount > 0) .sort((a, b) => b[1] - a[1]); expenses.push({ diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index f2aa884..eeb9a51 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -873,8 +873,8 @@ export const economyMethods = { _spendBudget(civ, amount, category = null) { if (amount <= 0) return true; this.budgets.set(civ, this.getBudget(civ) - amount); - // The money does not vanish: it is paid into the nation's regions, the - // private sector that supplied the goods, labour or services. + // The money does not vanish: it is paid into the nation's own regions, the + // private sector that supplied the labour or service. this._creditRegions(civ, amount); if (category) this._recordBudgetCash(civ, category, -amount); return true; @@ -978,10 +978,17 @@ export const economyMethods = { const cultureRate = modifiers[EFFECT_CULTURE] || 0; if (scienceRate === 0 && cultureRate === 0) continue; // Science buildings spend a unit of high-tech for each research point - // they make, so a nation short of chips researches only as far as its - // store reaches. Government research needs no high-tech. + // they make, so a region short of chips researches only as far as its + // store reaches, and reports the shortfall. Government research needs no + // high-tech. const buildingRate = this.getCivBuildingModifiers(civ)[EFFECT_RESEARCH] || 0; - const effectiveBuilding = this._spendHighTechForResearch(civ, buildingRate); + let effectiveBuilding = 0; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const cityRate = this.getCityBuildingModifiers(city)[EFFECT_RESEARCH] || 0; + if (cityRate <= 0) continue; + effectiveBuilding += this._spendCityHighTechForResearch(civ, city, cityRate); + } this._addResearch(civ, scienceRate - buildingRate + effectiveBuilding); this.culture.set(civ, this.getCulture(civ) + cultureRate); } @@ -1107,22 +1114,48 @@ export const economyMethods = { steel: targetHeal * repair.unitSteelPerHp, hightech: proto.advanced ? targetHeal * repair.unitHighTechPerHp : 0, }; - const fromStock = this._drawFromNearest(city.civ, city.coords, wanted); + const payer = this._treasuryPayer(city.civ); + const fromStock = this._drawFromNearest(city.civ, city.coords, wanted, payer); const shortfall = { steel: wanted.steel - fromStock.steel, hightech: wanted.hightech - fromStock.hightech, }; - const cost = this._marketUnitCost("steel") * shortfall.steel + - this._marketUnitCost("hightech") * shortfall.hightech; - const steelCost = this._marketUnitCost("steel") * shortfall.steel; - const techCost = this._marketUnitCost("hightech") * shortfall.hightech; - const paid = 1; - if (cost > 0) { - // The government can always pay, borrowing if the treasury is dry. - this._spendBudget(city.civ, cost * paid, "units"); - this._recordResourceSpend(city.civ, "upkeep", cost * paid, { - steel: steelCost * paid, - hightech: techCost * paid, + const v = this.currencyValue(city.civ); + let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v; + let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v; + let paid = 1; + if (shortfall.steel > 0 || shortfall.hightech > 0) { + // The replacement steel and high-tech come from reachable regions, + // bought by the treasury. Whatever is not available is not healed. + const nodes = this._resourceNodes(); + const haul = { steel: 0, hightech: 0 }; + let gotSteel = 0; + let gotTech = 0; + if (shortfall.steel > 0) { + const before = payer.total; + gotSteel = this._procureFromNeighbours( + city, "steel", shortfall.steel, haul, nodes, { payer } + ); + spentSteel += payer.total - before; + } + if (shortfall.hightech > 0) { + const before = payer.total; + gotTech = this._procureFromNeighbours( + city, "hightech", shortfall.hightech, haul, nodes, { payer } + ); + spentTech += payer.total - before; + } + if (gotSteel + 1e-6 < shortfall.steel || gotTech + 1e-6 < shortfall.hightech) { + paid = 0; + } + } + if (spentSteel + spentTech > 0) { + this._recordBudgetCash(city.civ, "units", -(spentSteel + spentTech)); + this._recordBudgetCategoryResource(city.civ, "units", "steel", spentSteel); + this._recordBudgetCategoryResource(city.civ, "units", "hightech", spentTech); + this._recordResourceSpend(city.civ, "upkeep", spentSteel + spentTech, { + steel: spentSteel, + hightech: spentTech, }); } const gained = targetHeal * paid; diff --git a/shared/game_state/money.js b/shared/game_state/money.js index 508028e..71e784e 100644 --- a/shared/game_state/money.js +++ b/shared/game_state/money.js @@ -161,21 +161,6 @@ export const moneyMethods = { } }, - // Pays the world market's producers -- every region outside the buyer's nation - // -- so an import does not drain money out of the world. - _payWorld(buyerCiv, amount, code) { - if (!(amount > 0)) return; - const others = this.cities.filter((city) => city.civ !== buyerCiv); - if (others.length === 0) { - this._creditRegions(buyerCiv, amount, code); - return; - } - const shares = this._populationShares(others); - for (let i = 0; i < others.length; i++) { - this._creditRegionCash(others[i], code, amount * shares[i]); - } - }, - // Each region's share of a set's total population, defaulting to an even split // when there are no people at all. _populationShares(cities) { @@ -261,22 +246,25 @@ export const moneyMethods = { return total; }, - // A region buys `globalValue` (in global units) worth of `targetCiv`'s + // A buyer acquires `globalValue` (in global units) worth of `targetCiv`'s // currency: first from what it already holds, then from that nation's central // bank, paying with its own national currency (borrowing from its own bank if // it is short). The target's central bank keeps the buyer's currency as a // foreign reserve. Returns the amount of the target currency that ends up // handed to the seller. - _regionAcquireCurrency(buyerCity, targetCiv, globalValue) { + // + // `charge` overrides who pays the national-currency cost: a region does by + // default, but a government buys with the treasury. `useHeld` is false for the + // treasury, which holds no basket of its own. + _regionAcquireCurrency(buyerCity, targetCiv, globalValue, charge = null, useHeld = true) { if (!(globalValue > 0)) return { code: this.currencyOf(targetCiv).code, amount: 0 }; const target = this.currencyOf(targetCiv); const buyer = this.currencyOf(buyerCity.civ); const vTarget = this.currencyValue(targetCiv); const vBuyer = this.currencyValue(buyerCity.civ); const needTarget = globalValue / vTarget; - const basket = this.regionCash.get(buyerCity.id); - if (!basket) return null; - const heldTarget = basket.get(target.code) || 0; + const basket = useHeld ? this.regionCash.get(buyerCity.id) : null; + const heldTarget = basket ? basket.get(target.code) || 0 : 0; const fromHeld = Math.min(heldTarget, needTarget); const remaining = needTarget - fromHeld; if (remaining > 0) { @@ -285,7 +273,8 @@ export const moneyMethods = { // own central bank if it is short; the seller's bank holds that currency // as a reserve and issues its own, taking on the matching debt so no money // is conjured from nothing. - this._chargeRegionCash(buyerCity, buyer.code, costBuyer); + if (charge) charge(costBuyer); + else this._chargeRegionCash(buyerCity, buyer.code, costBuyer); this._addReserve(targetCiv, buyer.code, costBuyer); this._addCentralBankDebt(targetCiv, target.code, remaining); } @@ -373,6 +362,64 @@ export const moneyMethods = { return total; }, + // Every place a nation's own currency is stored: its treasury, its own + // regions' cash, any foreign region still holding some, and every central + // bank that keeps it as a reserve. Foreign holdings are what exports earned. + getCurrencyHoldings(civ) { + const currency = this.currencyOf(civ); + const code = currency.code; + const own = []; + const foreign = []; + let ownTotal = 0; + let foreignTotal = 0; + for (const city of this.cities) { + const amount = this.getRegionCash(city).get(code) || 0; + if (!(amount > 0)) continue; + const host = this.civilisations[city.civ]; + const entry = { + cityId: city.id, + name: city.name, + civ: city.civ, + civName: host ? host.name : "", + amount, + }; + if (city.civ === civ) { + own.push(entry); + ownTotal += amount; + } else { + foreign.push(entry); + foreignTotal += amount; + } + } + const banks = []; + let bankTotal = 0; + for (let c = 0; c < this.civilisations.length; c++) { + const amount = this.getCentralBankReserves(c).get(code) || 0; + if (!(amount > 0)) continue; + const host = this.civilisations[c]; + banks.push({ civ: c, name: host ? host.name : "", amount }); + bankTotal += amount; + } + const budget = this.getBudget(civ) || 0; + const treasury = Math.max(0, budget); + const treasuryDebt = Math.max(0, -budget); + return { + code, + symbol: currency.symbol, + name: currency.name, + value: this.currencyValue(civ), + treasury, + treasuryDebt, + own, + ownTotal, + foreign, + foreignTotal, + banks, + bankTotal, + total: treasury + ownTotal + foreignTotal + bankTotal, + }; + }, + // --------------------------------------------------- daily tick -- // The day's monetary work: debts to the central bank accrue interest, regions diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index 1d5eb09..5f03693 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -6,7 +6,7 @@ import { ECONOMY } from "../data/economy.js"; import { GOVERNMENTS } from "../data/governments.js"; import { PILLAGE, pillageSteps } from "../data/combat.js"; import { transportImprovement } from "../data/improvements.js"; -import { buildingBuildCost, buildingRefund } from "../rules.js"; +import { buildingBuildCost } from "../rules.js"; import { tileImprovementResourceCost } from "../resources.js"; import { TRAINING_QUEUE_LIMIT } from "./constants.js"; @@ -42,7 +42,7 @@ export const orderMethods = { if (capacity <= 0) return false; if (!this._spendBudget(city.civ, proto.cost, "training")) return false; const materials = this.constructionResourceCost(proto, 0, proto.cost); - if (!this._payConstructionResources(city.civ, city.coords, materials, "training")) { + if (!this._payConstructionResources(city.civ, city.coords, materials, "training", proto.name)) { this._refundBudget(city.civ, proto.cost, "training"); return false; } @@ -115,7 +115,7 @@ export const orderMethods = { if (capacity <= 0) return false; if (!this._spendBudget(city.civ, cost, "construction")) return false; const materials = this.constructionResourceCost(proto, level, cost); - if (!this._payConstructionResources(city.civ, city.coords, materials)) { + if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) { this._refundBudget(city.civ, cost, "construction"); return false; } @@ -154,7 +154,7 @@ export const orderMethods = { if (!free) { if (!this._spendBudget(civ, improvement.buildCost, "tilebuild")) return false; const materials = tileImprovementResourceCost(improvement); - if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) { + if (!this._payConstructionResources(civ, coords, materials, "tilebuild", improvement.name)) { this._refundBudget(civ, improvement.buildCost, "tilebuild"); return false; } @@ -191,12 +191,10 @@ export const orderMethods = { requestDemolish(cityId, protoIndex) { const city = this.findCity(cityId); if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; - const proto = this.protoBuildings[protoIndex]; - const civ = city.civ; const level = this.getCityBuildingLevel(city, protoIndex); if (level <= 0) return false; - const refund = buildingRefund(proto, level); - if (refund > 0) this._refundBudget(civ, refund, "construction"); + // Downgrading refunds nothing: the money and materials already sunk into + // the level are lost. if (level <= 1) delete city.buildings[protoIndex]; else city.buildings[protoIndex] = level - 1; this._gdpPerCapitaCache.clear(); diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index a503b37..f50bb50 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -1,11 +1,13 @@ // Resources: the five material groups nations produce, store, trade and // consume. Storage lives on the map -- a city tile or a resource-building tile // holds its own stock -- while energy is a flow: power plants feed the grid of -// their connected region, and a shortfall is bought and delivered for money. +// their connected region, and a grid in deficit imports power from a reachable +// surplus. // // The daily tick is the heart of it: produce, satisfy electric demand, consume -// what the people need, buy the rest from neighbours and finally from the -// global market, then let shortages bite and prices drift. +// what the people need, buy the rest from reachable stores -- the region first, +// home regions next, foreign ones last -- then let shortages bite and prices +// drift. There is no global market to fall back on. import { key, parseKey } from "../hex.js"; import { @@ -34,7 +36,8 @@ import { } from "../resources.js"; import { buildingBuildCost } from "../rules.js"; import { Random } from "../rng.js"; -import { TILE_IMPROVEMENT_HP } from "./constants.js"; +import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; +import { EFFECT_RESEARCH } from "../data/effects.js"; // A city keeps at least this many days of a material in stock before it will // sell any of it on; the rest is surplus a neighbour may buy. @@ -82,6 +85,13 @@ export const resourceMethods = { // What each nation imported and exported over the last simulated day, by // resource, from cross-nation deliveries only. this.resourceTrade = new Map(); + // What each nation bought from outside its border over the day, with the + // quantity and the money it moved, per resource. + this.resourceExternal = new Map(); + // The day's resource-shortage notices, per nation: a producing building that + // could not get the material or power it needs. Cleared each day and shipped + // to that nation's own viewer only. + this._resourceAlerts = new Map(); // What each nation spent on resources during the current day, split into // electric imports, bought goods, transport, construction and upkeep. this.resourceSpend = new Map(); @@ -121,6 +131,49 @@ export const resourceMethods = { this._recordBudgetResource(civ, resource, 0, amount); }, + // Records that a nation bought a resource from outside its border: a foreign + // neighbour or the world market. `value` is the money paid, in the buyer's + // national currency. Same-nation purchases are not external and are skipped + // by the callers. + _recordExternalBuy(civ, id, qty, value) { + if (civ < 0 || !(qty > 0)) return; + this._externalRecord(civ, "buy", id, qty, value); + }, + + // Records that a nation sold a resource across its border, and the money it + // was paid, in the seller's national currency. + _recordExternalSell(civ, id, qty, value) { + if (civ < 0 || !(qty > 0)) return; + this._externalRecord(civ, "sell", id, qty, value); + }, + + _externalRecord(civ, side, id, qty, value) { + if (!this.resourceExternal) this.resourceExternal = new Map(); + let record = this.resourceExternal.get(civ); + if (!record) { + record = { buy: {}, sell: {} }; + this.resourceExternal.set(civ, record); + } + const entry = record[side][id] || (record[side][id] = { qty: 0, value: 0 }); + entry.qty += qty; + if (value > 0) entry.value += value; + }, + + // One nation's cross-border buying and selling over the last day, as plain + // per-resource { qty, value } objects for the Economy tab's trade table. + _civExternalTrade(civ) { + const record = this.resourceExternal ? this.resourceExternal.get(civ) : null; + const buy = {}; + const sell = {}; + for (const id of RESOURCE_IDS) { + const b = record && record.buy[id]; + const s = record && record.sell[id]; + buy[id] = { qty: b ? b.qty : 0, value: b ? b.value : 0 }; + sell[id] = { qty: s ? s.qty : 0, value: s ? s.value : 0 }; + } + return { buy, sell }; + }, + _resourceRecord(civ) { let record = this.resourceSpend.get(civ); if (!record) { @@ -427,6 +480,60 @@ export const resourceMethods = { }; }, + // What one region holds and draws per day, for its city panel: the reserve of + // every storable resource, that reserve's market value, and the private + // sector's daily consumption -- the people's staples, the region's building + // upkeep and the grid power its industry pulls. + getRegionResourceInfo(city) { + const economy = this.getCityEconomy(city); + const population = economy.population; + const store = this.getCityResourceStock(city); + const consumption = { + food: foodNeedPerDay(population), + steel: steelNeedPerDay(population), + luxury: luxuryNeedPerDay(population), + hightech: 0, + energy: cityEnergyPerDay(economy.gdp), + }; + for (const [index, level] of Object.entries(city.buildings || {})) { + if (level <= 0) continue; + const proto = this.protoBuildings[Number(index)]; + if (!proto) continue; + const costs = this.buildingUpkeepResources(proto, level); + consumption.steel += costs.steel || 0; + consumption.energy += costs.energy || 0; + consumption.hightech += costs.hightech || 0; + } + const stock = {}; + let value = 0; + for (const id of STORABLE_RESOURCE_IDS) { + const amount = store[id] || 0; + stock[id] = amount; + value += amount * this.getResourcePrice(id); + } + return { stock, consumption, value }; + }, + + // The region's unmet resource needs over the last day: for each resource the + // share of the day's need it could not buy, and the amount that stands for, + // so the People tab can say what ran short and by how much. + getRegionShortages(city) { + const fractions = (this._resourceShortages && this._resourceShortages.get(city.id)) || {}; + const population = this.getCityEconomy(city).population; + const needs = { + food: foodNeedPerDay(population), + steel: steelNeedPerDay(population), + luxury: luxuryNeedPerDay(population), + }; + const shortages = {}; + for (const id of ["food", "steel", "luxury"]) { + const fraction = fractions[id] || 0; + if (!(fraction > 0)) continue; + shortages[id] = { fraction, amount: fraction * needs[id] }; + } + return shortages; + }, + // Every resource figure one nation's cities hold, for the summary panel: the // summed stock, the summed daily need and the summed production. Energy has // no store, so it travels as a daily demand and the plants' daily supply. @@ -491,6 +598,9 @@ export const resourceMethods = { consumed, // Yesterday's cross-nation deliveries: what the nation imported and sold. trade: this._civResourceTrade(civ), + // What the nation bought from and sold outside its border over the day, + // with the money it moved, for the Economy tab's consumption table. + external: this._civExternalTrade(civ), prices: Object.fromEntries(RESOURCE_IDS.map((id) => [id, this.getResourcePrice(id)])), }; }, @@ -536,38 +646,27 @@ export const resourceMethods = { return sharedConstructionResourceCost(proto, level, buildCost); }, - // Pays a construction's material bill. The stock of the nation's cities is - // drawn down first, then the global market covers the rest at its current - // price. Energy is a flow rather than a store, so it is bought from the - // market outright. Returns false only when the nation cannot afford the - // market bill, in which case nothing is charged and the drawn stock is put - // back. - _payConstructionResources(civ, coords, resources, category = "construction") { + // Pays a construction's material bill. The stock of the nation's own cities + // is bought first (the region, then its neighbours), each region paid for the + // material into its private cash; the rest is bought from reachable foreign + // regions. There is no global market, so a bill the reachable stores cannot + // cover refuses the construction. Energy is a flow rather than a store, so it + // is not procured here. + _payConstructionResources(civ, coords, resources, category = "construction", source = null) { const steel = resources.steel || 0; const hightech = resources.hightech || 0; const energy = resources.energy || 0; if (steel <= 0 && hightech <= 0 && energy <= 0) return true; - const fromStock = this._drawFromNearest(civ, coords, { steel, hightech }); + const payer = this._treasuryPayer(civ); + const fromStock = this._drawFromNearest(civ, coords, { steel, hightech }, payer); const shortfall = { steel: steel - fromStock.steel, hightech: hightech - fromStock.hightech, }; - const materialsShort = shortfall.steel > 0 || shortfall.hightech > 0; - // No working port means no foreign market: the stores must cover the bill. - // Energy is the exception -- a nation always powers its own works. - if (materialsShort && !this._civMarketAccess(civ)) { - this._refundToNearest(civ, coords, fromStock); - return false; - } - const buyEnergy = this._civMarketAccess(civ) ? energy : 0; - const steelCost = this._marketCost(shortfall.steel, "steel"); - const techCost = this._marketCost(shortfall.hightech, "hightech"); - const energyCost = this._marketCost(buyEnergy, "energy"); - const cost = steelCost + techCost + energyCost; - if (!this._spendBudget(civ, cost, "construction")) { - this._refundToNearest(civ, coords, fromStock); - return false; - } + const v = this.currencyValue(civ); + // What the nation's own stores supplied is paid to the owning regions. + let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v; + let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v; // The whole bill, paid or drawn from stores, is the construction's material // cost for the month's budget report. this._recordBudgetEconomic( @@ -577,35 +676,49 @@ export const resourceMethods = { hightech * this.getResourcePrice("hightech") + energy * this.getResourcePrice("energy") ); - if (steelCost > 0) this._recordBudgetCategoryResource(civ, category, "steel", steelCost); - if (techCost > 0) this._recordBudgetCategoryResource(civ, category, "hightech", techCost); - if (energyCost > 0) this._recordBudgetCategoryResource(civ, category, "energy", energyCost); - if (cost > 0) { - this._recordResourceSpend(civ, "construction", cost, { - steel: steelCost, - hightech: techCost, - energy: energyCost, + let covered = shortfall.steel <= 0 && shortfall.hightech <= 0; + if (!covered) { + const city = this._nearestCity(civ, coords); + if (city) { + const nodes = this._resourceNodes(); + const haul = { steel: 0, hightech: 0 }; + let gotSteel = 0; + let gotTech = 0; + if (shortfall.steel > 0) { + const before = payer.total; + gotSteel = this._procureFromNeighbours(city, "steel", shortfall.steel, haul, nodes, { payer }); + spentSteel += payer.total - before; + } + if (shortfall.hightech > 0) { + const before = payer.total; + gotTech = this._procureFromNeighbours(city, "hightech", shortfall.hightech, haul, nodes, { payer }); + spentTech += payer.total - before; + } + covered = gotSteel + 1e-6 >= shortfall.steel && gotTech + 1e-6 >= shortfall.hightech; + } + } + if (spentSteel > 0) { + this._recordBudgetCash(civ, category, -spentSteel); + this._recordBudgetCategoryResource(civ, category, "steel", spentSteel, source); + } + if (spentTech > 0) { + this._recordBudgetCash(civ, category, -spentTech); + this._recordBudgetCategoryResource(civ, category, "hightech", spentTech, source); + } + if (spentSteel + spentTech > 0) { + this._recordResourceSpend(civ, "construction", spentSteel + spentTech, { + steel: spentSteel, + hightech: spentTech, }); } - const steelMarket = this.resourceMarketStats.get("steel"); - if (steelMarket) steelMarket.demand += shortfall.steel; - const techMarket = this.resourceMarketStats.get("hightech"); - if (techMarket) techMarket.demand += shortfall.hightech; - if (buyEnergy > 0) { - const energyMarket = this.resourceMarketStats.get("energy"); - if (energyMarket) energyMarket.demand += buyEnergy; - } - return true; - }, - - _marketCost(amount, id) { - if (amount <= 0) return 0; - return amount * this.getResourcePrice(id); + return covered; }, // Pulls each material from the nearest of the nation's cities to `coords`, - // capped by what that city holds. Returns how much of each was taken. - _drawFromNearest(civ, coords, wanted) { + // capped by what that city holds, and buys it: with a `payer` the state pays + // the owning region the material's value into its private cash, so the store + // is never taken for free. Returns how much of each was taken. + _drawFromNearest(civ, coords, wanted, payer = null) { const taken = { steel: 0, hightech: 0 }; const remaining = { steel: wanted.steel || 0, hightech: wanted.hightech || 0 }; const cities = this.cities @@ -623,23 +736,21 @@ export const resourceMethods = { store[id] = available - take; remaining[id] -= take; taken[id] += take; + this._payRegionForMaterial(city, id, take, payer); } if (remaining.steel <= 0 && remaining.hightech <= 0) break; } return taken; }, - _refundToNearest(civ, coords, resources) { - let city = null; - for (const other of this.cities) { - if (other.civ !== civ) continue; - if (!city || this.topology.tileDistance(other.coords, coords) < - this.topology.tileDistance(city.coords, coords)) city = other; - } - if (!city) return; - const store = this.getCityResourceStock(city); - store.steel += resources.steel || 0; - store.hightech += resources.hightech || 0; + // The state buys a material out of a region's store: the region is paid the + // material's value into its private cash and the payer (the treasury) carries + // the bill. Without a payer there is nothing to settle. + _payRegionForMaterial(city, id, amount, payer) { + if (!(amount > 0) || !payer) return; + const cost = amount * this.getResourcePrice(id) / this.currencyValue(city.civ); + payer.charge(cost); + this._creditRegionCash(city, this.currencyOf(city.civ).code, cost); }, // ---------------------------------------------------------- upkeep -- @@ -661,22 +772,28 @@ export const resourceMethods = { // stands in, so it is settled by `_buildEnergyLedger` instead. getCivUpkeepBreakdown(civ) { const categories = { - buildings: { steel: 0, energy: 0, hightech: 0 }, - units: { steel: 0, energy: 0, hightech: 0 }, - works: { steel: 0, energy: 0, hightech: 0 }, - transport: { steel: 0, energy: 0, hightech: 0 }, + buildings: { steel: 0, energy: 0, hightech: 0, parts: [] }, + units: { steel: 0, energy: 0, hightech: 0, parts: [] }, + works: { steel: 0, energy: 0, hightech: 0, parts: [] }, + transport: { steel: 0, energy: 0, hightech: 0, parts: [] }, }; - const add = (category, costs) => { - category.steel += costs.steel || 0; - category.energy += costs.energy || 0; - category.hightech += costs.hightech || 0; + // `label` names the thing that wore out -- a building, unit, work or + // transport type -- so the budget row can show what consumed each resource. + const add = (category, costs, label = null) => { + const steel = costs.steel || 0; + const energy = costs.energy || 0; + const hightech = costs.hightech || 0; + category.steel += steel; + category.energy += energy; + category.hightech += hightech; + if (label) category.parts.push({ label, steel, energy, hightech }); }; for (const city of this.cities) { if (city.civ !== civ) continue; for (const [index, level] of Object.entries(city.buildings || {})) { if (level <= 0) continue; const proto = this.protoBuildings[Number(index)]; - if (proto) add(categories.buildings, this.buildingUpkeepResources(proto, level)); + if (proto) add(categories.buildings, this.buildingUpkeepResources(proto, level), proto.name); } } const battle = this._battleUnitIds(); @@ -691,20 +808,42 @@ export const resourceMethods = { add(categories.units, { steel: costs.steel * multiplier, hightech: costs.hightech * multiplier, - }); + }, proto.name); } for (const [k, id] of this.tileImprovements) { if (this.tileImprovementOwner.get(k) !== civ) continue; const proto = tileImprovementById(id); if (!proto) continue; - add(categories.works, protoUpkeep(proto, proto.buildCost || 0)); - if (proto.fuelEnergyPerDay) categories.works.energy += proto.fuelEnergyPerDay; + const costs = protoUpkeep(proto, proto.buildCost || 0); + if (proto.fuelEnergyPerDay) costs.energy = (costs.energy || 0) + proto.fuelEnergyPerDay; + add(categories.works, costs, proto.name); } const counts = this._transportTiles(civ); - add(categories.transport, { - steel: counts.road * RESOURCE_RULES.transportUpkeepSteel.road + - counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway, - }); + if (counts.road > 0) { + add(categories.transport, { + steel: counts.road * RESOURCE_RULES.transportUpkeepSteel.road, + }, "Roads"); + } + if (counts.railway > 0) { + add(categories.transport, { + steel: counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway, + }, "Railways"); + } + // Merge the per-thing parts into one entry per label, so a building type + // spread over several cities is a single line in the budget. + for (const category of Object.values(categories)) { + const byLabel = new Map(); + for (const part of category.parts) { + const merged = byLabel.get(part.label) + || { label: part.label, steel: 0, energy: 0, hightech: 0 }; + merged.steel += part.steel; + merged.energy += part.energy; + merged.hightech += part.hightech; + byLabel.set(part.label, merged); + } + category.sources = Array.from(byLabel.values()); + delete category.parts; + } return categories; }, @@ -737,9 +876,9 @@ export const resourceMethods = { return foodNeedPerDay(population) * multiplier; }, - // Pulls a material from the nation's cities, nearest first not required, and - // returns what is still missing. - _drawFromCities(civ, id, amount) { + // Pulls a material from the nation's cities and, with a `payer`, buys it from + // each owning region into its private cash. Returns what is still missing. + _drawFromCities(civ, id, amount, payer = null) { let remaining = Math.max(0, amount); for (const city of this.cities) { if (remaining <= 0) break; @@ -749,30 +888,40 @@ export const resourceMethods = { if (take <= 0) continue; store[id] = (store[id] || 0) - take; remaining -= take; + this._payRegionForMaterial(city, id, take, payer); } return remaining; }, - // Settles one nation's daily upkeep materials: draw the stores, buy the rest - // from the market with the treasury, and return the fraction of the bill the - // nation could actually pay. The unpaid fraction becomes unrest. + // Settles one nation's daily upkeep materials: buy the stores from the regions + // that hold them, buy the rest from reachable regions, and return the fraction + // of the bill the nation could actually settle. With no global market, a bill + // the reachable stores cannot cover is left unmet, and the rest is unrest. _settleUpkeep(civ, categories) { const ids = ["buildings", "units", "works", "transport"]; const steelUnit = this._marketUnitCost("steel"); const techUnit = this._marketUnitCost("hightech"); + const payer = this._treasuryPayer(civ); let totalSteel = 0; let totalTech = 0; let drawnSteel = 0; let drawnTech = 0; + let drawnSteelCost = 0; + let drawnTechCost = 0; const shortfalls = {}; - // Draw each category's demand from the shared city stores. The totals do - // not depend on the order, only which category gets the stock first does. + // Draw each category's demand from the shared city stores, paying the owning + // region. The totals do not depend on the order, only which category gets + // the stock first does. for (const id of ids) { const demand = categories[id] || {}; const steel = demand.steel || 0; const hightech = demand.hightech || 0; - const steelShort = steel > 0 ? this._drawFromCities(civ, "steel", steel) : 0; - const techShort = hightech > 0 ? this._drawFromCities(civ, "hightech", hightech) : 0; + const beforeSteel = payer.total; + const steelShort = steel > 0 ? this._drawFromCities(civ, "steel", steel, payer) : 0; + drawnSteelCost += payer.total - beforeSteel; + const beforeTech = payer.total; + const techShort = hightech > 0 ? this._drawFromCities(civ, "hightech", hightech, payer) : 0; + drawnTechCost += payer.total - beforeTech; shortfalls[id] = { steel: steelShort, hightech: techShort }; totalSteel += steel; totalTech += hightech; @@ -781,10 +930,6 @@ export const resourceMethods = { } const steelShortTotal = totalSteel - drawnSteel; const techShortTotal = totalTech - drawnTech; - // What the stores covered, against what still has to be bought. - const storeCost = drawnSteel * steelUnit + drawnTech * techUnit; - const shortfall = steelShortTotal * steelUnit + techShortTotal * techUnit; - const full = storeCost + shortfall; // The market value of a category's wear is an economic cost whether the // stores covered it or the treasury had to buy it. for (const id of ids) { @@ -795,42 +940,95 @@ export const resourceMethods = { (demand.steel || 0) * steelUnit + (demand.hightech || 0) * techUnit ); } - if (storeCost > 0 && shortfall <= 0) return 1; - if (full <= 0) return 1; - // No port, no foreign market: only the stores count, and the rest is unmet. - if (!this._civMarketAccess(civ)) return storeCost / full; - // The government can always pay, borrowing the rest from its central bank, - // so the whole bill is settled and the fraction is one. - const fraction = 1; - if (shortfall > 0) { - this._spendBudget(civ, shortfall * fraction); - this._recordResourceSpend(civ, "upkeep", shortfall * fraction, { - steel: steelShortTotal * steelUnit * fraction, - hightech: techShortTotal * techUnit * fraction, + if (totalSteel <= 0 && totalTech <= 0) return 1; + // The shortfall is bought region by region, each city taking its share, from + // the nearest reachable stores -- home regions first, then foreign ones. + const cities = this.cities.filter((city) => city.civ === civ); + const shares = this._populationShares(cities); + const nodes = this._resourceNodes(); + let gotSteel = 0; + let gotTech = 0; + let spentSteel = drawnSteelCost; + let spentTech = drawnTechCost; + for (let i = 0; i < cities.length; i++) { + const city = cities[i]; + const allowSea = this._cityMarketAccess(city); + const haul = { steel: 0, hightech: 0 }; + if (steelShortTotal > 0) { + const before = payer.total; + gotSteel += this._procureFromNeighbours( + city, "steel", steelShortTotal * shares[i], haul, nodes, { payer, allowSea } + ); + spentSteel += payer.total - before; + } + if (techShortTotal > 0) { + const before = payer.total; + gotTech += this._procureFromNeighbours( + city, "hightech", techShortTotal * shares[i], haul, nodes, { payer, allowSea } + ); + spentTech += payer.total - before; + } + } + if (spentSteel + spentTech > 0) { + this._recordResourceSpend(civ, "upkeep", spentSteel + spentTech, { + steel: spentSteel, + hightech: spentTech, }); + // Split the cash across the categories that wore the material, then across + // the buildings, units, works and transport that consumed it. for (const id of ids) { - const short = shortfalls[id]; - const steelCash = short.steel * steelUnit * fraction; - const techCash = short.hightech * techUnit * fraction; + const demand = categories[id] || {}; + const steelCash = totalSteel > 0 ? spentSteel * ((demand.steel || 0) / totalSteel) : 0; + const techCash = totalTech > 0 ? spentTech * ((demand.hightech || 0) / totalTech) : 0; const cash = steelCash + techCash; if (cash > 0) this._recordBudgetCash(civ, id, -cash); - if (steelCash > 0) this._recordBudgetCategoryResource(civ, id, "steel", steelCash); - if (techCash > 0) this._recordBudgetCategoryResource(civ, id, "hightech", techCash); + const sources = (categories[id] && categories[id].sources) || []; + if (steelCash > 0) { + this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel"); + } + if (techCash > 0) { + this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech"); + } + } + } + const Psteel = this.getResourcePrice("steel"); + const Ptech = this.getResourcePrice("hightech"); + const fullValue = totalSteel * Psteel + totalTech * Ptech; + if (!(fullValue > 0)) return 1; + const settled = (drawnSteel + gotSteel) * Psteel + (drawnTech + gotTech) * Ptech; + return Math.min(1, settled / fullValue); + }, + + // Files a category's market purchase of one resource under the sources that + // consumed it -- the building, unit, work or transport type -- split in + // proportion to how much of that resource each source wore out. + _recordResourceBySource(civ, category, resource, cash, sources, field) { + let total = 0; + for (const source of sources) total += source[field] || 0; + if (!(total > 0)) { + this._recordBudgetCategoryResource(civ, category, resource, cash); + return; + } + let remaining = cash; + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const amount = i === sources.length - 1 + ? remaining + : cash * ((source[field] || 0) / total); + remaining -= amount; + if (amount > 0) { + this._recordBudgetCategoryResource(civ, category, resource, amount, source.label); } - const steelMarket = this.resourceMarketStats.get("steel"); - if (steelMarket) steelMarket.demand += steelShortTotal * fraction; - const techMarket = this.resourceMarketStats.get("hightech"); - if (techMarket) techMarket.demand += techShortTotal * fraction; } - return (storeCost + shortfall * fraction) / full; }, // ------------------------------------------------------ combat use -- // What one use of a weapon spends. Guns and aircraft are cheap parked and - // costly to fire: the state draws its stores and buys the rest. The payment - // is atomic -- if the treasury cannot cover the market part, the stores are - // put back and nothing is spent. Returns whether the shot could be paid for. + // costly to fire: the state draws its stores and buys the rest from reachable + // regions. The payment is atomic -- if the reachable stores cannot cover it, + // the drawn stock is put back and nothing is spent. Returns whether the shot + // could be paid for. payCombatResources(civ, kind, count = 1) { const consumption = RESOURCE_RULES.combatConsumption[kind]; if (!consumption) return true; @@ -839,37 +1037,52 @@ export const resourceMethods = { hightech: (consumption.hightech || 0) * count, }; if (wanted.steel <= 0 && wanted.hightech <= 0) return true; - const drawn = this._drawFromNearest(civ, this._anyCityCoords(civ), wanted); + const coords = this._anyCityCoords(civ); + const payer = this._treasuryPayer(civ); + const drawn = this._drawFromNearest(civ, coords, wanted, payer); const missing = { steel: wanted.steel - drawn.steel, hightech: wanted.hightech - drawn.hightech, }; - // No port, no foreign market: the stores must cover the whole shot. - if ((missing.steel > 0 || missing.hightech > 0) && !this._civMarketAccess(civ)) { - this._refundToNearest(civ, this._anyCityCoords(civ), drawn); - return false; + const v = this.currencyValue(civ); + let gotSteel = 0; + let gotTech = 0; + let spentSteel = drawn.steel * this.getResourcePrice("steel") / v; + let spentTech = drawn.hightech * this.getResourcePrice("hightech") / v; + let covered = missing.steel <= 0 && missing.hightech <= 0; + if (!covered) { + const city = this._nearestCity(civ, coords); + if (city) { + const nodes = this._resourceNodes(); + const haul = { steel: 0, hightech: 0 }; + if (missing.steel > 0) { + const before = payer.total; + gotSteel = this._procureFromNeighbours(city, "steel", missing.steel, haul, nodes, { payer }); + spentSteel += payer.total - before; + } + if (missing.hightech > 0) { + const before = payer.total; + gotTech = this._procureFromNeighbours(city, "hightech", missing.hightech, haul, nodes, { payer }); + spentTech += payer.total - before; + } + covered = gotSteel + 1e-6 >= missing.steel && gotTech + 1e-6 >= missing.hightech; + } } - const cost = missing.steel * this._marketUnitCost("steel") + - missing.hightech * this._marketUnitCost("hightech"); - // The government can always pay; an overdraft is a central-bank debt. - this._spendBudget(civ, cost, "military"); - if (cost > 0) { - this._recordBudgetCategoryResource( - civ, "military", "steel", missing.steel * this._marketUnitCost("steel") - ); - this._recordBudgetCategoryResource( - civ, "military", "hightech", missing.hightech * this._marketUnitCost("hightech") - ); - this._recordResourceSpend(civ, "combat", cost, { - steel: missing.steel * this._marketUnitCost("steel"), - hightech: missing.hightech * this._marketUnitCost("hightech"), + if (spentSteel > 0) { + this._recordBudgetCash(civ, "military", -spentSteel); + this._recordBudgetCategoryResource(civ, "military", "steel", spentSteel); + } + if (spentTech > 0) { + this._recordBudgetCash(civ, "military", -spentTech); + this._recordBudgetCategoryResource(civ, "military", "hightech", spentTech); + } + if (spentSteel + spentTech > 0) { + this._recordResourceSpend(civ, "combat", spentSteel + spentTech, { + steel: spentSteel, + hightech: spentTech, }); - const steelMarket = this.resourceMarketStats.get("steel"); - if (steelMarket) steelMarket.demand += missing.steel; - const techMarket = this.resourceMarketStats.get("hightech"); - if (techMarket) techMarket.demand += missing.hightech; } - return true; + return covered; }, _anyCityCoords(civ) { @@ -937,6 +1150,7 @@ export const resourceMethods = { this.resourceLinks = []; this.resourceSpend = new Map(); this.resourceTrade = new Map(); + this.resourceExternal = new Map(); this._resourceShortages = new Map(); const market = new Map(); for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) }); @@ -977,14 +1191,17 @@ export const resourceMethods = { } // Settle every connected grid: what the plants make against what the cities - // and industry draw. The shortfall is bought from the market and capped by - // what the nation can pay. + // and industry draw. A grid that runs short imports power from the reachable + // grids that have a surplus -- there is no global market to fall back on. for (const component of ledger.values()) { if (component.civ < 0) continue; - const demand = component.cityEnergy + component.converterEnergy + component.foodEnergy; - const deficit = Math.max(0, demand - component.supply); - component.demand = demand; - component.available = component.supply + this._buyGridEnergy(component.civ, deficit); + component.demand = component.cityEnergy + component.converterEnergy + component.foodEnergy; + component.imports = 0; + } + this._tradeGridPower(ledger, components); + for (const component of ledger.values()) { + if (component.civ < 0) continue; + component.available = component.supply + component.imports; const industrial = component.converterEnergy + component.foodEnergy; component.industrialScale = industrial > 0 ? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial)) @@ -997,8 +1214,10 @@ export const resourceMethods = { ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy)) : 0; market.get("energy").supply += component.supply; - market.get("energy").demand += demand; + market.get("energy").demand += component.demand; } + // A grid that cannot power its industry names the works left idle. + this._noteGridPowerShortages(ledger, components); // Food lands in the region's store, scaled by the power the grid could give. for (const entry of pendingFood) { @@ -1023,8 +1242,8 @@ export const resourceMethods = { market.get(proto.resource).supply += produced; } - // The people eat, wear and spend. Shortfalls are matched against nearby - // stores, then the market. + // The people eat, wear and spend. Shortfalls are matched against reachable + // stores -- home regions first, then foreign ones. this._consumeCityResources(components, market); // Every nation's buildings, units and works then wear out: the state draws @@ -1123,17 +1342,107 @@ export const resourceMethods = { return ledger; }, - // Buys the grid's electric shortfall on the market, capped by what the nation - // can pay. Returns the kWh actually bought. - _buyGridEnergy(civ, deficit) { - if (deficit <= 0) return 0; - // Without a working port there is no foreign market to buy power from. - if (!this._civMarketAccess(civ)) return 0; + // Moves power between grids. A grid in deficit buys from the reachable grids + // that hold a surplus, cheapest route first; power is a flow with no store, so + // it flows the same day. Each importer's treasury pays the exporter, whose + // regions receive the money. + _tradeGridPower(ledger, components) { const price = this.getResourcePrice("energy"); - this._spendBudget(civ, deficit * price, "energy"); - this._recordBudgetCategoryResource(civ, "energy", "energy", deficit * price); - this._recordResourceSpend(civ, "energy", deficit * price, "energy"); - return deficit; + const surplus = new Map(); + for (const [id, component] of ledger) { + surplus.set(id, Math.max(0, component.supply - component.demand)); + } + for (const [id, component] of ledger) { + if (component.civ < 0) continue; + const deficit = Math.max(0, component.demand - component.supply); + if (deficit <= 0) continue; + const origin = this._componentCity(id, components); + if (!origin) continue; + const reach = this._reachableComponents(origin, components, this._cityMarketAccess(origin)); + const suppliers = Array.from(reach.entries()) + .filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0) + .sort((a, b) => a[1] - b[1]); + let needed = deficit; + let spent = 0; + for (const [sid, routeEnergy] of suppliers) { + if (needed <= 0) break; + const available = surplus.get(sid) || 0; + const take = Math.min(needed, available); + if (!(take > 0)) continue; + // The power costs its price plus the energy burned carrying it. + const cost = take * (price + routeEnergy * price); + const seller = ledger.get(sid); + this.budgets.set(component.civ, this.getBudget(component.civ) - cost); + this._recordBudgetCash(component.civ, "energy", -cost); + this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost); + if (seller.civ !== component.civ) { + this._creditRegions(seller.civ, cost); + this._recordExternalBuy(component.civ, "energy", take, cost); + this._recordExternalSell(seller.civ, "energy", take, cost); + } else { + this._creditRegions(seller.civ, cost); + } + surplus.set(sid, available - take); + component.imports += take; + needed -= take; + spent += cost; + } + if (spent > 0) this._recordResourceSpend(component.civ, "energy", spent, "energy"); + } + }, + + // One city that sits in grid component `id`, the origin for a power trade. + _componentCity(id, components) { + for (const city of this.cities) { + if (components.get(key(city.coords.x, city.coords.y)) === id) return city; + } + return null; + }, + + // The grid components reachable from a city over the transport network, mapped + // to the least energy it takes to carry a unit there. A sea leg needs a + // working port; a land leg needs road or railway; goods never cross the land + // of a nation you are at war with. + _reachableComponents(city, components, allowSea = true) { + const reach = new Map(); + const origin = key(city.coords.x, city.coords.y); + const originComponent = components.get(origin); + const visited = new Set([origin]); + const queue = [{ coords: city.coords, energy: 0 }]; + while (queue.length > 0) { + const current = queue.shift(); + for (const neighbour of this._neighbours(current.coords)) { + const nk = key(neighbour.x, neighbour.y); + if (visited.has(nk)) continue; + const tile = this.tiles[nk]; + if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" || + tile.terrainClass === "Ice")) continue; + const sea = tile.terrainClass === "Sea"; + let mode; + if (sea) { + if (!allowSea) continue; + mode = "ship"; + } else { + if (this.railways.has(nk)) mode = "train"; + else if (this.roads.has(nk)) mode = "road"; + else continue; + const owner = this.civAt(neighbour); + if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue; + } + const stepEnergy = deliveryEnergyForResource( + "food", 1, 1, mode, tile.movementCostMultiplier || 1 + ); + visited.add(nk); + const energy = current.energy + stepEnergy; + const component = components.get(nk); + if (component !== undefined && component !== originComponent) { + const best = reach.get(component); + if (best === undefined || energy < best) reach.set(component, energy); + } + queue.push({ coords: neighbour, energy }); + } + } + return reach; }, _consumeCityResources(components, market) { @@ -1155,7 +1464,9 @@ export const resourceMethods = { // it lives off its own reserves until the ring is broken. A portless city // can still trade over land on its own continent, but not across water. const cutOff = this._isEncircled(city.coords, city.civ); - const marketAccess = !cutOff && this._cityMarketAccess(city); + // A shipping lane needs a working port; an encircled city is cut off from + // every neighbour, home or foreign. + const allowSea = !cutOff && this._cityMarketAccess(city); for (const id of ["food", "steel", "luxury"]) { const need = needs[id]; market.get(id).demand += need; @@ -1170,12 +1481,7 @@ export const resourceMethods = { : dailyShortfall; let bought = 0; if (wanted > 0 && !cutOff) { - bought = this._procureFromNeighbours( - city, id, wanted, store, nodes, this._marketUnitCost(id), marketAccess - ); - if (bought < wanted && marketAccess) { - bought += this._buyMarketMaterial(city, id, wanted - bought, store); - } + bought = this._procureFromNeighbours(city, id, wanted, store, nodes, { allowSea }); } // What was bought against today's need is eaten today; only the food // buffer left over stays in the store. @@ -1189,16 +1495,19 @@ export const resourceMethods = { } }, - // A city buys a material from the cheapest source it can reach: a nearby - // store whose delivered price beats the market, or the market itself. The - // city pays from its own private cash, and only the region that owns the - // surplus is paid; a foreign seller insists on its national currency, which - // the buyer acquires from that nation's central bank. Returns the amount - // bought, and records each successful leg on the delivery graph. `marketUnit` - // is the market's delivered price per unit; suppliers at or above it are - // ignored. A surplus several buyers chase is bid up: each earlier claim raises - // the price the next buyer pays. - _procureFromNeighbours(city, id, amount, cityStore, nodes, marketUnit = Infinity, allowSea = true) { + // A city buys a material from the stores it can reach over the transport + // network. There is no global market: if no reachable store holds enough, the + // rest is simply not bought. Suppliers in the buyer's own nation come first; + // foreign regions are considered only to cover what home cannot. Within a + // tier the cheapest delivered price wins, and a surplus several buyers chase + // is bid up. + // + // The region pays from its own private cash by default; when `payer` is given + // (a government buy) the treasury pays instead. A foreign seller is paid in + // its national currency, acquired from that nation's central bank. Returns the + // amount bought and records each leg on the delivery graph. + _procureFromNeighbours(city, id, amount, cityStore, nodes, options = {}) { + const { allowSea = true, payer = null, foreignOnly = false } = options; let remaining = amount; const price = this.getResourcePrice(id); const energyPrice = this.getResourcePrice("energy"); @@ -1208,48 +1517,46 @@ export const resourceMethods = { for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) { const surplus = this._nodeSurplus(supplier, id); if (surplus <= 0) continue; + const foreign = supplier.civ >= 0 && supplier.civ !== city.civ; + if (foreignOnly && !foreign) continue; const claimed = this._nodeClaims.get(key(supplier.coords.x, supplier.coords.y)) || 0; const available = Math.max(0, surplus - claimed); if (available <= 0) continue; - const foreign = supplier.civ >= 0 && supplier.civ !== city.civ; - // A contested surplus is bid up, but never past the cap. const premium = Math.min( MONEY.maxBidMultiplier, 1 + MONEY.bidPremium * (claimed / Math.max(1e-9, surplus)) ); - // Every seller is paid for the goods in its own currency -- a neighbour - // in the shared national currency, a foreigner after a trip to its central - // bank. A contested surplus carries the bid premium. const goods = price * premium; const freight = (supplier.routeEnergyPerTonne || 0) * energyPrice; - const unitCost = goods + freight; - if (unitCost >= marketUnit) continue; - quotes.push({ supplier, available, foreign, unitCost, goods, freight }); + quotes.push({ supplier, available, foreign, goods, freight, unitCost: goods + freight }); } - quotes.sort((a, b) => a.unitCost - b.unitCost); + // Home regions before foreign ones; then by delivered price. + quotes.sort((a, b) => (a.foreign - b.foreign) || (a.unitCost - b.unitCost)); for (const quote of quotes) { if (remaining <= 0) break; const { supplier, foreign, goods, freight } = quote; const bought = Math.min(quote.available, remaining); if (!(bought > 0)) continue; - const goodsValue = goods * bought; - const freightValue = freight * bought; + const value = (goods + freight) * bought; const vBuyer = this.currencyValue(city.civ); - // The region pays from its cash and borrows the rest from its central - // bank, so a purchase is never refused for want of money. + const charge = payer + ? (cost) => payer.charge(cost) + : (cost) => this._chargeRegionCash(city, buyerCode, cost); + // The seller is paid the goods' value in its own currency -- a home + // neighbour in the shared national currency, a foreigner after a trip to + // its central bank. Freight rides with the goods. if (foreign) { - this._chargeRegionCash(city, buyerCode, freightValue / vBuyer); - this._payWorld(city.civ, freightValue / vBuyer, buyerCode); - const acquired = this._regionAcquireCurrency(city, supplier.civ, goodsValue); + const acquired = this._regionAcquireCurrency(city, supplier.civ, value, charge, !payer); if (!acquired) continue; this._paySupplier(supplier, acquired.code, acquired.amount); this._recordResourceTrade(city.civ, supplier.civ, id, bought); - this._recordCurrencyDemand(supplier.civ, goodsValue); - this._recordCurrencyDemand(city.civ, -goodsValue); + this._recordExternalBuy(city.civ, id, bought, value / vBuyer); + this._recordExternalSell(supplier.civ, id, bought, value / this.currencyValue(supplier.civ)); + this._recordCurrencyDemand(supplier.civ, value); + this._recordCurrencyDemand(city.civ, -value); } else { - this._chargeRegionCash(city, buyerCode, (goodsValue + freightValue) / vBuyer); - this._paySupplier(supplier, buyerCode, goodsValue / vBuyer); - this._payWorld(city.civ, freightValue / vBuyer, buyerCode); + charge(value / vBuyer); + this._paySupplier(supplier, buyerCode, value / vBuyer); } this._nodeClaims.set( key(supplier.coords.x, supplier.coords.y), @@ -1275,35 +1582,53 @@ export const resourceMethods = { if (city) this._creditRegionCash(city, code, amount); }, + // A payer that draws on a nation's treasury, so a government purchase settles + // with the supplier while the treasury carries the bill (and may go into + // overdraft). `total` accumulates what was spent. + _treasuryPayer(civ) { + const payer = { total: 0 }; + payer.charge = (cost) => { + if (!(cost > 0)) return; + this.budgets.set(civ, this.getBudget(civ) - cost); + payer.total += cost; + }; + return payer; + }, + + // The nation's city nearest `coords`, the region that buys on its behalf when + // the government procures away from a specific city. + _nearestCity(civ, coords) { + let best = null; + let bestDistance = Infinity; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const distance = this.topology.tileDistance(city.coords, coords); + if (distance < bestDistance) { + bestDistance = distance; + best = city; + } + } + return best; + }, + + // The capital, or the nation's first city when it has no capital. + _capitalCity(civ) { + let first = null; + for (const city of this.cities) { + if (city.civ !== civ) continue; + if (city.isCapital) return city; + if (!first) first = city; + } + return first; + }, + // The market's delivered price per unit: the good plus the energy a nominal - // two-tile road delivery burns. + // two-tile road delivery burns. Used to value materials, not to source them. _marketUnitCost(id) { const energyPerUnit = deliveryEnergyForResource(id, 1, 2, "road", 1); return this.getResourcePrice(id) + energyPerUnit * this.getResourcePrice("energy"); }, - // The market always has the goods; a city pays the price and a nominal - // two-tile delivery from its own private cash, in its national currency. - // Returns how much it could afford. - _buyMarketMaterial(city, id, amount, cityStore) { - if (amount <= 0) return 0; - const civ = city.civ; - // The foreign market is only reachable through a working port. - if (!this._civMarketAccess(civ)) return 0; - const code = this.currencyOf(civ).code; - const perUnit = this._marketUnitCost(id) / this.currencyValue(civ); - if (perUnit <= 0) return 0; - if (!this.regionCash.get(city.id)) return 0; - // The region pays from its cash and borrows the rest from its central bank, - // so the whole order goes through. The money is paid to the world market's - // producers, so it does not drain out of the world. - const payment = amount * perUnit; - this._chargeRegionCash(city, code, payment); - this._payWorld(civ, payment, code); - cityStore[id] += amount; - return amount; - }, - _resourceNodes() { const nodes = []; for (const [k, stock] of this.resourceStock) { @@ -1497,22 +1822,29 @@ export const resourceMethods = { } }, - // Spends what steel it can on a tile's repair: the owner's nearest city - // first, then the market. Returns the amount actually paid. + // Spends what steel it can on a tile's repair: the owner's stores first, then + // reachable regions. Returns the amount actually paid. _payTileDebt(civ, coords, amount) { if (!(amount > 0)) return 0; - const fromStock = this._drawFromNearest(civ, coords, { steel: amount, hightech: 0 }); + const payer = this._treasuryPayer(civ); + const fromStock = this._drawFromNearest(civ, coords, { steel: amount, hightech: 0 }, payer); const shortfall = amount - fromStock.steel; let paid = fromStock.steel; - if (shortfall > 0 && !this._civMarketAccess(civ)) return paid; - const cost = shortfall * this._marketUnitCost("steel"); - if (cost > 0) { - // The government can always pay, borrowing if the treasury is dry. - const fraction = 1; - this._spendBudget(civ, cost * fraction, "works"); - paid += shortfall * fraction; - this._recordBudgetCategoryResource(civ, "works", "steel", cost * fraction); - this._recordResourceSpend(civ, "upkeep", cost * fraction, "steel"); + let spent = fromStock.steel * this.getResourcePrice("steel") / this.currencyValue(civ); + if (shortfall > 0) { + const city = this._nearestCity(civ, coords); + if (city) { + const nodes = this._resourceNodes(); + const haul = { steel: 0, hightech: 0 }; + const before = payer.total; + paid += this._procureFromNeighbours(city, "steel", shortfall, haul, nodes, { payer }); + spent += payer.total - before; + } + } + if (spent > 0) { + this._recordBudgetCash(civ, "works", -spent); + this._recordBudgetCategoryResource(civ, "works", "steel", spent); + this._recordResourceSpend(civ, "upkeep", spent, "steel"); } return paid; }, @@ -1599,22 +1931,41 @@ export const resourceMethods = { const store = this.getCityResourceStock(city); const take = Math.min(store.food || 0, tonnes); const remaining = tonnes - take; + const payer = this._treasuryPayer(unit.civ); + let procuredSpent = 0; if (remaining > 0.0001) { - // Feeding the unit from off-continent needs a working port. - if (!this._civMarketAccess(unit.civ)) return false; - const unitCost = this._marketUnitCost("food"); - const cost = remaining * unitCost; - // The government can always pay, borrowing if the treasury is dry. - this._spendBudget(unit.civ, cost, "units"); - this._recordBudgetCategoryResource(unit.civ, "units", "food", cost); - this._recordResourceSpend(unit.civ, "upkeep", cost, "food"); - const market = this.resourceMarketStats.get("food"); - if (market) market.demand += remaining; + // The rest comes from reachable regions, bought by the treasury. + const nodes = this._resourceNodes(); + const haul = { food: 0 }; + const before = payer.total; + const got = this._procureFromNeighbours(city, "food", remaining, haul, nodes, { payer }); + procuredSpent = payer.total - before; + if (got + 1e-6 < remaining) { + // Not enough food reachable: the unit goes without, and what was bought + // stays in the city's store. + store.food = (store.food || 0) + got; + this._recordProvisionSpend(unit.civ, 0, procuredSpent); + return false; + } } store.food = (store.food || 0) - take; + // The city's own food is bought from the region that stored it. + const takeValue = take * this.getResourcePrice("food") / this.currencyValue(unit.civ); + this._payRegionForMaterial(city, "food", take, payer); + this._recordProvisionSpend(unit.civ, takeValue, procuredSpent); return true; }, + // Books the food a unit ate, whether it came from a region's store or from a + // foreign one. + _recordProvisionSpend(civ, takeValue, procuredSpent) { + const spent = takeValue + procuredSpent; + if (!(spent > 0)) return; + this._recordBudgetCash(civ, "units", -spent); + this._recordBudgetCategoryResource(civ, "units", "food", spent); + this._recordResourceSpend(civ, "upkeep", spent, "food"); + }, + // Every land unit eats a day from its carried stores. At home, or in foreign // territory with a clear route to an allied city, it is fed and topped back // up to a full pack; cut off, the days run down and then the soldiers start @@ -1667,26 +2018,77 @@ export const resourceMethods = { // ------------------------------------------------------- research link -- - // Science buildings spend a unit of high-tech for every research point. The - // nation's cities cover the bill up to what they hold; the rest of the - // points are simply not produced. Returns the effective points. - _spendHighTechForResearch(civ, points) { + // One city's science buildings spend a unit of high-tech for every research + // point they make, up to what the city holds; the rest of the points are not + // produced. A city that cannot cover its buildings' need raises a shortage + // notice. Returns the effective points. + _spendCityHighTechForResearch(civ, city, points) { if (points <= 0) return 0; - let needed = points * RESOURCE_RULES.highTechPerResearchPoint; + const needed = points * RESOURCE_RULES.highTechPerResearchPoint; + const store = this.getCityResourceStock(city); + const have = store.hightech || 0; if (needed <= 0) return points; - let available = 0; - const cities = this.cities.filter((city) => city.civ === civ); - for (const city of cities) available += this.getCityResourceStock(city).hightech || 0; - if (available <= 0) return 0; - const ratio = Math.min(1, available / needed); - let toSpend = needed * ratio; - for (const city of cities) { - if (toSpend <= 0) break; - const store = this.getCityResourceStock(city); - const take = Math.min(store.hightech || 0, toSpend); - store.hightech = (store.hightech || 0) - take; - toSpend -= take; + if (have + 1e-9 >= needed) { + store.hightech = have - needed; + return points; } + this._noteResourceShortage(civ, this._cityResearchBuilding(city), city.name, "hightech"); + if (have <= 0) return 0; + const ratio = have / needed; + store.hightech = 0; return points * ratio; }, + + // The science building(s) a city runs, named for a shortage notice. + _cityResearchBuilding(city) { + const names = []; + for (const [indexStr, level] of Object.entries(city.buildings || {})) { + if (level <= 0) continue; + const proto = this.protoBuildings[Number(indexStr)]; + if (!proto) continue; + if ((proto.effects || []).some((e) => e.stat === EFFECT_RESEARCH)) names.push(proto.name); + } + return names.length > 0 ? names.join(" & ") : "Science buildings"; + }, + + // ----------------------------------------------------- shortage notices -- + + // Records that a producing building in a region went without the material or + // power it needs, for the day's notice to that nation's own viewer. Notes are + // deduplicated per building, region and resource. + _noteResourceShortage(civ, building, region, resource) { + if (civ < 0 || !building || !region || !resource) return; + let set = this._resourceAlerts.get(civ); + if (!set) { + set = new Map(); + this._resourceAlerts.set(civ, set); + } + const key = `${region}|${building}|${resource}`; + if (!set.has(key)) set.set(key, { building, region, resource }); + }, + + // A grid short of power leaves its resource works idle: the owner is told + // which work in which region is missing energy. + _noteGridPowerShortages(ledger, components) { + for (const [id, component] of ledger) { + if (component.civ < 0 || component.industrialScale >= 0.999) continue; + const city = this._componentCity(id, components); + if (!city) continue; + for (const [k, imprId] of this.tileImprovements) { + if (components.get(k) !== id) continue; + const proto = tileImprovementById(imprId); + if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue; + this._noteResourceShortage(component.civ, proto.name, city.name, "energy"); + } + } + }, + + // The day's shortage notices for one nation, for its own viewer. Each carries + // the day it was raised so the viewer re-announces it once per day. + _resourceAlertsFor(civ) { + const set = this._resourceAlerts ? this._resourceAlerts.get(civ) : null; + if (!set) return []; + const day = Math.floor(this.totalHours / HOURS_PER_DAY); + return Array.from(set.values()).map((entry) => ({ ...entry, day })); + }, }; diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index f8719c5..99a0bf6 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -151,6 +151,8 @@ export const serializationMethods = { constructionSpeed: 0, railSpeed: 0, resources: null, breakdown: null, + currencyHoldings: null, + resourceAlerts: [], }; } const headline = this._civHeadline(viewerCiv); @@ -162,6 +164,9 @@ export const serializationMethods = { railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0, // The nation's stores, daily needs and production, plus today's prices. resources: this.getCivResourceSummary(viewerCiv), + // The day's notices that the viewer's own producing buildings went short + // of a material or of grid power. + resourceAlerts: this._resourceAlertsFor(viewerCiv), budget: this.getBudget(viewerCiv), // The upkeep figure the HUD nets against income: propaganda money plus // the market value of the materials buildings, units and works wear out. @@ -188,6 +193,9 @@ export const serializationMethods = { exchangeRates: this._serializeExchangeRates(viewerCiv), centralBank: this._serializeCentralBank(viewerCiv), privateCash: this.getPrivateSectorCash(viewerCiv), + // Where every unit of the viewer's currency is stored: its treasury, its + // regions, and foreign regions and central banks that hold it. + currencyHoldings: this.getCurrencyHoldings(viewerCiv), governmentDebt: this.getGovernmentDebt(viewerCiv), // How far the headline figures have grown, against January 2000 in the // first year and year-on-year afterwards. @@ -419,6 +427,12 @@ export const serializationMethods = { cash: this._serializeRegionCash(city), cashValue: this.getRegionCashValue(city), debt: this.getRegionDebt(city), + // What the region's people went short of over the last day, so the + // People tab can report it. + shortage: this.getRegionShortages(city), + // The region's reserves, their market value and the private sector's + // daily consumption, for the Resources tab. + resources: this.getRegionResourceInfo(city), // The ethnicity of the region's people, for the circle graph. ethnicMakeup: this.getCityEthnicMakeup(city), }; diff --git a/shared/game_state/warfare.js b/shared/game_state/warfare.js index 65904ea..78bbdc2 100644 --- a/shared/game_state/warfare.js +++ b/shared/game_state/warfare.js @@ -62,7 +62,7 @@ export const warfareMethods = { if (ECONOMY.productionCapacity(gdp) <= 0) return false; if (!this._spendBudget(civ, proto.buildCost, "tilebuild")) return false; const materials = tileImprovementResourceCost(proto, proto.buildCost); - if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) { + if (!this._payConstructionResources(civ, coords, materials, "tilebuild", proto.name)) { this._refundBudget(civ, proto.buildCost, "tilebuild"); return false; } diff --git a/tests/budget_test.js b/tests/budget_test.js index 66e14ce..be31007 100644 --- a/tests/budget_test.js +++ b/tests/budget_test.js @@ -111,13 +111,33 @@ export class BudgetTest extends TestCase { this.assertApprox(steel[1], 300); } + 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 stores so the day's upkeep has to be bought on the market. + // 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); - store.steel = 0; - store.hightech = 0; + 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(); diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index aa641b1..e0db3eb 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -206,11 +206,19 @@ export class GameStateBuildingsTest extends TestCase { state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding({ baseCost: 100, costAlpha: 2.0 })); const city = cityOf(state, 0); + // Stock the steel so the whole bill is drawn from the stores and the two + // orders can be compared cleanly. + for (const own of state.cities) { + if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e9; + } const before = state.getBudget(0); this.assertTrue(state.requestBuild(city.id, index)); + const first = before - state.getBudget(0); + const mid = state.getBudget(0); this.assertTrue(state.requestBuild(city.id, index)); - // 100 for the first level, 200 for the second. - this.assertApprox(before - state.getBudget(0), 300); + // The second level (money and steel) is priced off the level the first + // leaves behind, so it costs twice the first. + this.assertApprox(mid - state.getBudget(0), first * 2, 1e-6); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 2); } @@ -229,6 +237,10 @@ export class GameStateBuildingsTest extends TestCase { state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); const city = cityOf(state, 0); + // Stock the steel so the material bill is never the limit being tested. + for (const own of state.cities) { + if (own.civ === 0) state.getCityResourceStock(own).steel = 1.0e12; + } for (let i = 0; i < 20; i++) this.assertTrue(state.requestBuild(city.id, index)); this.advanceUntilIdle(state); this.assertEqual(state.getCityBuildingLevel(city, index), 20); @@ -242,7 +254,7 @@ export class GameStateBuildingsTest extends TestCase { this.assertFalse(state.requestBuild(cityOf(state, 0).id, 9999)); } - test_request_demolish_refunds_and_removes_the_building() { + test_request_demolish_removes_the_building_without_a_refund() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const index = this.addBuilding(state, gdpBuilding()); @@ -253,7 +265,7 @@ export class GameStateBuildingsTest extends TestCase { const before = state.getBudget(0); this.assertTrue(state.requestDemolish(city.id, index)); this.assertEqual(state.getCityBuildingLevel(city, index), 1); - this.assertGreater(state.getBudget(0), before, "refunded"); + this.assertApprox(state.getBudget(0), before, 1e-6, "downgrading refunds nothing"); this.assertTrue(state.requestDemolish(city.id, index)); this.assertEqual(state.getCityBuildingLevel(city, index), 0); this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish"); diff --git a/tests/game_state_test.js b/tests/game_state_test.js index 3bad688..989b307 100644 --- a/tests/game_state_test.js +++ b/tests/game_state_test.js @@ -243,10 +243,22 @@ export class GameStateTest extends TestCase { const state = smallState(); const city = state.cities[0]; grantBuilding(state, city, "barracks"); + // Stock the materials so the bill is the training cost plus the material + // value the state buys from its regions. + for (const own of state.cities) { + if (own.civ !== city.civ) continue; + const stock = state.getCityResourceStock(own); + stock.steel = 1.0e9; + stock.hightech = 1.0e9; + } const proto = state.protoUnits[0]; + const materials = state.constructionResourceCost(proto, 0, proto.cost); + const materialValue = + materials.steel * state.getResourcePrice("steel") + + materials.hightech * state.getResourcePrice("hightech"); const before = state.getBudget(city.civ); this.assertTrue(state.requestTrain(city.id, 0)); - this.assertApprox(state.getBudget(city.civ), before - proto.cost); + this.assertApprox(before - state.getBudget(city.civ), proto.cost + materialValue, 1e-3); const queue = state.getTraining(city.id); this.assertSize(queue, 1); this.assertEqual(queue[0].kind, "unit"); @@ -257,6 +269,13 @@ export class GameStateTest extends TestCase { const city = state.cities[0]; grantBuilding(state, city, "barracks"); state.budgets.set(city.civ, 1.0e15); + // Stock the materials so the queue limit, not the stores, is what caps it. + for (const own of state.cities) { + if (own.civ !== city.civ) continue; + const stock = state.getCityResourceStock(own); + stock.steel = 1.0e12; + stock.hightech = 1.0e12; + } for (let i = 0; i < TRAINING_QUEUE_LIMIT; i++) { this.assertTrue(state.requestTrain(city.id, 0), `order ${i} accepted`); } diff --git a/tests/improvements_test.js b/tests/improvements_test.js index 9c3d6f6..801dcb4 100644 --- a/tests/improvements_test.js +++ b/tests/improvements_test.js @@ -19,8 +19,9 @@ export class ImprovementsTest extends TestCase { test_building_a_road_costs_money_and_resources_and_lays_the_tile() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); - // Stock enough steel that only the electricity is bought on the market, so - // the money bill is the build cost plus the construction energy. + // Stock enough steel that the whole material bill is drawn from the stores; + // the treasury pays the build cost plus the steel it buys from the region. + // Construction electricity is a grid draw, not a purchase, so it is free. for (const city of state.cities) { if (city.civ === 0) state.getCityResourceStock(city).steel = 1e9; } @@ -32,12 +33,12 @@ export class ImprovementsTest extends TestCase { this.assertTrue(state.roads.has(k), "the road is on the tile"); this.assertFalse(state.railways.has(k), "it is not a railway"); const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road); - const energyCost = materials.energy * state.getResourcePrice("energy"); + const steelValue = materials.steel * state.getResourcePrice("steel"); this.assertApprox( before - state.getBudget(0), - TRANSPORT_BY_ID.road.buildCost + energyCost, + TRANSPORT_BY_ID.road.buildCost + steelValue, 1e-3, - "money plus the construction electricity" + "the build cost plus the steel the region supplied" ); } @@ -196,8 +197,9 @@ export class ImprovementsTest extends TestCase { test_a_tile_work_costs_steel_energy_and_high_tech() { const state = smallState(); state.budgets.set(0, 1.0e15); - // Enough stores that only the electricity is bought, so the money bill is - // the build cost plus the construction energy. + // Enough stores that the whole material bill is drawn from them; the + // treasury pays the build cost plus the steel and high-tech it buys from the + // region. Construction electricity comes off the grid, so it is not bought. for (const city of state.cities) { if (city.civ !== 0) continue; const stock = state.getCityResourceStock(city); @@ -213,16 +215,17 @@ export class ImprovementsTest extends TestCase { const radar = TILE_IMPROVEMENT_BY_ID.radar_tower; const materials = tileImprovementResourceCost(radar); this.assertGreater(materials.steel, 0, "steel is spent"); - this.assertGreater(materials.energy, 0, "energy is spent"); + this.assertGreater(materials.energy, 0, "energy is drawn"); this.assertGreater(materials.hightech, 0, "high-tech is spent"); const before = state.getBudget(0); this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower")); - const energyCost = materials.energy * state.getResourcePrice("energy"); + const steelValue = materials.steel * state.getResourcePrice("steel"); + const techValue = materials.hightech * state.getResourcePrice("hightech"); this.assertApprox( before - state.getBudget(0), - radar.buildCost + energyCost, + radar.buildCost + steelValue + techValue, 1e-3, - "money plus the construction electricity" + "the build cost plus the materials the region supplied" ); } } diff --git a/tests/modals_test.js b/tests/modals_test.js index 1d936bc..6c6515f 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -277,6 +277,64 @@ export class CityModalTest extends TestCase { teardownDom(env); } } + + async test_resources_tab_shows_reserves_value_and_consumption() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.show( + { id: 1, name: "Lyon" }, + { + budget: 1.0e9, + gdp: 1.0e9, + resources: { + stock: { food: 1000, steel: 2000, luxury: 0, hightech: 50 }, + consumption: { food: 100, steel: 200, luxury: 10, hightech: 2, energy: 2500 }, + value: 123456, + }, + } + ); + env.$("#modal-city .tab[data-tab=resources]").click(); + this.assertTrue(env.$("#city-tab-resources").hasClass("active")); + this.assertTrue( + env.$("#city-resources-sub").text().includes("123 456"), + "the reserve value shows" + ); + const text = env.$("#city-resources-list").text(); + this.assertTrue(text.includes("Food"), "food is listed"); + this.assertTrue(text.includes("Steel"), "steel is listed"); + this.assertTrue(text.includes("Energy"), "energy is listed"); + this.assertTrue(text.includes("2.5 MWh"), "the daily power draw is shown"); + } finally { + teardownDom(env); + } + } + + async test_people_tab_reports_resource_shortages() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.show( + { id: 1, name: "Lyon" }, + { + budget: 1.0e9, + gdp: 1.0e9, + ethnicMakeup: [], + approval: 0.5, + shortage: { steel: { fraction: 0.4, amount: 1200 } }, + } + ); + env.$("#modal-city .tab[data-tab=people]").click(); + this.assertTrue(env.$("#city-tab-people").hasClass("active")); + const text = env.$("#city-people-list").text(); + this.assertTrue(text.includes("Resource shortages"), "the shortage section shows"); + this.assertTrue(text.includes("Steel"), "the short resource is named"); + this.assertTrue(text.includes("1200"), "the amount it is short by is shown"); + this.assertTrue(text.includes("40%"), "the share of need is shown"); + } finally { + teardownDom(env); + } + } } export class NationModalTest extends TestCase { @@ -550,7 +608,10 @@ export class NationModalTest extends TestCase { totalEconomic: 900, net: 545, expenses: [ - ["buildings", 400, 700, [["steel", 250], ["hightech", 150]]], + ["buildings", 400, 700, [ + ["steel", 250, [["Barracks", 250]]], + ["hightech", 150, [["Laboratory", 150]]], + ]], ["units", 150, 200, []], ], resourceTrade: [["steel", 220, 0], ["food", 0, 90]], @@ -567,8 +628,8 @@ export class NationModalTest extends TestCase { this.assertTrue(text.includes("+¤1 035"), "the month's tax income is shown"); this.assertTrue(text.includes("Resource sales"), "resource sales are shown"); this.assertTrue(text.includes("Building upkeep"), "a building category is listed"); - this.assertTrue(text.includes("−¤400"), "the building cash is listed"); - this.assertTrue(text.includes("materials valued at ¤700"), "the market value is noted"); + this.assertTrue(text.includes("−¤700"), "the rightmost column is the market value"); + this.assertTrue(text.includes("cash −¤400"), "the cash the treasury paid is noted"); this.assertTrue(text.includes("Unit upkeep"), "a unit category is listed"); this.assertTrue(text.includes("Bought Steel"), "a bought resource is listed"); this.assertTrue(text.includes("Sold Food"), "a sold resource is listed"); @@ -577,11 +638,13 @@ export class NationModalTest extends TestCase { this.assertTrue(env.$("#budget-sub").text().includes("Treasury")); this.assertTrue(env.$("#budget-sub").text().includes("January 2000"), "the month is named"); - // Expanding an upkeep row names the resources it bought. + // Expanding an upkeep row names the resources it bought and the buildings + // that consumed them. env.$("#budget-list tr .budget-name.clickable").first().click(); const expanded = env.$("#budget-list").text(); this.assertTrue(expanded.includes("Steel"), "the expanded row names steel"); this.assertTrue(expanded.includes("−¤250"), "and what it paid for it"); + this.assertTrue(expanded.includes("Barracks"), "and the building that used it"); // The pager steps back to the previous month's digest. env.$("#budget-months .budget-month-nav").first().click(); @@ -755,10 +818,39 @@ export class NationModalTest extends TestCase { }, privateCash: 5000, cities: [{ id: 1, civ: 0, name: "Paris", cashValue: 5000 }], + currencyHoldings: { + code: "FRF", + symbol: "₣", + name: "French Franc", + value: 1, + treasury: 1000, + treasuryDebt: 0, + own: [{ cityId: 1, name: "Paris", civ: 0, civName: "France", amount: 5000 }], + ownTotal: 5000, + foreign: [{ cityId: 2, name: "London", civ: 1, civName: "Britain", amount: 700 }], + foreignTotal: 700, + banks: [{ civ: 1, name: "Britain", amount: 1234 }], + bankTotal: 1234, + total: 7934, + }, + }; + const resourcesInfo = { + summary: { + stock: {}, + need: {}, + production: {}, + consumed: { steel: 1200, food: 10, luxury: 0, hightech: 0, energy: 0 }, + external: { + buy: { steel: { qty: 300, value: 180_000 } }, + sell: { food: { qty: 40, value: 12_000 } }, + }, + prices: {}, + }, + market: [], }; modal.show( GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, - { months: [], cities: [] }, null, [], null, null, null, money + { months: [], cities: [] }, null, [], null, null, resourcesInfo, money ); env.$("#modal-nation .tab[data-tab=economy]").click(); this.assertTrue(env.$("#tab-economy").hasClass("active")); @@ -775,6 +867,24 @@ export class NationModalTest extends TestCase { env.$("#economy-subtabs .subtab[data-view=rates]").click(); this.assertTrue(env.$("#economy-rates").text().includes("British Pound"), "the rate table shows"); this.assertTrue(env.$("#economy-rates").text().includes("0.8500"), "the rate is shown"); + + env.$("#economy-subtabs .subtab[data-view=consumption]").click(); + const consumption = env.$("#economy-consumption").text(); + this.assertTrue(consumption.includes("Steel"), "the consumed resource is listed"); + this.assertTrue(consumption.includes("1200"), "its daily consumption is shown"); + this.assertTrue(consumption.includes("180 000"), "what was spent abroad is shown"); + this.assertTrue(consumption.includes("40"), "what was sold abroad is shown"); + + env.$("#economy-subtabs .subtab[data-view=currency]").click(); + const currency = env.$("#economy-currency").text(); + this.assertTrue( + env.$("#economy-sub").text().includes("French Franc"), + "the currency is named" + ); + this.assertTrue(currency.includes("Paris"), "a home region holding it is listed"); + this.assertTrue(currency.includes("London"), "a foreign region holding it is listed"); + this.assertTrue(currency.includes("Central bank of Britain"), "a foreign bank is listed"); + this.assertTrue(currency.includes("7 934"), "the total in circulation is shown"); } finally { teardownDom(env); } diff --git a/tests/money_test.js b/tests/money_test.js index 822e0f5..c4b8307 100644 --- a/tests/money_test.js +++ b/tests/money_test.js @@ -39,6 +39,30 @@ export class MoneyTest extends TestCase { 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; @@ -60,14 +84,45 @@ export class MoneyTest extends TestCase { this.assertApprox(this.totalMoney(state), before, 1, "the spend moved money, it did not mint it"); } - test_a_market_purchase_neither_creates_nor_destroys_money() { + test_a_cross_border_purchase_neither_creates_nor_destroys_money() { const state = smallState(); state.setCurrencyValue = () => {}; - const city = cityOf(state, 0); + 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._chargeRegionCash(city, state.currencyOf(0).code, 2_000_000); - state._payWorld(0, 2_000_000, state.currencyOf(0).code); - this.assertApprox(this.totalMoney(state), before, 1, "the import stayed in the world"); + 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() { @@ -211,14 +266,15 @@ export class MoneyTest extends TestCase { test_a_broke_region_buys_by_borrowing() { const state = smallState(); const city = cityOf(state, 0); - const portIndex = state.protoBuildings.findIndex((b) => b.id === "port"); - const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c)); - if (!coastal) return; - coastal.buildings[portIndex] = 1; - this.assertTrue(state._civMarketAccess(0), "a port opens the market"); + 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._buyMarketMaterial(city, "food", 100, store); + 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"); } diff --git a/tests/politics_test.js b/tests/politics_test.js index 6fa54f3..d4c468d 100644 --- a/tests/politics_test.js +++ b/tests/politics_test.js @@ -372,6 +372,13 @@ export class PoliticsModelTest extends TestCase { state.budgets.set(1, 1e15); for (const city of state.cities) { state.getRegionCash(city).set(state.currencyOf(city.civ).code, 1e15); + // With no global market, money alone does not feed anyone: stock the + // stores so no shortage can drown out the museum. + const stock = state.getCityResourceStock(city); + stock.food = 1e15; + stock.steel = 1e15; + stock.luxury = 1e15; + stock.hightech = 1e15; } const museum = BUILDINGS.findIndex((b) => b.id === "museum"); const city = state.cities.find((c) => c.civ === 0); diff --git a/tests/regression_test.js b/tests/regression_test.js index 9d561b4..a4adf25 100644 --- a/tests/regression_test.js +++ b/tests/regression_test.js @@ -104,7 +104,7 @@ export class EconomyOrderEdgesTest extends TestCase { this.assertTrue(state.requestBuild(city.id, 0), "levels keep rising without a cap"); } - test_demolishing_removes_one_level_and_refunds() { + test_demolishing_removes_one_level_without_refunding() { const state = smallState(); const city = cityOf(state, 0); state.budgets.set(0, 1e15); @@ -113,7 +113,7 @@ export class EconomyOrderEdgesTest extends TestCase { const budget = state.getBudget(0); this.assertTrue(state.requestDemolish(city.id, 0)); this.assertEqual(state.getCityBuildingLevel(city, 0), 0); - this.assertGreater(state.getBudget(0), budget, "the refund raises the budget"); + this.assertApprox(state.getBudget(0), budget, 1e-6, "downgrading refunds nothing"); } test_cancelling_an_out_of_range_queue_entry_fails() { diff --git a/tests/resources_test.js b/tests/resources_test.js index 0b08a03..7155215 100644 --- a/tests/resources_test.js +++ b/tests/resources_test.js @@ -8,7 +8,7 @@ import { EFFECT_RESEARCH, technologyCost, } from "../shared/data.js"; -import { DAYS_PER_YEAR } from "../shared/game_state/constants.js"; +import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js"; import { formatEnergy, formatResourceAmount, @@ -128,6 +128,135 @@ export class ResourceModelTest extends TestCase { this.assertApprox(steel.sold, 400); } + test_external_trade_ledger_counts_buying_and_selling() { + const state = smallState(); + state._recordExternalBuy(0, "steel", 100, 1_000); + state._recordExternalSell(0, "food", 40, 500); + const summary = state.getCivResourceSummary(0); + this.assertApprox(summary.external.buy.steel.qty, 100); + this.assertApprox(summary.external.buy.steel.value, 1_000); + this.assertApprox(summary.external.sell.food.qty, 40); + this.assertApprox(summary.external.sell.food.value, 500); + } + + test_the_day_s_shortfalls_are_bought_abroad() { + const state = smallState(); + state.budgets.set(0, 1.0e15); + for (const city of state.cities) { + if (city.civ !== 0) continue; + const stock = state.getCityResourceStock(city); + stock.food = 0; + stock.steel = 0; + stock.luxury = 0; + } + for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour(); + const summary = state.getCivResourceSummary(0); + const bought = Object.values(summary.external.buy) + .reduce((sum, entry) => sum + entry.qty, 0); + this.assertGreater(bought, 0, "the day's shortfalls were bought across the border"); + const spent = Object.values(summary.external.buy) + .reduce((sum, entry) => sum + entry.value, 0); + this.assertGreater(spent, 0, "and the money it cost is recorded"); + } + + test_a_region_reports_what_it_ran_short_of() { + const state = smallState(); + const city = cityOf(state, 0); + state._resourceShortages.set(city.id, { steel: 0.5 }); + const shortages = state.getRegionShortages(city); + this.assertTrue(shortages.steel, "steel is reported"); + this.assertApprox(shortages.steel.fraction, 0.5); + const population = state.getCityEconomy(city).population; + this.assertApprox(shortages.steel.amount, 0.5 * steelNeedPerDay(population)); + } + + test_the_snapshot_ships_each_region_s_shortages() { + const state = smallState(); + const city = cityOf(state, 0); + state._resourceShortages.set(city.id, { food: 0.25 }); + const snapshot = state.snapshot(0); + const stats = snapshot.cityStats.find((entry) => entry.id === city.id); + this.assertTrue(stats.shortage.food, "the shortage is shipped"); + this.assertApprox(stats.shortage.food.fraction, 0.25); + } + + test_a_research_building_without_high_tech_raises_a_notice() { + const state = smallState(); + const city = cityOf(state, 0); + grantBuilding(state, city, "research_lab"); + for (const own of state.cities) { + if (own.civ === 0) state.getCityResourceStock(own).hightech = 0; + } + state._tickBuildings(); + const alert = state._resourceAlertsFor(0) + .find((entry) => entry.resource === "hightech" && entry.region === city.name); + this.assertNotNull(alert, "the lab's missing high-tech is reported"); + this.assertTrue(alert.building.includes("Research lab"), "the building is named"); + } + + test_a_power_starved_work_raises_a_notice() { + const state = smallState(); + state.budgets.set(0, 1e15); + const city = cityOf(state, 0); + const tile = freeLandTile(state, 0); + const k = placeResourceBuilding(state, tile, "steel_mill", 0); + const components = new Map([ + [k, 0], + [key(city.coords.x, city.coords.y), 0], + ]); + const ledger = new Map([[0, { civ: 0, industrialScale: 0.5 }]]); + state._noteGridPowerShortages(ledger, components); + const alert = state._resourceAlertsFor(0) + .find((entry) => entry.building === "Steel mill" && entry.resource === "energy"); + this.assertNotNull(alert, "the idle work is reported"); + this.assertEqual(alert.region, city.name); + } + + test_region_resource_info_reports_reserves_value_and_consumption() { + const state = smallState(); + const city = cityOf(state, 0); + const store = state.getCityResourceStock(city); + store.food = 1000; + store.steel = 2000; + store.hightech = 50; + store.luxury = 10; + const info = state.getRegionResourceInfo(city); + this.assertEqual(info.stock.food, 1000); + this.assertEqual(info.stock.steel, 2000); + const expected = 1000 * state.getResourcePrice("food") + + 2000 * state.getResourcePrice("steel") + + 50 * state.getResourcePrice("hightech") + + 10 * state.getResourcePrice("luxury"); + this.assertApprox(info.value, expected); + const population = state.getCityEconomy(city).population; + this.assertApprox(info.consumption.food, foodNeedPerDay(population)); + this.assertApprox(info.consumption.steel, steelNeedPerDay(population)); + this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population)); + this.assertTrue(info.consumption.energy > 0, "grid demand draws power"); + } + + test_city_stats_ship_the_region_resources() { + const state = smallState(); + const city = cityOf(state, 0); + state.getCityResourceStock(city).steel = 42; + const stats = state.snapshot(0).cityStats.find((entry) => entry.id === city.id); + this.assertNotNull(stats.resources, "the region's reserves travel in the snapshot"); + this.assertEqual(stats.resources.stock.steel, 42); + this.assertTrue(stats.resources.value > 0, "the reserve carries a value"); + } + + test_shortage_notices_are_private_to_the_owner() { + const state = smallState(); + state._noteResourceShortage(0, "University", "Paris", "hightech"); + const mine = state.snapshot(0).viewerStats.resourceAlerts; + const theirs = state.snapshot(1).viewerStats.resourceAlerts; + this.assertSize(mine, 1, "the owner sees the notice"); + this.assertSize(theirs, 0, "another nation does not"); + this.assertEqual(mine[0].building, "University"); + this.assertEqual(mine[0].resource, "hightech"); + this.assertGreaterOrEqual(mine[0].day, 0); + } + test_national_gdp_per_capita_matches_the_regions() { const state = smallState(); const snapshot = state.snapshot(0); @@ -145,29 +274,64 @@ export class ResourceModelTest extends TestCase { test_construction_draws_steel_from_the_city_before_the_market() { const state = smallState(); const city = cityOf(state, 0); + const code = state.currencyOf(0).code; const stock = state.getCityResourceStock(city); stock.steel = 1000; + const cashBefore = state.getRegionCash(city).get(code) || 0; const before = state.getBudget(0); const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 }); this.assertTrue(ok); this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store"); - this.assertApprox(state.getBudget(0), before, 1e-6, "no market bill was needed"); + // The region is not charged: the state buys the steel from it. + const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0); + this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it"); + this.assertApprox( + (state.getRegionCash(city).get(code) || 0) - cashBefore, + value, + 1e-3, + "the region received the money" + ); } - test_construction_buys_the_shortfall_on_the_market() { + test_drawing_a_national_store_pays_the_region() { + const state = smallState(); + const city = cityOf(state, 0); + const code = state.currencyOf(0).code; + state.getCityResourceStock(city).steel = 1000; + const cashBefore = state.getRegionCash(city).get(code) || 0; + const before = state.getBudget(0); + const payer = state._treasuryPayer(0); + const drawn = state._drawFromNearest( + 0, city.coords, { steel: 400, hightech: 0 }, payer + ); + this.assertApprox(drawn.steel, 400); + const value = 400 * state.getResourcePrice("steel") / state.currencyValue(0); + this.assertApprox(payer.total, value, 1e-3, "the treasury carried the bill"); + this.assertApprox(state.getBudget(0), before - value, 1e-3, "the state paid for it"); + this.assertApprox( + (state.getRegionCash(city).get(code) || 0) - cashBefore, + value, + 1e-3, + "the region's private sector received it" + ); + } + + test_construction_buys_the_shortfall_from_a_reachable_region() { const state = smallState(); state.budgets.set(0, 1e15); const city = cityOf(state, 0); - // Every city of the nation must be empty, or the draw would find another - // store before it reaches the market. + const seller = cityOf(state, 1); + // Every city of the nation must be empty, or the draw would find their + // stores before it reaches a foreign region. for (const own of state.cities) { if (own.civ === 0) state.getCityResourceStock(own).steel = 0; } - const price = state.getResourcePrice("steel"); + state.getCityResourceStock(seller).steel = 1.0e9; const before = state.getBudget(0); const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 }); - this.assertTrue(ok); - this.assertApprox(before - state.getBudget(0), 1000 * price, 1e-3); + this.assertTrue(ok, "the steel was found across the border"); + this.assertLess(state.getBudget(0), before, "the treasury paid for it"); + this.assertLess(state.getCityResourceStock(seller).steel, 1.0e9, "the seller's store shrank"); } test_research_spends_high_tech() { @@ -253,6 +417,65 @@ export class ResourceModelTest extends TestCase { ); } + test_materials_come_from_home_regions_before_foreign_ones() { + const state = smallState(); + const buyer = cityOf(state, 0); + const land = state.topology.neighbours(buyer.coords.x, buyer.coords.y) + .filter((n) => state._isLand(n)); + this.assertGreaterOrEqual(land.length, 2, "the city has land neighbours"); + for (const n of land) state.roads.add(key(n.x, n.y)); + // Two reachable suppliers, home and foreign, both with a surplus. Home wins. + const homeNode = { coords: land[0], civ: 0, stock: { food: 1.0e9 }, kind: "building" }; + const foreignNode = { coords: land[1], civ: 1, stock: { food: 1.0e9 }, kind: "building" }; + const store = state.getCityResourceStock(buyer); + const bought = state._procureFromNeighbours( + buyer, "food", 1000, store, [homeNode, foreignNode] + ); + this.assertGreater(bought, 0, "the order was filled"); + this.assertApprox(homeNode.stock.food, 1.0e9 - 1000, 1e-6, "the home region supplied it"); + this.assertApprox(foreignNode.stock.food, 1.0e9, 1e-6, "the foreign region was left alone"); + } + + test_without_a_market_unreachable_goods_are_not_bought() { + const state = smallState(); + const buyer = cityOf(state, 0); + for (const [, stock] of state.resourceStock) stock.food = 0; + state.roads.clear(); + state.railways.clear(); + const store = state.getCityResourceStock(buyer); + const bought = state._procureFromNeighbours( + buyer, "food", 1000, store, state._resourceNodes(), { allowSea: false } + ); + this.assertEqual(bought, 0, "no trade route, no goods"); + this.assertEqual(store.food, 0, "and nothing arrives"); + } + + test_power_flows_between_reachable_grids() { + const state = smallState(); + const city = cityOf(state, 0); + const neighbour = state.topology.neighbours(city.coords.x, city.coords.y) + .find((n) => state._isLand(n) && state.civAt(n) === 0); + this.assertNotNull(neighbour, "the city has an owned land neighbour"); + const nk = key(neighbour.x, neighbour.y); + state.roads.add(nk); + const components = new Map([ + [key(city.coords.x, city.coords.y), 0], + [nk, 1], + ]); + const ledger = new Map([ + [0, { + civ: 0, supply: 0, demand: 1000, imports: 0, + cityEnergy: 1000, converterEnergy: 0, foodEnergy: 0, + }], + [1, { + civ: 0, supply: 5000, demand: 0, imports: 0, + cityEnergy: 0, converterEnergy: 0, foodEnergy: 0, + }], + ]); + state._tradeGridPower(ledger, components); + this.assertApprox(ledger.get(0).imports, 1000, 1e-6, "the deficit grid imported the power"); + } + test_foreign_trade_runs_on_the_transport_network() { const state = smallState(); state.budgets.set(0, 1e15); @@ -480,6 +703,12 @@ export class ResourceModelTest extends TestCase { test_fusion_needs_its_technology() { const state = smallState(); state.budgets.set(0, 1e15); + for (const own of state.cities) { + if (own.civ !== 0) continue; + const stock = state.getCityResourceStock(own); + stock.steel = 1.0e12; + stock.hightech = 1.0e12; + } const tile = freeLandTile(state, 0); this.assertFalse( state.requestBuildTileImprovement(0, tile, "fusion_power_plant"), @@ -613,22 +842,18 @@ export class ResourceModelTest extends TestCase { this.assertFalse(inland && state.hasCityBuilding(inland, "port"), "an inland city does not"); } - test_a_city_without_a_port_has_no_foreign_market() { + test_a_city_without_a_port_has_no_sea_lane() { const state = smallState(); - state.budgets.set(0, 1e15); - const city = cityOf(state, 0); - const store = state.getCityResourceStock(city); const portIndex = state.protoBuildings.findIndex((b) => b.id === "port"); for (const own of state.cities) { if (own.civ === 0) delete own.buildings[portIndex]; } - this.assertFalse(state._civMarketAccess(0), "no working port, no market"); - this.assertEqual(state._buyMarketMaterial(city, "food", 100, store), 0, "nothing can be bought"); + this.assertFalse(state._civMarketAccess(0), "no working port, no sea trade"); const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c)); + this.assertNotNull(coastal, "the nation has a coastal city"); coastal.buildings[portIndex] = 1; - this.assertTrue(state._civMarketAccess(0), "the port opens the market"); - this.assertGreater(state._buyMarketMaterial(city, "food", 100, store), 0, "goods now arrive"); + this.assertTrue(state._civMarketAccess(0), "the port opens the sea lane"); } test_air_defense_spends_ammunition_when_it_fires() {