diff --git a/client/css/style.css b/client/css/style.css
index 440ffb1..c0e2bba 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -754,6 +754,39 @@ input:focus, select:focus { border-color: var(--accent); }
color: var(--text);
font-variant-numeric: tabular-nums;
}
+/* The trade-route key, shown under the colour scale in the economic modes. */
+.map-legend-graph {
+ margin-top: 10px;
+ border-top: 1px solid var(--panel-border);
+ padding-top: 8px;
+}
+.map-legend-resources {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--text);
+}
+.map-legend-resource {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.map-legend-swatch {
+ width: 16px;
+ height: 3px;
+ border-radius: 2px;
+ flex: none;
+}
+.map-legend-node {
+ width: 8px;
+ height: 8px;
+ margin: 0 4px;
+ border-radius: 50%;
+ background: #ffffff;
+ border: 1px solid rgba(0, 0, 0, 0.6);
+ flex: none;
+}
/* The inspected tile / city panel is anchored to the bottom right. */
#tile-panel {
diff --git a/client/index.html b/client/index.html
index a9bf023..a2eb631 100644
--- a/client/index.html
+++ b/client/index.html
@@ -136,6 +136,10 @@
+
diff --git a/client/js/map_view.js b/client/js/map_view.js
index 5f56061..914b72c 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -147,6 +147,8 @@ export class MapView {
this._fogChanged = null;
// The colour scale shown while an economic mode is active.
this.$legend = $("#map-legend");
+ this.$legendGraph = $("#map-legend-graph");
+ this.$legendResources = $("#map-legend-resources");
this._labelSvg = null;
this._labelEntries = [];
this._countryLabels = [];
diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js
index 7363580..95036bc 100644
--- a/client/js/map_view/modes.js
+++ b/client/js/map_view/modes.js
@@ -67,6 +67,7 @@ export const modeMethods = {
const graph = this.resourceGraph || { nodes: [], links: [] };
if (!this.economic || graph.links.length === 0) {
if (this.$resources[0].firstChild) this.$resources.empty();
+ this._renderTradeLegend();
return;
}
const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
@@ -86,6 +87,35 @@ export const modeMethods = {
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
+ this._renderTradeLegend();
+ },
+
+ // The key for the trade graph: one swatch per commodity whose deliveries are
+ // on screen, plus the storage-node marker. Shown only with the graph.
+ _renderTradeLegend() {
+ const $el = this.$legendGraph;
+ if (!$el || !$el.length) return;
+ const graph = this.resourceGraph || { nodes: [], links: [] };
+ if (!this.economic || graph.links.length === 0) {
+ $el.addClass("hidden");
+ return;
+ }
+ $el.removeClass("hidden");
+ const present = new Set(graph.links.map((link) => link[4]));
+ const $list = this.$legendResources.empty();
+ for (const resource of RESOURCES) {
+ if (!present.has(resource.id)) continue;
+ $list.append(
+ $("
")
+ .append($("
").css("background", resource.colour))
+ .append($("
").text(resource.name))
+ );
+ }
+ $list.append(
+ $("
")
+ .append($("
"))
+ .append($("
").text("Storage node"))
+ );
},
// Political shows when the user asked for it, or while terrain mode is
diff --git a/client/js/modals/format.js b/client/js/modals/format.js
index cb69995..68566f9 100644
--- a/client/js/modals/format.js
+++ b/client/js/modals/format.js
@@ -46,6 +46,16 @@ export function formatEffect(effect, level) {
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% construction speed`;
case "rail_speed":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% railway speed`;
+ case "energy_efficiency":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% electricity per fuel`;
+ case "steel_efficiency":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% steel per energy`;
+ case "food_efficiency":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% food per energy`;
+ case "luxury_efficiency":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% luxury per energy`;
+ case "hightech_efficiency":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% high-tech per energy`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
diff --git a/shared/data/economy.js b/shared/data/economy.js
index 535e4cc..c0b525f 100644
--- a/shared/data/economy.js
+++ b/shared/data/economy.js
@@ -60,6 +60,24 @@ export const MONEY = {
// raises the price it pays by this share, capped at `maxBidMultiplier`.
bidPremium: 0.3,
maxBidMultiplier: 2,
+ // The central bank's foreign-exchange desk. It works the reserves the nation
+ // earned abroad: it retires the national money foreign banks hold, sells more
+ // reserves to defend a weak rate, and pays part of the nation's foreign
+ // purchases out of reserves to keep food and energy imports cheap.
+ fx: {
+ // Share of the bank's foreign reserves spent redeeming the national money
+ // held abroad on an ordinary day.
+ redemptionPerDay: 0.004,
+ // Extra share sold on a day the national currency sits below `weakBelow`.
+ interventionPerDay: 0.05,
+ // The value against par the bank treats as a weak rate to defend.
+ weakBelow: 0.98,
+ // The share of a foreign purchase of each resource the bank covers from its
+ // reserves, and the most of its reserves it will spend subsidising imports
+ // in one day.
+ importSubsidy: { food: 0.5, energy: 0.5, steel: 0.25, luxury: 0, hightech: 0.25 },
+ importSubsidyPerDay: 0.02,
+ },
};
export const ECONOMY = {
diff --git a/shared/data/effects.js b/shared/data/effects.js
index faf3b40..92d976d 100644
--- a/shared/data/effects.js
+++ b/shared/data/effects.js
@@ -14,6 +14,15 @@ export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
export const EFFECT_RAIL_SPEED = "rail_speed";
+// Resource efficiency: the national bonus to a commodity's output per unit of
+// energy input. One technology per commodity raises its own stat, and the
+// simulation divides that commodity's energy draw by `1 + bonus`.
+export const EFFECT_ENERGY_EFFICIENCY = "energy_efficiency";
+export const EFFECT_STEEL_EFFICIENCY = "steel_efficiency";
+export const EFFECT_FOOD_EFFICIENCY = "food_efficiency";
+export const EFFECT_LUXURY_EFFICIENCY = "luxury_efficiency";
+export const EFFECT_HIGHTECH_EFFICIENCY = "hightech_efficiency";
+
export function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
}
diff --git a/shared/data/resources.js b/shared/data/resources.js
index 91526a5..7092ec0 100644
--- a/shared/data/resources.js
+++ b/shared/data/resources.js
@@ -6,6 +6,14 @@
// here are the main tuning knobs for the whole resource economy; the per-person
// needs and the energy-per-material figures come straight from the roadmap.
+import {
+ EFFECT_ENERGY_EFFICIENCY,
+ EFFECT_STEEL_EFFICIENCY,
+ EFFECT_FOOD_EFFICIENCY,
+ EFFECT_LUXURY_EFFICIENCY,
+ EFFECT_HIGHTECH_EFFICIENCY,
+} from "./effects.js";
+
// Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_000;
export const KWH_PER_GWH = 1_000_000;
@@ -73,6 +81,17 @@ export const RESOURCE_INDEX = Object.fromEntries(RESOURCES.map((r, i) => [r.id,
// The four stockable resources, in catalogue order. Energy is handled as a flow.
export const STORABLE_RESOURCE_IDS = RESOURCES.filter((r) => r.storable).map((r) => r.id);
+// The modifier each resource's efficiency technology writes. The simulation
+// divides that resource's energy draw by `1 + modifier`, so a higher figure is
+// more of the commodity produced for the same power.
+export const RESOURCE_EFFICIENCY_EFFECT = {
+ energy: EFFECT_ENERGY_EFFICIENCY,
+ steel: EFFECT_STEEL_EFFICIENCY,
+ food: EFFECT_FOOD_EFFICIENCY,
+ luxury: EFFECT_LUXURY_EFFICIENCY,
+ hightech: EFFECT_HIGHTECH_EFFICIENCY,
+};
+
export function resourceById(id) {
return RESOURCES[RESOURCE_INDEX[id]] || null;
}
@@ -240,6 +259,13 @@ export const RESOURCE_RULES = {
// global market.
export const RESOURCE_TRADE_RADIUS = 8;
+// A region that can reach a food surplus it does not need buys extra to resell
+// to nearby regions that cannot, acting as a middleman. The freight a buyer pays
+// on the longer outbound leg is its margin, so it only relays toward a market
+// farther than its own source, and stocks this many days of the neighbours'
+// shortfall.
+export const MIDDLEMAN = { coverDays: 3, markup: 0.25 };
+
// Resource-producing buildings. Built only outside cities, they occupy a land
// tile like a military improvement and are managed through the same tile-works
// queue. A power plant produces energy; every other building consumes energy
diff --git a/shared/data/technologies.js b/shared/data/technologies.js
index fd6568f..749a12d 100644
--- a/shared/data/technologies.js
+++ b/shared/data/technologies.js
@@ -16,6 +16,11 @@ import {
EFFECT_ARTILLERY_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
+ EFFECT_ENERGY_EFFICIENCY,
+ EFFECT_STEEL_EFFICIENCY,
+ EFFECT_FOOD_EFFICIENCY,
+ EFFECT_LUXURY_EFFICIENCY,
+ EFFECT_HIGHTECH_EFFICIENCY,
effect,
} from "./effects.js";
@@ -141,6 +146,65 @@ export const TECHNOLOGIES = [
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
},
+
+ // Efficiency research: one repeatable technology per commodity that raises the
+ // output the nation gets from each unit of energy. Each level adds 5% to that
+ // commodity's output per input, so the power drawn to make it falls.
+ {
+ id: "efficient_generation",
+ name: "Efficient Generation",
+ description: "Better turbines and heat recovery wring more electricity from every tonne of fuel.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.03,
+ prerequisites: ["renewable_energy"],
+ effects: [effect(EFFECT_ENERGY_EFFICIENCY, 0.05, 1.0)],
+ },
+ {
+ id: "advanced_metallurgy",
+ name: "Advanced Metallurgy",
+ description: "Continuous casting and heat recycling smelt more steel for less power.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.03,
+ prerequisites: ["industrial_automation"],
+ effects: [effect(EFFECT_STEEL_EFFICIENCY, 0.05, 1.0)],
+ },
+ {
+ id: "precision_agriculture",
+ name: "Precision Agriculture",
+ description: "Climate-controlled growing and tailored feed raise the food won from each unit of power, on the land and in synthesis.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.03,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)],
+ },
+ {
+ id: "automated_extraction",
+ name: "Automated Extraction",
+ description: "Automated mines and sorting win more gold and diamonds for the same power.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.03,
+ prerequisites: ["industrial_automation"],
+ effects: [effect(EFFECT_LUXURY_EFFICIENCY, 0.05, 1.0)],
+ },
+ {
+ id: "advanced_lithography",
+ name: "Advanced Lithography",
+ description: "Finer process nodes pack more chips onto every wafer, cutting the power each component costs.",
+ theme: "science",
+ cost: 80,
+ repeatable: true,
+ repeatCostGrowth: 0.03,
+ prerequisites: ["quantum_computing"],
+ effects: [effect(EFFECT_HIGHTECH_EFFICIENCY, 0.05, 1.0)],
+ },
];
export function technologyById(id) {
diff --git a/shared/game_state/industry.js b/shared/game_state/industry.js
index 08a83a6..f826a2c 100644
--- a/shared/game_state/industry.js
+++ b/shared/game_state/industry.js
@@ -18,6 +18,7 @@ import { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES } from "../data/resources.js";
import {
RESOURCE_BUILDING_UPGRADE,
+ RESOURCE_EFFICIENCY_EFFECT,
resourceBuildingOutputAt,
resourceBuildingInputAt,
resourceBuildingUpgradeMoneyCost,
@@ -75,6 +76,23 @@ export const industryMethods = {
return resourceBuildingInputAt(proto, this.resourceBuildingLevel(k), field);
},
+ // The nation's output-per-input bonus for one commodity, from its efficiency
+ // research. Energy drawn to make that commodity is divided by `1 + bonus`.
+ // Read from the technology modifiers alone, so seeding never primes the
+ // cached building-modifier totals with a half-built world.
+ resourceEfficiency(civ, resource) {
+ if (civ < 0) return 0;
+ const stat = RESOURCE_EFFICIENCY_EFFECT[resource];
+ if (!stat) return 0;
+ let cached = this._resourceEfficiencyCache ? this._resourceEfficiencyCache.get(civ) : null;
+ if (!cached || cached.version !== this._modifiersVersion) {
+ if (!this._resourceEfficiencyCache) this._resourceEfficiencyCache = new Map();
+ cached = { version: this._modifiersVersion, values: this.getCivTechnologyModifiers(civ) };
+ this._resourceEfficiencyCache.set(civ, cached);
+ }
+ return Math.max(0, cached.values[stat] || 0);
+ },
+
// ------------------------------------------------------------ money --
// A buyer's payment reaches the producing building, into its own cash, in the
diff --git a/shared/game_state/money.js b/shared/game_state/money.js
index 9257d37..e29f66a 100644
--- a/shared/game_state/money.js
+++ b/shared/game_state/money.js
@@ -33,6 +33,8 @@ export const moneyMethods = {
this.currencyValues = new Map();
this._currencyDemand = new Map();
this._currencyCivMap = null;
+ // What each central bank has spent subsidising imports in the current day.
+ this._fxSubsidySpent = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.centralBankRate.set(civ, MONEY.defaultInterestRate);
this.centralBankReserves.set(civ, emptyBasket());
@@ -234,6 +236,136 @@ export const moneyMethods = {
return total;
},
+ // ------------------------------------------------------ fx desk --
+
+ // Lower a central bank's holding of one currency, never below zero.
+ _reduceReserve(civ, code, amount) {
+ const basket = this.centralBankReserves ? this.centralBankReserves.get(civ) : null;
+ if (!basket || !(amount > 0)) return;
+ basket.set(code, Math.max(0, (basket.get(code) || 0) - amount));
+ },
+
+ // Retire part of a central bank's issued-currency liability.
+ _reduceCentralBankDebt(civ, code, amount) {
+ const debts = this.centralBankDebt ? this.centralBankDebt.get(civ) : null;
+ if (!debts || !(amount > 0)) return;
+ debts.set(code, Math.max(0, (debts.get(code) || 0) - amount));
+ },
+
+ // Which reserve to spend redeeming our own currency: prefer the holder's own
+ // currency, so both banks cancel a reserve, else our largest foreign balance.
+ _fxPaymentCurrency(civ, other, ownCode) {
+ const reserves = this.centralBankReserves.get(civ);
+ if (!reserves) return null;
+ const otherCode = this.currencyOf(other).code;
+ if (otherCode !== ownCode && (reserves.get(otherCode) || 0) > 0) return otherCode;
+ let best = null;
+ let bestAmount = 0;
+ for (const [code, amount] of reserves) {
+ if (code === ownCode || !(amount > 0)) continue;
+ if (amount > bestAmount) {
+ bestAmount = amount;
+ best = code;
+ }
+ }
+ return best;
+ },
+
+ // Spend up to `budget` (in the bank's own currency) of foreign reserves buying
+ // back the national money foreign banks hold, then retire it. The reserves
+ // change hands and the money leaves circulation, so the currency is bid for
+ // without anything being minted. Returns the own-currency amount redeemed.
+ _redeemOwnCurrency(civ, budget) {
+ const code = this.currencyOf(civ).code;
+ const ownValue = this.currencyValue(civ);
+ const reserves = this.centralBankReserves ? this.centralBankReserves.get(civ) : null;
+ if (!reserves || !(budget > 0)) return 0;
+ let remaining = budget;
+ let redeemed = 0;
+ for (let other = 0; other < this.civilisations.length && remaining > 0; other++) {
+ if (other === civ) continue;
+ const holder = this.centralBankReserves.get(other);
+ if (!holder) continue;
+ const held = holder.get(code) || 0;
+ if (!(held > 0)) continue;
+ const payCode = this._fxPaymentCurrency(civ, other, code);
+ if (!payCode) continue;
+ const payValue = this.currencyValueOf(payCode);
+ const havePay = reserves.get(payCode) || 0;
+ if (!(havePay > 0) || !(payValue > 0)) continue;
+ const take = Math.min(remaining, held);
+ let payQty = (take * ownValue) / payValue;
+ if (payQty > havePay) payQty = havePay;
+ if (!(payQty > 0)) continue;
+ const actual = (payQty * payValue) / ownValue;
+ reserves.set(payCode, havePay - payQty);
+ if (payCode === this.currencyOf(other).code) {
+ // Handing a bank its own money back retires its liability.
+ this._reduceCentralBankDebt(other, payCode, payQty);
+ } else {
+ this._addReserve(other, payCode, payQty);
+ }
+ holder.set(code, held - actual);
+ this._reduceCentralBankDebt(civ, code, actual);
+ // The bid for our money and the offer of the reserve move the day's rates.
+ this._recordCurrencyDemand(civ, actual * ownValue);
+ this._recordCurrencyDemand(other, -(payQty * payValue));
+ remaining -= actual;
+ redeemed += actual;
+ }
+ return redeemed;
+ },
+
+ // The day's reserve policy for one nation: retire a slice of the national
+ // money held abroad, and sell more reserves when the rate is weak.
+ _settleCentralBankFx(civ) {
+ if (!this.centralBankReserves) return 0;
+ const code = this.currencyOf(civ).code;
+ const reserves = this.centralBankReserves.get(civ);
+ if (!reserves) return 0;
+ const reserveValue = this._basketValue(reserves, civ);
+ if (!(reserveValue > 0)) return 0;
+ let heldAbroad = 0;
+ for (let other = 0; other < this.civilisations.length; other++) {
+ if (other === civ) continue;
+ const basket = this.centralBankReserves.get(other);
+ if (basket) heldAbroad += basket.get(code) || 0;
+ }
+ if (!(heldAbroad > 0)) return 0;
+ let budget = reserveValue * MONEY.fx.redemptionPerDay;
+ if (this.currencyValue(civ) < MONEY.fx.weakBelow) {
+ budget += reserveValue * MONEY.fx.interventionPerDay;
+ }
+ budget = Math.min(budget, heldAbroad);
+ if (!(budget > 0)) return 0;
+ return this._redeemOwnCurrency(civ, budget);
+ },
+
+ // What the central bank will cover of one foreign purchase, in global value,
+ // for the day's import subsidy. It is bounded by the day's budget; the bill
+ // is only actually paid when the seller's currency is in reserve.
+ _centralBankImportSubsidy(civ, resource, globalValue) {
+ if (!(globalValue > 0) || !this.centralBankReserves) return 0;
+ const rate = (MONEY.fx.importSubsidy && MONEY.fx.importSubsidy[resource]) || 0;
+ if (!(rate > 0)) return 0;
+ const reserves = this.centralBankReserves.get(civ);
+ if (!reserves) return 0;
+ const reserveValue = this._basketValue(reserves, civ);
+ if (!(reserveValue > 0)) return 0;
+ const spent = this._fxSubsidySpent ? (this._fxSubsidySpent.get(civ) || 0) : 0;
+ const budget = reserveValue * MONEY.fx.importSubsidyPerDay * this.currencyValue(civ) - spent;
+ if (!(budget > 0)) return 0;
+ return Math.min(globalValue * rate, budget);
+ },
+
+ // Record what a purchase actually drew from the subsidy budget, so the day's
+ // cap holds across every buyer.
+ _recordImportSubsidy(civ, globalValue) {
+ if (!(globalValue > 0)) return;
+ if (!this._fxSubsidySpent) this._fxSubsidySpent = new Map();
+ this._fxSubsidySpent.set(civ, (this._fxSubsidySpent.get(civ) || 0) + globalValue);
+ },
+
// 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
@@ -243,10 +375,16 @@ export const moneyMethods = {
//
// `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 };
+ // treasury, which holds no basket of its own. `subsidize` is a global value the
+ // buyer's central bank covers from its reserves of the seller's currency, so
+ // the buyer pays less for the same goods; the seller is still paid in full.
+ _regionAcquireCurrency(
+ buyerCity, targetCiv, globalValue, charge = null, useHeld = true, subsidize = 0
+ ) {
const target = this.currencyOf(targetCiv);
+ if (!(globalValue > 0)) {
+ return { code: target.code, amount: 0, spent: 0, subsidised: 0 };
+ }
const buyer = this.currencyOf(buyerCity.civ);
const vTarget = this.currencyValue(targetCiv);
const vBuyer = this.currencyValue(buyerCity.civ);
@@ -254,10 +392,23 @@ export const moneyMethods = {
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;
+ let remaining = needTarget - fromHeld;
+ // The buyer's central bank may cover part of the bill with the reserves of
+ // the seller's currency it holds.
+ let subsidised = 0;
+ if (subsidize > 0 && remaining > 0 && buyerCity.civ !== targetCiv) {
+ const bank = this.centralBankReserves ? this.centralBankReserves.get(buyerCity.civ) : null;
+ const bankHeld = bank ? (bank.get(target.code) || 0) : 0;
+ const cover = Math.min(remaining, bankHeld, subsidize / vTarget);
+ if (cover > 0) {
+ bank.set(target.code, bankHeld - cover);
+ subsidised = cover;
+ remaining -= cover;
+ }
+ }
// The buyer currency actually spent acquiring the foreign currency. The
- // held portion costs nothing, so an import tariff can fall only on what was
- // really exchanged.
+ // held and subsidised portions cost the buyer nothing, so an import tariff
+ // can fall only on what was really exchanged.
let spent = 0;
if (remaining > 0) {
const costBuyer = remaining * vTarget / vBuyer;
@@ -272,7 +423,12 @@ export const moneyMethods = {
this._addCentralBankDebt(targetCiv, target.code, remaining);
}
if (fromHeld > 0) basket.set(target.code, heldTarget - fromHeld);
- return { code: target.code, amount: fromHeld + remaining, spent };
+ return {
+ code: target.code,
+ amount: fromHeld + subsidised + remaining,
+ spent,
+ subsidised: subsidised * vTarget,
+ };
},
// --------------------------------------------------- demand/rates --
@@ -431,11 +587,13 @@ export const moneyMethods = {
// --------------------------------------------------- daily tick --
// The day's monetary work: debts to the central bank accrue interest, regions
- // repatriate any foreign money they still hold and pay down what they owe, and
- // the day's trading leaves each currency's value drifting with net demand. The
- // interest rate is the borrowing cost, not a direct inflation lever.
+ // repatriate any foreign money they still hold and pay down what they owe,
+ // the central banks work their reserves, and the day's trading leaves each
+ // currency's value drifting with net demand. The interest rate is the
+ // borrowing cost, not a direct inflation lever.
_tickMoney() {
if (!this.regionCash) return;
+ this._fxSubsidySpent = new Map();
for (const city of this.cities) {
const basket = this.regionCash.get(city.id);
const rate = this.getCentralBankRate(city.civ) / DAYS_PER_YEAR;
@@ -453,6 +611,13 @@ export const moneyMethods = {
const rate = this.getCentralBankRate(civ) / DAYS_PER_YEAR;
this.budgets.set(civ, budget * (1 + rate));
}
+ }
+ // The central banks spend their reserves before the day's rates are set, so
+ // an intervention shows up in the demand it records.
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ this._settleCentralBankFx(civ);
+ }
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
const demand = this._currencyDemand.get(civ) || 0;
const mass = this._currencyMass(civ);
const value = this.currencyValue(civ) * (1 + MONEY.demandSensitivity * (demand / mass));
diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js
index 3e46629..c743ed0 100644
--- a/shared/game_state/resources.js
+++ b/shared/game_state/resources.js
@@ -16,6 +16,7 @@ import {
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_TRADE_RADIUS,
+ MIDDLEMAN,
FAMINE,
tileImprovementById,
isResourceTileBuilding,
@@ -85,6 +86,9 @@ export const resourceMethods = {
}
this.resourceLinks = [];
this._resourceShortages = new Map();
+ // The food each region has bought to relay on to its neighbours, so only
+ // that stock carries the middleman margin when it is resold.
+ this._relayStock = new Map();
// The regions whose food store has run dry and cannot feed their people.
this._famine = new Set();
this._resourceVersion = 0;
@@ -1189,6 +1193,13 @@ export const resourceMethods = {
return false;
},
+ // The grid power a region's land harvest draws, after its food-efficiency
+ // research has cut the energy each tonne costs.
+ _foodHarvestEnergy(civ, produced) {
+ return producerEnergyPerDay("food", produced) /
+ (1 + this.resourceEfficiency(civ, "food"));
+ },
+
// ------------------------------------------------------------ daily tick --
_tickResources() {
@@ -1240,7 +1251,7 @@ export const resourceMethods = {
});
const component = ledger.get(components.get(k));
if (component) {
- const foodPower = producerEnergyPerDay("food", produced);
+ const foodPower = this._foodHarvestEnergy(component.civ, produced);
component.foodEnergy += foodPower;
component.foodConsumers.push({ city, amount: foodPower });
}
@@ -1268,12 +1279,17 @@ export const resourceMethods = {
for (const supplier of this._nearbySuppliers(city, "food", nodes, allowSea)) {
buyable += this._nodeSurplus(supplier, "food");
}
- const extra = synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable);
+ // Food efficiency cheapens synthesis, so it is worth growing more before
+ // the market wins; the energy it actually spends falls by the same share.
+ const foodEfficiency = 1 + this.resourceEfficiency(city.civ, "food");
+ const extra = synthesisedFoodFor(
+ foodPrice, energyPrice / foodEfficiency, shortfall, buyable
+ );
if (!(extra > 0)) continue;
const componentId = components.get(key(city.coords.x, city.coords.y));
const component = ledger.get(componentId);
if (!component) continue;
- const energy = foodSynthesisEnergy(extra);
+ const energy = foodSynthesisEnergy(extra) / foodEfficiency;
component.foodEnergy += energy;
component.foodConsumers.push({ city, amount: energy });
pendingFood.push({
@@ -1320,6 +1336,7 @@ export const resourceMethods = {
// Food lands in the region's store, scaled by the power the grid could give
// it. Food is served before the converters, so the harvest survives a
// power squeeze that idles the factories.
+ const grownFood = new Map();
for (const entry of pendingFood) {
const component = ledger.get(entry.component);
const scale = component ? component.foodScale : 1;
@@ -1327,8 +1344,13 @@ export const resourceMethods = {
if (produced <= 0) continue;
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
market.get("food").supply += produced;
+ grownFood.set(entry.cityId, (grownFood.get(entry.cityId) || 0) + produced);
}
+ // Middlemen top up for neighbours whose own power-limited harvest fell short
+ // before anyone buys, so the relay stock is on hand in the same day.
+ this._relayFood(grownFood, nodes);
+
// Converters turn the power they were allocated into their material, held
// at the building's own tile.
for (const [k, id] of this.tileImprovements) {
@@ -1470,6 +1492,9 @@ export const resourceMethods = {
const component = ledger.get(components.get(k));
if (!component) continue;
if (isResourceTileBuilding(proto)) {
+ // Efficiency research cuts the power each commodity costs: the draw is
+ // divided by `1 + bonus`, so the same output needs less energy.
+ const efficiency = 1 + this.resourceEfficiency(component.civ, proto.resource);
if (proto.resource === "energy") {
const output = this.resourceBuildingOutputAt(k, proto);
component.supply += output;
@@ -1478,12 +1503,12 @@ export const resourceMethods = {
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
const scale = this._converterProductionScale(k, proto, agent);
this._converterScales.set(k, scale);
- const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale;
+ const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale / efficiency;
component.converterEnergy += amount;
component.converters.push({ k, amount });
}
if (proto.fuelEnergyPerDay) {
- const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
+ const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay") / efficiency;
component.fuelEnergy += fuel;
component.cityEnergy += fuel;
}
@@ -1594,6 +1619,21 @@ export const resourceMethods = {
return null;
},
+ // The tiles held by hostile troops: an army camped on a road or a fleet in a
+ // sea lane severs the route while it stands there. Trade, grid power and unit
+ // supply searches all route around these tiles, so moving a unit in forces
+ // the next recalculation to find another way -- or none.
+ _hostileOccupied(civ) {
+ const blocked = new Set();
+ for (const unit of this.units) {
+ if (unit.civ === civ) continue;
+ if (!this.isAtWar(civ, unit.civ)) continue;
+ if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
+ blocked.add(key(unit.coords.x, unit.coords.y));
+ }
+ return blocked;
+ },
+
// 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
@@ -1602,13 +1642,14 @@ export const resourceMethods = {
const reach = new Map();
const origin = key(city.coords.x, city.coords.y);
const originComponent = components.get(origin);
+ const blocked = this._hostileOccupied(city.civ);
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;
+ if (visited.has(nk) || blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
@@ -1640,12 +1681,68 @@ export const resourceMethods = {
return reach;
},
+ // Middlemen: a region that can reach a food surplus it does not itself need
+ // buys extra to resell to nearby regions that have no reach of their own. It
+ // charges a margin on that resale, which covers its transport and turns a
+ // profit, so food hops past a region that is itself a net importer.
+ _relayFood(grownFood, nodes) {
+ const need = new Map();
+ const stock = new Map();
+ for (const city of this.cities) {
+ need.set(city.id, foodNeedPerDay(this.getCityEconomy(city).population));
+ stock.set(city.id, this.getCityResourceStock(city).food || 0);
+ }
+ // What each region actually grew today, and whether it can reach any food
+ // surplus on its own.
+ const sources = new Map();
+ const allowSea = new Map();
+ for (const city of this.cities) {
+ const sea = this._cityMarketAccess(city) && !this._isEncircled(city.coords, city.civ);
+ allowSea.set(city.id, sea);
+ sources.set(city.id, this._nearbySuppliers(city, "food", nodes, sea));
+ }
+ for (const city of this.cities) {
+ const suppliers = sources.get(city.id);
+ if (!suppliers || suppliers.length === 0) continue;
+ let relayDemand = 0;
+ for (const other of this.cities) {
+ if (other.id === city.id) continue;
+ if ((sources.get(other.id) || []).length > 0) continue;
+ const shortfall = Math.max(0, need.get(other.id) - (grownFood.get(other.id) || 0));
+ if (shortfall <= 0) continue;
+ if (stock.get(other.id) >= need.get(other.id) * 2) continue;
+ // The buyer must be able to reach this region's store.
+ if (this.topology.tileDistance(city.coords, other.coords) > RESOURCE_TRADE_RADIUS) {
+ continue;
+ }
+ relayDemand += shortfall;
+ }
+ if (!(relayDemand > 0)) continue;
+ const wanted = relayDemand * MIDDLEMAN.coverDays;
+ const store = this.getCityResourceStock(city);
+ const bought = this._procureFromNeighbours(city, "food", wanted, store, nodes, {
+ allowSea: allowSea.get(city.id),
+ });
+ if (bought > 0) {
+ this._relayStock.set(city.id, (this._relayStock.get(city.id) || 0) + bought);
+ }
+ }
+ },
+
_consumeCityResources(components, market, nodes = null) {
nodes = nodes || this._resourceNodes();
// How much of each supplier's surplus earlier buyers have already claimed
// today, so a contested surplus goes to whoever bids highest.
this._nodeClaims = new Map();
- for (const city of this.cities) {
+ // Starving regions buy first: a city topping up its month-long buffer must
+ // not strip a neighbour whose store is empty of the last reachable surplus.
+ const urgency = (city) => {
+ const store = this.getCityResourceStock(city);
+ const need = Math.max(1e-9, foodNeedPerDay(this.getCityEconomy(city).population));
+ return (store.food || 0) / need;
+ };
+ const order = this.cities.slice().sort((a, b) => urgency(a) - urgency(b));
+ for (const city of order) {
const population = this.getCityEconomy(city).population;
const needs = {
food: foodNeedPerDay(population),
@@ -1775,7 +1872,10 @@ export const resourceMethods = {
const { supplier, foreign, goods, freight } = quote;
const bought = Math.min(quote.available, remaining);
if (!(bought > 0)) continue;
- const value = (goods + freight) * bought;
+ // A region selling food it bought to relay charges its middleman margin on
+ // that stock, so the resale covers its transport and turns a profit.
+ const markup = this._middlemanMarkup(supplier, id, bought, price);
+ const value = (goods + freight) * bought + markup;
const vBuyer = this.currencyValue(city.civ);
const charge = payer
? (cost) => payer.charge(cost)
@@ -1787,10 +1887,14 @@ export const resourceMethods = {
// The seller's government charges the foreign buyer an export tariff,
// in the seller's currency, on top of the goods.
const exportTax = value * this.getTaxRate(supplier.civ, "export", id);
+ // The central bank pays part of the bill from its reserves, so a region
+ // or converter imports food and energy more cheaply than the market.
+ const subsidy = this._centralBankImportSubsidy(city.civ, id, value + exportTax);
const acquired = this._regionAcquireCurrency(
- city, supplier.civ, value + exportTax, charge, !payer
+ city, supplier.civ, value + exportTax, charge, !payer, subsidy
);
if (!acquired) continue;
+ if (acquired.subsidised > 0) this._recordImportSubsidy(city.civ, acquired.subsidised);
const total = value + exportTax;
const sellerShare = total > 0 ? value / total : 1;
const sellerAmount = acquired.amount * sellerShare;
@@ -1928,6 +2032,7 @@ export const resourceMethods = {
const results = [];
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y);
+ const blocked = this._hostileOccupied(city.civ);
const visited = new Set([origin]);
// Every delivery runs over the transport network: railways and roads on
// land, shipping lanes across water (which need a working port).
@@ -1948,7 +2053,7 @@ export const resourceMethods = {
if (current.tiles >= RESOURCE_TRADE_RADIUS) continue;
for (const neighbour of this._neighbours(current.coords)) {
const nk = key(neighbour.x, neighbour.y);
- if (visited.has(nk)) continue;
+ if (visited.has(nk) || blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
@@ -1981,6 +2086,19 @@ export const resourceMethods = {
return results;
},
+ // The middleman margin a region charges when it sells food it bought to relay.
+ // Only the stock it actually bought for resale carries the margin, and the
+ // stock is drawn down as it sells.
+ _middlemanMarkup(supplier, id, bought, price) {
+ if (id !== "food" || !this._relayStock || supplier.kind !== "city") return 0;
+ const cityId = supplier.city.id;
+ const held = this._relayStock.get(cityId) || 0;
+ if (!(held > 0)) return 0;
+ const sold = Math.min(held, bought);
+ this._relayStock.set(cityId, held - sold);
+ return sold * price * MIDDLEMAN.markup;
+ },
+
_nodeSurplus(node, id) {
if (id === "hightech") return Math.max(0, node.stock.hightech || 0);
const population = node.city ? this.getCityEconomy(node.city).population : 0;
@@ -2165,13 +2283,14 @@ export const resourceMethods = {
const here = this.cityAt(unit.coords);
if (here && here.civ === civ) return here;
if (this._hasSeaSupply(unit.coords, civ)) return this._nearestAlliedCity(civ, unit.coords);
+ const blocked = this._hostileOccupied(civ);
const visited = new Set([key(unit.coords.x, unit.coords.y)]);
const queue = [unit.coords];
while (queue.length > 0) {
const coords = queue.shift();
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
- if (visited.has(nk)) continue;
+ if (visited.has(nk) || blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile) continue;
const sea = tile.terrainClass === "Sea";
diff --git a/tests/central_bank_fx_test.js b/tests/central_bank_fx_test.js
new file mode 100644
index 0000000..f80fd56
--- /dev/null
+++ b/tests/central_bank_fx_test.js
@@ -0,0 +1,92 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { MONEY } from "../shared/data.js";
+
+// Gives both central banks a symmetric reserve of each other's currency and the
+// matching issued-currency debt, so the FX desk has something to work.
+function bookReserves(state, amount = 1000) {
+ const a = state.currencyOf(0).code;
+ const b = state.currencyOf(1).code;
+ state._addReserve(1, a, amount);
+ state._addReserve(0, b, amount);
+ state._addCentralBankDebt(0, a, amount);
+ state._addCentralBankDebt(1, b, amount);
+ return { a, b };
+}
+
+export class CentralBankFxTest extends TestCase {
+ test_redeeming_own_currency_retires_it_from_the_foreign_bank() {
+ const state = smallState();
+ const { a, b } = bookReserves(state, 1000);
+ const redeemed = state._redeemOwnCurrency(0, 500);
+ this.assertApprox(redeemed, 500);
+ // Britain hands back the francs it held; France hands back the pounds.
+ this.assertApprox(state.getCentralBankReserves(1).get(a), 500);
+ this.assertApprox(state.getCentralBankReserves(0).get(b), 500);
+ // Both issued-currency liabilities shrink with the money retired.
+ this.assertApprox(state.getCentralBankDebt(0).get(a), 500);
+ this.assertApprox(state.getCentralBankDebt(1).get(b), 500);
+ // The bid for francs and the offer of pounds register as currency demand.
+ this.assertGreater(state._currencyDemand.get(0) || 0, 0);
+ this.assertLess(state._currencyDemand.get(1) || 0, 0);
+ }
+
+ test_a_weak_rate_sells_more_reserves_than_a_strong_one() {
+ const strong = smallState();
+ bookReserves(strong, 1_000_000);
+ strong.setCurrencyValue(0, 1);
+ const ordinary = strong._settleCentralBankFx(0);
+
+ const weak = smallState();
+ bookReserves(weak, 1_000_000);
+ weak.setCurrencyValue(0, 0.9);
+ const defended = weak._settleCentralBankFx(0);
+
+ this.assertGreater(ordinary, 0, "the bank redeems even when the rate is calm");
+ this.assertGreater(defended, ordinary, "a weak rate is defended harder");
+ }
+
+ test_the_bank_leaves_its_reserves_alone_without_money_held_abroad() {
+ const state = smallState();
+ const b = state.currencyOf(1).code;
+ state._addReserve(0, b, 1_000_000);
+ this.assertApprox(state._settleCentralBankFx(0), 0);
+ }
+
+ test_the_import_subsidy_draws_on_its_own_currency_reserves() {
+ const state = smallState();
+ const buyer = cityOf(state, 0);
+ const a = state.currencyOf(0).code;
+ const b = state.currencyOf(1).code;
+ state._addReserve(0, b, 1_000_000);
+ const before = state.getRegionCash(buyer).get(a) || 0;
+ const acquired = state._regionAcquireCurrency(buyer, 1, 1000, null, true, 400);
+ this.assertApprox(acquired.subsidised, 400, 1e-6, "the bank covered its share");
+ this.assertApprox(acquired.amount, 1000, 1e-6, "the seller is paid in full");
+ this.assertApprox(
+ state.getCentralBankReserves(0).get(b), 999_600, 1e-6,
+ "the subsidy came out of reserves"
+ );
+ const after = state.getRegionCash(buyer).get(a) || 0;
+ this.assertApprox(before - after, 600, 1e-6, "the buyer paid only the rest");
+ }
+
+ test_the_import_subsidy_budget_is_capped_for_the_day() {
+ const state = smallState();
+ const b = state.currencyOf(1).code;
+ state._addReserve(0, b, 1_000_000);
+ state._fxSubsidySpent = new Map();
+ const budget = 1_000_000 * MONEY.fx.importSubsidyPerDay;
+ const first = state._centralBankImportSubsidy(0, "food", 1_000_000);
+ this.assertApprox(first, budget, 1e-6);
+ state._recordImportSubsidy(0, first);
+ this.assertApprox(state._centralBankImportSubsidy(0, "food", 1_000_000), 0);
+ }
+
+ test_no_subsidy_for_a_resource_the_bank_does_not_back() {
+ const state = smallState();
+ const b = state.currencyOf(1).code;
+ state._addReserve(0, b, 1_000_000);
+ this.assertEqual(state._centralBankImportSubsidy(0, "luxury", 1_000_000), 0);
+ }
+}
diff --git a/tests/food_security_test.js b/tests/food_security_test.js
new file mode 100644
index 0000000..ad1ad68
--- /dev/null
+++ b/tests/food_security_test.js
@@ -0,0 +1,43 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { RESOURCE_IDS, MIDDLEMAN } from "../shared/data.js";
+import { foodNeedPerDay } from "../shared/resources.js";
+
+export class FoodSecurityTest extends TestCase {
+ test_starving_regions_buy_before_stocked_ones() {
+ const state = smallState();
+ const cities = state.cities.filter((city) => city.civ === 0);
+ this.assertGreater(cities.length, 1, "the fixture has two regions");
+ const starving = cities[0];
+ const stocked = cities[1];
+ state.getCityResourceStock(starving).food = 0;
+ state.getCityResourceStock(stocked).food =
+ foodNeedPerDay(state.getCityEconomy(stocked).population) * 5;
+ const order = [];
+ const orig = state._procureFromNeighbours.bind(state);
+ state._procureFromNeighbours = function (city, ...rest) {
+ order.push(city.id);
+ return orig(city, ...rest);
+ };
+ const market = new Map(RESOURCE_IDS.map((id) => [
+ id, { supply: 0, demand: 0, price: state.getResourcePrice(id) },
+ ]));
+ state._consumeCityResources(new Map(), market);
+ this.assertEqual(order[0], starving.id, "the empty region buys first");
+ }
+
+ test_a_middleman_charges_a_margin_on_relayed_food() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ state._relayStock.set(city.id, 100);
+ const price = state.getResourcePrice("food");
+ const supplier = { kind: "city", city };
+ const markup = state._middlemanMarkup(supplier, "food", 60, price);
+ this.assertApprox(markup, 60 * price * MIDDLEMAN.markup);
+ this.assertApprox(state._relayStock.get(city.id), 40, 1e-9, "only the sold stock is drawn");
+ this.assertEqual(
+ state._middlemanMarkup(supplier, "steel", 10, price), 0,
+ "only food carries the margin"
+ );
+ }
+}
diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js
index e15dca5..fb496db 100644
--- a/tests/map_modes_test.js
+++ b/tests/map_modes_test.js
@@ -159,6 +159,40 @@ export class MapModesTest extends TestCase {
}
}
+ async test_the_delivery_graph_has_a_colour_legend() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ map.setMapMode("population");
+ map.setResourceGraph({
+ nodes: [[1, 1, 1]],
+ links: [[0, 0, 1, 1, "steel", 10], [2, 2, 3, 3, "food", 5]],
+ });
+ this.assertFalse(
+ env.$("#map-legend-graph").hasClass("hidden"), "the trade-route key is shown"
+ );
+ const labels = env.$("#map-legend-resources .map-legend-resource")
+ .map((_, el) => env.$(el).text()).get();
+ this.assertTrue(labels.some((t) => t.includes("Steel")), "steel is keyed");
+ this.assertTrue(labels.some((t) => t.includes("Food")), "food is keyed");
+ this.assertTrue(labels.some((t) => t.includes("Storage node")), "the node marker is keyed");
+ this.assertSize(
+ env.$("#map-legend-resources .map-legend-swatch"), 2,
+ "one swatch per commodity on the map"
+ );
+ map.setMapMode("terrain");
+ map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
+ this.assertTrue(
+ env.$("#map-legend-graph").hasClass("hidden"),
+ "the key hides off the economic map"
+ );
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_fog_rebuilds_are_coalesced_between_frames() {
const env = await setupDom();
const map = this.buildMap(env);
diff --git a/tests/resource_efficiency_test.js b/tests/resource_efficiency_test.js
new file mode 100644
index 0000000..e168bb8
--- /dev/null
+++ b/tests/resource_efficiency_test.js
@@ -0,0 +1,114 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState } from "./framework/helpers.js";
+import { key } from "../shared/hex.js";
+import {
+ RESOURCE_IDS,
+ RESOURCE_EFFICIENCY_EFFECT,
+ TECHNOLOGIES,
+} from "../shared/data.js";
+import { producerEnergyPerDay } from "../shared/resources.js";
+
+// Puts a resource building on the first free land tile of `civ`.
+function placeProducer(state, id, civ = 0) {
+ for (const coords of state._territoryByCiv.get(civ) || []) {
+ if (state.cityAt(coords)) continue;
+ if (!state._isLand(coords)) continue;
+ const k = key(coords.x, coords.y);
+ if (state.tileImprovements.has(k)) continue;
+ state.tileImprovements.set(k, id);
+ state.tileImprovementOwner.set(k, civ);
+ state.tileImprovementHp.set(k, 1000);
+ state._ensureResourceStock(k);
+ state._ensureBuildingAgent(k);
+ return { coords, k };
+ }
+ return null;
+}
+
+// Studies one level of a repeatable technology for a nation.
+function research(state, civ, id) {
+ const index = state.technologies.findIndex((tech) => tech.id === id);
+ state.researched.get(civ).add(index);
+ const counts = state.repeatCounts.get(civ);
+ counts.set(id, (counts.get(id) || 0) + 1);
+ state._touchModifiers();
+}
+
+export class ResourceEfficiencyDataTest extends TestCase {
+ test_there_is_exactly_one_efficiency_technology_per_resource() {
+ for (const resource of RESOURCE_IDS) {
+ const stat = RESOURCE_EFFICIENCY_EFFECT[resource];
+ const matching = TECHNOLOGIES.filter(
+ (tech) => (tech.effects || []).some((e) => e.stat === stat)
+ );
+ this.assertSize(matching, 1, `one technology raises ${resource} output per input`);
+ this.assertTrue(matching[0].repeatable, `${resource} efficiency is repeatable`);
+ }
+ }
+}
+
+export class ResourceEfficiencyModelTest extends TestCase {
+ test_efficiency_is_zero_until_the_technology_is_researched() {
+ const state = smallState();
+ this.assertApprox(state.resourceEfficiency(0, "steel"), 0);
+ research(state, 0, "advanced_metallurgy");
+ this.assertApprox(state.resourceEfficiency(0, "steel"), 0.05);
+ research(state, 0, "advanced_metallurgy");
+ this.assertApprox(state.resourceEfficiency(0, "steel"), 0.10, 1e-9);
+ this.assertApprox(state.resourceEfficiency(1, "steel"), 0, 1e-9, "research is national");
+ }
+
+ test_steel_efficiency_cuts_the_mill_s_energy_draw() {
+ const state = smallState();
+ const placed = placeProducer(state, "steel_mill");
+ const components = state._resourceLandComponents();
+ const componentId = components.get(placed.k);
+ const draw = () => state._buildEnergyLedger(components).get(componentId).converters
+ .find((entry) => entry.k === placed.k).amount;
+ const before = draw();
+ this.assertGreater(before, 0);
+ research(state, 0, "advanced_metallurgy");
+ const after = draw();
+ this.assertApprox(before / after, 1.05, 1e-6, "the same steel costs 5% less power");
+ }
+
+ test_energy_efficiency_cuts_the_plant_s_fuel() {
+ const state = smallState();
+ const placed = placeProducer(state, "coal_power_plant");
+ const components = state._resourceLandComponents();
+ const componentId = components.get(placed.k);
+ const fuel = () => state._buildEnergyLedger(components).get(componentId).fuelEnergy;
+ const before = fuel();
+ this.assertGreater(before, 0);
+ research(state, 0, "efficient_generation");
+ const after = fuel();
+ this.assertApprox(before / after, 1.05, 1e-6, "the same power burns 5% less fuel");
+ }
+
+ test_every_efficiency_technology_reads_back_on_its_own_resource() {
+ const state = smallState();
+ const byResource = {
+ energy: "efficient_generation",
+ steel: "advanced_metallurgy",
+ food: "precision_agriculture",
+ luxury: "automated_extraction",
+ hightech: "advanced_lithography",
+ };
+ for (const [resource, tech] of Object.entries(byResource)) {
+ research(state, 0, tech);
+ this.assertApprox(state.resourceEfficiency(0, resource), 0.05, 1e-9, resource);
+ }
+ }
+
+ test_food_efficiency_cuts_the_harvest_s_energy() {
+ const state = smallState();
+ this.assertApprox(
+ state._foodHarvestEnergy(0, 1000), producerEnergyPerDay("food", 1000)
+ );
+ research(state, 0, "precision_agriculture");
+ this.assertApprox(
+ state._foodHarvestEnergy(0, 1000), producerEnergyPerDay("food", 1000) / 1.05, 1e-6,
+ "better farming grows the same food for less power"
+ );
+ }
+}
diff --git a/tests/trade_route_test.js b/tests/trade_route_test.js
new file mode 100644
index 0000000..e5c2650
--- /dev/null
+++ b/tests/trade_route_test.js
@@ -0,0 +1,55 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { key } from "../shared/hex.js";
+
+// Puts a food-holding producer on an adjacent owned land tile and wires the road,
+// so it is a reachable supplier for the city.
+function placeSupplier(state, city) {
+ const neighbour = state.topology.neighbours(city.coords.x, city.coords.y)
+ .find((n) => state._isLand(n) && state.civAt(n) === city.civ);
+ const k = key(neighbour.x, neighbour.y);
+ if (!state.roads.has(k) && !state.railways.has(k)) state.roads.add(k);
+ state.tileImprovements.set(k, "steel_mill");
+ state.tileImprovementOwner.set(k, city.civ);
+ state.tileImprovementHp.set(k, 1000);
+ state._ensureResourceStock(k).food = 1_000_000;
+ state._ensureBuildingAgent(k);
+ return { coords: neighbour, k };
+}
+
+function hasSupplierAt(result, coords) {
+ return result.some((entry) => entry.coords.x === coords.x && entry.coords.y === coords.y);
+}
+
+export class TradeRouteTest extends TestCase {
+ test_a_hostile_unit_cuts_a_trade_route() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const supplier = placeSupplier(state, city);
+ const nodes = state._resourceNodes();
+ this.assertTrue(
+ hasSupplierAt(state._nearbySuppliers(city, "food", nodes), supplier.coords),
+ "the supplier is reachable while the road is clear"
+ );
+ // A hostile army standing on the road closes it.
+ state._declareWar(0, 1);
+ state._spawnUnit(supplier.coords, 1, state.protoUnits[0]);
+ this.assertFalse(
+ hasSupplierAt(state._nearbySuppliers(city, "food", nodes), supplier.coords),
+ "the hostile unit cut the route"
+ );
+ }
+
+ test_a_neutral_unit_leaves_the_route_open() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const supplier = placeSupplier(state, city);
+ // No war: the same troops are neutral traffic.
+ state._spawnUnit(supplier.coords, 1, state.protoUnits[0]);
+ const nodes = state._resourceNodes();
+ this.assertTrue(
+ hasSupplierAt(state._nearbySuppliers(city, "food", nodes), supplier.coords),
+ "neutral troops do not cut the route"
+ );
+ }
+}