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.
This commit is contained in:
2026-09-22 06:41:58 +02:00
parent e7ee5dbf0e
commit 91dfedf7ec
24 changed files with 1726 additions and 395 deletions
+3
View File
@@ -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);
+7
View File
@@ -225,6 +225,7 @@
<button class="tab active" data-tab="train">Train</button>
<button class="tab" data-tab="buildings">Buildings</button>
<button class="tab" data-tab="people">People</button>
<button class="tab" data-tab="resources">Resources</button>
<button class="tab" data-tab="budget">Budget</button>
<button class="tab" data-tab="economy">Economy</button>
</div>
@@ -244,6 +245,10 @@
<div id="city-people-sub" class="modal-sub"></div>
<div id="city-people-list" class="modal-body"></div>
</div>
<div class="tab-page" id="city-tab-resources">
<div id="city-resources-sub" class="modal-sub"></div>
<div id="city-resources-list" class="modal-body"></div>
</div>
<div class="tab-page" id="city-tab-budget">
<div id="city-budget-sub" class="modal-sub"></div>
<div id="city-budget-list" class="modal-body"></div>
@@ -351,6 +356,8 @@
<div id="economy-sub" class="modal-sub"></div>
<div id="economy-subtabs" class="subtabs"></div>
<div id="economy-summary" class="modal-body"></div>
<div id="economy-consumption" class="modal-body hidden"></div>
<div id="economy-currency" class="modal-body hidden"></div>
<div id="economy-central-bank" class="modal-body hidden"></div>
<div id="economy-rates" class="modal-body hidden"></div>
</div>
+3
View File
@@ -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.
+33 -1
View File
@@ -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
+23 -10
View File
@@ -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 class='budget-table'></table>");
$table.append($body);
$table.append($("<tfoot></tfoot>").append(monthTotalRow("TOTAL", report.net)));
$table.append($("<tfoot></tfoot>").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 class='budget-total'></tr>");
$tr.append($("<td class='budget-name'></td>").text(label));
$tr.append($("<td class='budget-value'></td>").text(money(value)));
const $name = $("<td class='budget-name'></td>").text(label);
if (note) $name.append($("<div class='budget-note'></div>").text(note));
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
return $tr;
}
+119 -8
View File
@@ -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($("<div class='desc'></div>").text("No population data."));
}
this._renderShortages(shortage || {});
if (taxMultiplier !== undefined && taxMultiplier < 0.999) {
this.$peopleList.append(
$("<div class='desc politics-penalty'></div>").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($("<div class='subhead'></div>").text("Resource shortages"));
if (entries.length === 0) {
this.$peopleList.append(
$("<div class='desc'></div>").text(
"No shortages: the region met all of its people's and industry's needs."
)
);
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("Short by"))
.append($("<th class='numeric'></th>").text("Share of need"))
);
const $body = $("<tbody></tbody>");
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(
$("<tr></tr>")
.append($("<td></td>").text(name))
.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, amount || 0))
)
.append(
$("<td class='numeric'></td>").text(`${Math.round((fraction || 0) * 100)}%`)
)
);
}
$table.append($head, $body);
this.$peopleList.append($table);
this.$peopleList.append(
$("<div class='desc'></div>").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(
$("<div class='desc'></div>").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 = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("Reserve"))
.append($("<th class='numeric'></th>").text("Consumption/day"))
.append($("<th class='numeric'></th>").text("Value"))
);
const $body = $("<tbody></tbody>");
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 = $("<tr></tr>").append($("<td></td>").text(name));
row.append(
$("<td class='numeric'></td>").text(storable ? formatResourceAmount(id, held) : "—")
);
row.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
);
row.append(
$("<td class='numeric'></td>").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);
}
+210 -12
View File
@@ -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(
$("<div class='desc'></div>").text("No resource data yet.")
);
return;
}
const consumed = summary.consumed || {};
const external = summary.external || { buy: {}, sell: {} };
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("Consumed / day"))
.append($("<th class='numeric'></th>").text("Bought abroad"))
.append($("<th class='numeric'></th>").text("Spent"))
.append($("<th class='numeric'></th>").text("Sold abroad"))
.append($("<th class='numeric'></th>").text("Earned"))
);
const $body = $("<tbody></tbody>");
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(
$("<tr></tr>")
.append($("<td></td>").text(name))
.append(
$("<td class='numeric'></td>").text(
formatResourceAmount(id, consumed[id] || 0)
)
)
.append($("<td class='numeric'></td>").text(
buy.qty > 0 ? formatResourceAmount(id, buy.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
buy.value > 0 ? this._amount(buy.value) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.qty > 0 ? formatResourceAmount(id, sell.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.value > 0 ? this._amount(sell.value) : "—"
))
);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td colspan='3'></td>").text("Trade balance"))
.append($("<td class='numeric'></td>").text(this._amount(totalSpent)))
.append($("<td></td>"))
.append($("<td class='numeric'></td>").text(this._amount(totalEarned)))
)
);
this.$economyConsumption.append($table);
this.$economyConsumption.append(
$("<div class='desc'></div>").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(
$("<div class='desc'></div>").text("No currency data yet.")
);
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $body = $("<tbody></tbody>");
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Treasury")
)
);
$body.append(
$("<tr></tr>")
.append($("<td></td>").text("Government"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.treasury || 0)))
);
if (holdings.treasuryDebt > 0) {
$body.append(
$("<tr class='expense-detail'></tr>")
.append($("<td></td>").text("Overdraft (debt)"))
.append($("<td class='numeric'></td>").text(`−${this._amount(holdings.treasuryDebt)}`))
);
}
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text(`Your regions (${(holdings.own || []).length})`)
)
);
for (const region of holdings.own || []) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(region.name))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
if ((holdings.foreign || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Held by foreign regions")
)
);
for (const region of holdings.foreign) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${region.name} (${region.civName})`))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
}
if ((holdings.banks || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Central-bank reserves")
)
);
for (const bank of holdings.banks) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`Central bank of ${bank.name}`))
.append($("<td class='numeric'></td>").text(this._amount(bank.amount)))
);
}
}
$table.append($body);
const $foot = $("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("Total in circulation"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.total || 0)))
);
$table.append($foot);
this.$economyCurrency.append($table);
this.$economyCurrency.append(
$("<div class='desc'></div>").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(
$("<div class='desc'></div>").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."
)
);
}
+2
View File
@@ -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();
+19 -3
View File
@@ -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({
+50 -17
View File
@@ -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;
+68 -21
View File
@@ -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
+6 -8
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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),
};
+1 -1
View File
@@ -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;
}
+23 -3
View File
@@ -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();
+16 -4
View File
@@ -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");
+20 -1
View File
@@ -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`);
}
+14 -11
View File
@@ -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"
);
}
}
+115 -5
View File
@@ -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);
}
+67 -11
View File
@@ -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");
}
+7
View File
@@ -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);
+2 -2
View File
@@ -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() {
+241 -16
View File
@@ -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() {