").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(
+ $("
").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 = $("
").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 = $("
").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() {