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:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Vendored
+9
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user