Let central banks work their reserves and cleared the landlocked famines

Central banks now redeem the national money foreign banks hold, sell reserves to defend a weak rate, and cover part of strategic imports from reserves. Five repeatable technologies raise each commodity's output per unit of energy. Hostile troops on a road or sea lane sever trade, power and unit supply. Middlemen buy food to resell to neighbours at a markup, and starving regions buy before those topping up their buffers, which clears the landlocked famines. The economic map's trade graph gains a colour legend.
This commit is contained in:
2026-09-22 15:28:11 +02:00
parent 3e429ccb64
commit 71dad18e8d
17 changed files with 857 additions and 21 deletions
+33
View File
@@ -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 {
+4
View File
@@ -136,6 +136,10 @@
<span id="map-legend-mid"></span>
<span id="map-legend-max"></span>
</div>
<div id="map-legend-graph" class="map-legend-graph hidden">
<div class="map-legend-title">Trade routes</div>
<div id="map-legend-resources" class="map-legend-resources"></div>
</div>
</div>
<div id="tile-panel" class="panel hidden">
+2
View File
@@ -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 = [];
+30
View File
@@ -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(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-swatch'></span>").css("background", resource.colour))
.append($("<span></span>").text(resource.name))
);
}
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-node'></span>"))
.append($("<span></span>").text("Storage node"))
);
},
// Political shows when the user asked for it, or while terrain mode is
+10
View File
@@ -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)}`;
}
+18
View File
@@ -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 = {
+9
View File
@@ -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 };
}
+26
View File
@@ -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
+64
View File
@@ -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) {
+18
View File
@@ -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
+175 -10
View File
@@ -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));
+130 -11
View File
@@ -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";
+92
View File
@@ -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);
}
}
+43
View File
@@ -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"
);
}
}
+34
View File
@@ -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);
+114
View File
@@ -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"
);
}
}
+55
View File
@@ -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"
);
}
}